diff --git a/merge-schemes.ts b/merge-schemes.ts index ce1a742806..3e1d76e393 100644 --- a/merge-schemes.ts +++ b/merge-schemes.ts @@ -1,5 +1,4 @@ import { writeFileSync } from 'fs'; -import { mergeWith } from 'lodash'; import { resolvePackagePath } from './packages/common/src'; interface CustomSchema { @@ -9,6 +8,39 @@ interface CustomSchema { newSchemaPath: string; } +/** + * Deep merge two objects, invoking a customizer for each key. + * If the customizer returns `undefined`, the default deep-merge behavior applies. + */ +function deepMergeWith( + target: any, + source: any, + customizer: (targetVal: any, sourceVal: any) => any +): any { + if (source === undefined || source === null) { + return target; + } + const result = Array.isArray(target) ? [...target] : { ...target }; + for (const key of Object.keys(source)) { + const customResult = customizer(result[key], source[key]); + if (customResult !== undefined) { + result[key] = customResult; + } else if ( + typeof result[key] === 'object' && + result[key] !== null && + !Array.isArray(result[key]) && + typeof source[key] === 'object' && + source[key] !== null && + !Array.isArray(source[key]) + ) { + result[key] = deepMergeWith(result[key], source[key], customizer); + } else { + result[key] = source[key]; + } + } + return result; +} + const wd = process.cwd(); const schemesToMerge: CustomSchema[] = require(`${wd}/src/schemes`); @@ -27,7 +59,7 @@ for (const { const schemaExtensions = schemaExtensionPaths.map((path: string) => require(path)); const newSchema = schemaExtensions.reduce( (extendedSchema: any, currentExtension: any) => - mergeWith(extendedSchema, currentExtension, schemaMerger), + deepMergeWith(extendedSchema, currentExtension, schemaMerger), originalSchema ); writeFileSync(newSchemaPath, JSON.stringify(newSchema, schemaValueReplacer, 2), 'utf-8'); @@ -37,6 +69,7 @@ function schemaMerger(resultSchemaValue: unknown, extensionSchemaValue: unknown) if (Array.isArray(extensionSchemaValue) && extensionSchemaValue[0] === '__REPLACE__') { return extensionSchemaValue.slice(1); } + return undefined; } function schemaValueReplacer(key: unknown, value: unknown) { diff --git a/package.json b/package.json index e269a8f715..eefb3e0d68 100644 --- a/package.json +++ b/package.json @@ -37,11 +37,9 @@ "@commitlint/config-conventional": "^20.0.0", "@lerna-lite/cli": "^4.10.5", "@lerna-lite/publish": "^4.10.5", - "@types/lodash": "^4.14.118", "@types/node": "^24.0.0", "husky": "^9.0.0", "lint-staged": "^16.0.0", - "lodash": "^4.17.15", "prettier": "^3.0.0", "ts-jest": "29.4.6", "turbo": "^2.4.0", diff --git a/packages/custom-esbuild/e2e/custom-esbuild-schema.spec.ts b/packages/custom-esbuild/e2e/custom-esbuild-schema.spec.ts index 93430b8318..78295f0c1d 100644 --- a/packages/custom-esbuild/e2e/custom-esbuild-schema.spec.ts +++ b/packages/custom-esbuild/e2e/custom-esbuild-schema.spec.ts @@ -1,5 +1,4 @@ import { resolvePackagePath } from '@angular-builders/common'; -import { remove } from 'lodash'; describe('Custom ESBuild schema tests', () => { let customEsbuildApplicationSchema: any; @@ -32,7 +31,9 @@ describe('Custom ESBuild schema tests', () => { const path = resolvePackagePath('@angular/build', 'src/builders/unit-test/schema.json'); const originalUnitTestSchema = require(path); originalUnitTestSchema.properties['runner'] = undefined; - remove(originalUnitTestSchema.required, prop => prop === 'runner'); + originalUnitTestSchema.required = originalUnitTestSchema.required.filter( + (prop: string) => prop !== 'runner' + ); customEsbuildUnitTestSchema.properties['plugins'] = undefined; expect(originalUnitTestSchema.properties).toEqual(customEsbuildUnitTestSchema.properties); expect(originalUnitTestSchema.required).toEqual(customEsbuildUnitTestSchema.required); diff --git a/packages/custom-webpack/package.json b/packages/custom-webpack/package.json index 176d9b5a18..b92a3e339b 100644 --- a/packages/custom-webpack/package.json +++ b/packages/custom-webpack/package.json @@ -44,7 +44,6 @@ "@angular-devkit/build-angular": "^21.0.0", "@angular-devkit/core": "^21.0.0", "@angular/build": "^21.0.0", - "lodash": "^4.17.15", "webpack-merge": "^6.0.0" }, "peerDependencies": { diff --git a/packages/custom-webpack/src/custom-webpack-builder.ts b/packages/custom-webpack/src/custom-webpack-builder.ts index cc1d54ea80..3d5f56b31d 100644 --- a/packages/custom-webpack/src/custom-webpack-builder.ts +++ b/packages/custom-webpack/src/custom-webpack-builder.ts @@ -1,7 +1,6 @@ import * as path from 'node:path'; import { inspect } from 'util'; import { getSystemPath, logging, Path } from '@angular-devkit/core'; -import { get } from 'lodash'; import { Configuration } from 'webpack'; import { loadModule } from '@angular-builders/common'; @@ -12,6 +11,18 @@ import { mergeConfigs } from './webpack-config-merger'; export const defaultWebpackConfigPath = 'webpack.config.js'; +/** + * Accesses a nested property by dot/bracket path (e.g. 'output.enabledChunkLoadingTypes[0]'). + */ +function getByPath(obj: any, path: string): any { + const keys = path.replace(/\[(\d+)]/g, '.$1').split('.'); + let result = obj; + for (const key of keys) { + result = result?.[key]; + } + return result; +} + type CustomWebpackConfig = | Configuration | Promise @@ -93,7 +104,7 @@ function logConfigProperties( // entirely. Users can provide a list of properties they want to be logged. if (config.verbose?.properties) { for (const property of config.verbose.properties) { - const value = get(webpackConfig, property); + const value = getByPath(webpackConfig, property); if (value) { const message = inspect(value, /* showHidden */ false, config.verbose.serializationDepth); logger.info(message); diff --git a/packages/custom-webpack/src/webpack-config-merger.ts b/packages/custom-webpack/src/webpack-config-merger.ts index 2553a6d3dc..32e8750707 100644 --- a/packages/custom-webpack/src/webpack-config-merger.ts +++ b/packages/custom-webpack/src/webpack-config-merger.ts @@ -1,7 +1,30 @@ import { MergeRules } from './custom-webpack-builder-config'; import { CustomizeRule, mergeWithRules } from 'webpack-merge'; import { Configuration } from 'webpack'; -import { differenceWith, keyBy, merge } from 'lodash'; + +function isPlainObject(value: unknown): value is Record { + return ( + value !== null && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype + ); +} + +/** + * Recursively deep-merges source into target, mutating target. + * Arrays are replaced entirely (unlike lodash.merge which merges by index). + * This is intentional for webpack plugin options where full replacement is the expected behavior. + */ +function deepMerge>(target: T, source: Record): T { + for (const key of Object.keys(source)) { + const targetVal = (target as any)[key]; + const sourceVal = source[key]; + if (isPlainObject(targetVal) && isPlainObject(sourceVal)) { + deepMerge(targetVal, sourceVal); + } else { + (target as any)[key] = sourceVal; + } + } + return target; +} const DEFAULT_MERGE_RULES: MergeRules = { module: { @@ -24,16 +47,19 @@ export function mergeConfigs( const mergedConfig: Configuration = mergeWithRules(mergeRules)(webpackConfig1, webpackConfig2); if (webpackConfig1.plugins && webpackConfig2.plugins) { - const conf1ExceptConf2 = differenceWith( - webpackConfig1.plugins, - webpackConfig2.plugins, - (item1, item2) => item1.constructor.name === item2.constructor.name + const conf1ExceptConf2 = webpackConfig1.plugins.filter( + item1 => + !webpackConfig2.plugins!.some(item2 => item1.constructor.name === item2.constructor.name) ); if (!replacePlugins) { - const conf1ByName = keyBy(webpackConfig1.plugins, 'constructor.name'); - webpackConfig2.plugins = webpackConfig2.plugins.map(p => - conf1ByName[p.constructor.name] ? merge(conf1ByName[p.constructor.name], p) : p - ); + const conf1ByName: Record = {}; + for (const p of webpackConfig1.plugins) { + conf1ByName[p.constructor.name] = p; + } + webpackConfig2.plugins = webpackConfig2.plugins.map(p => { + const match = conf1ByName[p.constructor.name]; + return match ? deepMerge(match as any, p as any) : p; + }) as typeof webpackConfig2.plugins; } mergedConfig.plugins = [...conf1ExceptConf2, ...webpackConfig2.plugins]; } diff --git a/packages/jest/package.json b/packages/jest/package.json index 51b337eb8e..6487cb588a 100644 --- a/packages/jest/package.json +++ b/packages/jest/package.json @@ -44,8 +44,7 @@ "@angular-builders/common": "workspace:*", "@angular-devkit/architect": ">=0.2100.0 < 0.2200.0", "@angular-devkit/core": "^21.0.0", - "jest-preset-angular": "^16.0.0", - "lodash": "^4.17.15" + "jest-preset-angular": "^16.0.0" }, "peerDependencies": { "@angular-devkit/build-angular": "^21.0.0", diff --git a/packages/jest/src/default-config.resolver.ts b/packages/jest/src/default-config.resolver.ts index f9d950c19e..4875665f09 100644 --- a/packages/jest/src/default-config.resolver.ts +++ b/packages/jest/src/default-config.resolver.ts @@ -1,4 +1,3 @@ -import { pick } from 'lodash'; import { getSystemPath, normalize, Path } from '@angular-devkit/core'; import { JestConfig } from './types'; @@ -13,9 +12,13 @@ const globalMocks = { }; const getMockFiles = (enabledMocks: string[] = []): string[] => - Object.values(pick(globalMocks, enabledMocks)).map(fileName => - getSystemPath(normalize(`${__dirname}/global-mocks/${fileName}`)) - ); + Object.values( + Object.fromEntries( + enabledMocks + .filter(k => k in globalMocks) + .map(k => [k, globalMocks[k as keyof typeof globalMocks]]) + ) + ).map(fileName => getSystemPath(normalize(`${__dirname}/global-mocks/${fileName}`))); const getSetupFile = (zoneless: boolean = true): string => { const setupFileName = zoneless ? 'setup-zoneless.js' : 'setup-zone.js'; diff --git a/packages/jest/src/jest-configuration-builder.ts b/packages/jest/src/jest-configuration-builder.ts index 19535bdb12..c5c0b60cf7 100644 --- a/packages/jest/src/jest-configuration-builder.ts +++ b/packages/jest/src/jest-configuration-builder.ts @@ -1,5 +1,4 @@ import { Path, resolve } from '@angular-devkit/core'; -import { isArray, mergeWith } from 'lodash'; import { JestConfig } from './types'; import { CustomConfigResolver } from './custom-config.resolver'; @@ -15,20 +14,55 @@ const ARRAY_PROPERTIES_TO_CONCAT = [ 'astTransformers', ]; +type MergeCustomizer = (objValue: any, srcValue: any, key: string) => any; + +/** + * Deep merge two objects, invoking a customizer for each key. + * If the customizer returns `undefined`, the default deep-merge behavior applies. + */ +function deepMergeWith(target: any, source: any, customizer: MergeCustomizer): any { + if (source === undefined || source === null) { + return target; + } + if (target === undefined || target === null) { + return source; + } + const result = { ...target }; + for (const key of Object.keys(source)) { + const customResult = customizer(result[key], source[key], key); + if (customResult !== undefined) { + result[key] = customResult; + } else if ( + typeof result[key] === 'object' && + result[key] !== null && + !Array.isArray(result[key]) && + typeof source[key] === 'object' && + source[key] !== null && + !Array.isArray(source[key]) + ) { + result[key] = deepMergeWith(result[key], source[key], customizer); + } else { + result[key] = source[key]; + } + } + return result; +} + /** - * This function checks witch properties should be concat. Early return will - * merge the data as lodash#merge would do it. + * This function checks which properties should be concat. Returning `undefined` + * falls through to the default deep-merge behavior. */ -function concatArrayProperties(objValue: any[], srcValue: unknown, property: string) { +function concatArrayProperties(objValue: any, srcValue: unknown, property: string): any { if (!ARRAY_PROPERTIES_TO_CONCAT.includes(property)) { - return; + return undefined; } - if (!isArray(objValue)) { - return mergeWith(objValue, srcValue, (obj, src) => { - if (isArray(obj)) { + if (!Array.isArray(objValue)) { + return deepMergeWith(objValue, srcValue, (obj, src) => { + if (Array.isArray(obj)) { return obj.concat(src); } + return undefined; }); } @@ -47,12 +81,9 @@ const buildConfiguration = async ( const globalCustomConfig = await customConfigResolver.resolveGlobal(workspaceRoot); const projectCustomConfig = await customConfigResolver.resolveForProject(projectRoot, config); - return mergeWith( - globalDefaultConfig, - projectDefaultConfig, - globalCustomConfig, - projectCustomConfig, - concatArrayProperties + return [projectDefaultConfig, globalCustomConfig, projectCustomConfig].reduce( + (acc, cfg) => deepMergeWith(acc, cfg, concatArrayProperties), + globalDefaultConfig ); }; diff --git a/yarn.lock b/yarn.lock index b75baf38de..94f1716d89 100644 --- a/yarn.lock +++ b/yarn.lock @@ -232,7 +232,6 @@ __metadata: "@angular-devkit/core": ^21.0.0 "@angular/build": ^21.0.0 jest: 30.2.0 - lodash: ^4.17.15 rimraf: ^6.0.0 ts-node: ^10.0.0 typescript: 5.9.3 @@ -253,7 +252,6 @@ __metadata: cpy-cli: ^7.0.0 jest: 30.2.0 jest-preset-angular: ^16.0.0 - lodash: ^4.17.15 quicktype: ^15.0.260 rimraf: ^6.0.0 typescript: 5.9.3 @@ -7494,13 +7492,6 @@ __metadata: languageName: node linkType: hard -"@types/lodash@npm:^4.14.118": - version: 4.17.24 - resolution: "@types/lodash@npm:4.17.24" - checksum: 2b254973145ecdf9b052d83f00ebaa111245f319d45b217c78f81cea8892fda81180fc749bd50a99c08f4e5efceb834c774d3f74153a50a9c4a28648343dc9f3 - languageName: node - linkType: hard - "@types/mime@npm:^1": version: 1.3.5 resolution: "@types/mime@npm:1.3.5" @@ -8547,11 +8538,9 @@ __metadata: "@commitlint/config-conventional": ^20.0.0 "@lerna-lite/cli": ^4.10.5 "@lerna-lite/publish": ^4.10.5 - "@types/lodash": ^4.14.118 "@types/node": ^24.0.0 husky: ^9.0.0 lint-staged: ^16.0.0 - lodash: ^4.17.15 prettier: ^3.0.0 ts-jest: 29.4.6 turbo: ^2.4.0 @@ -15767,7 +15756,7 @@ __metadata: languageName: node linkType: hard -"lodash@npm:^4.17.15, lodash@npm:^4.17.20, lodash@npm:^4.17.21, lodash@npm:^4.17.23": +"lodash@npm:^4.17.20, lodash@npm:^4.17.21, lodash@npm:^4.17.23": version: 4.17.23 resolution: "lodash@npm:4.17.23" checksum: 7daad39758a72872e94651630fbb54ba76868f904211089721a64516ce865506a759d9ad3d8ff22a2a49a50a09db5d27c36f22762d21766e47e3ba918d6d7bab