Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { vi, expect, describe, it } from 'vitest';
import { deepMerge } from './framework-preset-angular-cli';

describe('Angular CLI Framework Preset - Deep Merge Fix', () => {
describe('deepMerge', () => {
it('should preserve stylePreprocessorOptions.includePaths from browserTarget when not in storybook options', () => {
const browserTargetOptions = {
stylePreprocessorOptions: {
includePaths: ['src/styles', 'node_modules'],
},
assets: ['src/assets'],
styles: ['src/styles.scss'],
};

const storybookOptions = {};

const result = deepMerge(browserTargetOptions, storybookOptions);

expect(result.stylePreprocessorOptions).toEqual({
includePaths: ['src/styles', 'node_modules'],
});
expect(result.assets).toEqual(['src/assets']);
expect(result.styles).toEqual(['src/styles.scss']);
});

it('should override browserTarget options with storybook-specific options when provided', () => {
const browserTargetOptions = {
stylePreprocessorOptions: {
includePaths: ['src/styles'],
},
assets: ['src/assets'],
styles: ['src/styles.scss'],
};

const storybookOptions = {
stylePreprocessorOptions: {
includePaths: ['storybook/styles'],
},
preserveSymlinks: true,
};

const result = deepMerge(browserTargetOptions, storybookOptions);

expect(result.stylePreprocessorOptions).toEqual({
includePaths: ['storybook/styles'],
});
expect(result.assets).toEqual(['src/assets']);
expect(result.styles).toEqual(['src/styles.scss']);
expect(result.preserveSymlinks).toBe(true);
});

it('should deeply merge stylePreprocessorOptions instead of overriding completely', () => {
const browserTargetOptions = {
stylePreprocessorOptions: {
includePaths: ['src/styles'],
precision: 8,
},
};

const storybookOptions = {
stylePreprocessorOptions: {
additionalData: '@import "storybook-vars";',
},
};

const result = deepMerge(browserTargetOptions, storybookOptions);

expect(result.stylePreprocessorOptions).toEqual({
includePaths: ['src/styles'],
precision: 8,
additionalData: '@import "storybook-vars";',
});
});

it('should handle arrays correctly (override instead of merge)', () => {
const browserTargetOptions = {
assets: ['src/assets', 'src/fonts'],
styles: ['src/styles.scss'],
};

const storybookOptions = {
assets: ['storybook/assets'],
};

const result = deepMerge(browserTargetOptions, storybookOptions);

expect(result.assets).toEqual(['storybook/assets']);
expect(result.styles).toEqual(['src/styles.scss']);
});

it('should handle null and undefined values correctly', () => {
const browserTargetOptions = {
stylePreprocessorOptions: {
includePaths: ['src/styles'],
},
assets: ['src/assets'],
};

const storybookOptions: any = {
stylePreprocessorOptions: null,
assets: undefined,
newOption: 'value',
};

const result = deepMerge(browserTargetOptions, storybookOptions);

// Null and undefined should not override existing values
expect(result.stylePreprocessorOptions).toEqual({
includePaths: ['src/styles'],
});
expect(result.assets).toEqual(['src/assets']);
expect(result.newOption).toBe('value');
});

it('should preserve complex nested structures', () => {
const browserTargetOptions = {
buildOptimizer: false,
optimization: {
scripts: true,
styles: {
minify: true,
inlineCritical: false,
},
},
};

const storybookOptions = {
optimization: {
styles: {
inlineCritical: true,
},
},
};

const result = deepMerge(browserTargetOptions, storybookOptions);

expect(result.optimization).toEqual({
scripts: true,
styles: {
minify: true,
inlineCritical: true,
},
});
expect(result.buildOptimizer).toBe(false);
});
});
});
54 changes: 45 additions & 9 deletions code/frameworks/angular/src/server/framework-preset-angular-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,35 @@ function getBuilderContext(options: PresetOptions): BuilderContext {
);
}

/**
* Deep merge function that properly handles nested objects
* Preserves arrays and objects from source when they exist in target
* @internal - exported for testing purposes
*/
export function deepMerge(target: JsonObject, source: JsonObject): JsonObject {
const result = { ...target };

for (const key in source) {
if (source[key] !== undefined && source[key] !== null) {
if (
typeof source[key] === 'object' &&
!Array.isArray(source[key]) &&
typeof target[key] === 'object' &&
!Array.isArray(target[key]) &&
target[key] !== null
) {
// Deep merge nested objects
result[key] = deepMerge(target[key] as JsonObject, source[key] as JsonObject);
} else {
// Override with source value
result[key] = source[key];
}
}
}

return result;
}

/** Get builder options Merge target options from browser target and from storybook options */
async function getBuilderOptions(options: PresetOptions, builderContext: BuilderContext) {
/** Get Browser Target options */
Expand All @@ -95,15 +124,22 @@ async function getBuilderOptions(options: PresetOptions, builderContext: Builder
browserTargetOptions = await builderContext.getTargetOptions(browserTarget);
}

/** Merge target options from browser target options and from storybook options */
const builderOptions = {
...browserTargetOptions,
...options.angularBuilderOptions,
tsConfig:
options.tsConfig ??
(await findUp('tsconfig.json', { cwd: options.configDir, stopAt: getProjectRoot() })) ??
browserTargetOptions.tsConfig,
};
/**
* Merge target options from browser target options and from storybook options
* Use deep merge to preserve nested properties like stylePreprocessorOptions.includePaths
* when they exist in browserTarget but not in storybook options
*/
const builderOptions = deepMerge(
browserTargetOptions,
options.angularBuilderOptions || {}
);

// Handle tsConfig separately to maintain existing logic
builderOptions.tsConfig =
options.tsConfig ??
(await findUp('tsconfig.json', { cwd: options.configDir, stopAt: getProjectRoot() })) ??
browserTargetOptions.tsConfig;

logger.info(`=> Using angular project with "tsConfig:${builderOptions.tsConfig}"`);

return builderOptions;
Expand Down
Loading