Skip to content

Commit a78d1c3

Browse files
alan-agius4filipesilva
authored andcommitted
fix(@angular-devkit/build-angular): dedupe duplicate modules
Webpack relies on package managers to do module hoisting and doesn't have any deduping logic since version 4. However relaying on package manager has a number of short comings, such as when having the same library with the same version laid out in different parts of the node_modules tree. Example: ``` /node_modules/[email protected] /node_modules/library-1/node_modules/[email protected] /node_modules/library-2/node_modules/[email protected] ``` In the above case, in the final bundle we'll end up with 3 versions of tslib instead of 2, even though 2 of the modules are identical. Webpack has an open issue for this webpack/webpack#5593 (Duplicate modules - NOT solvable by `npm dedupe`) With this change we add a custom resolve plugin that dedupes modules with the same name and versions that are laid out in different parts of the node_modules tree.
1 parent 28db29e commit a78d1c3

File tree

4 files changed

+128
-2
lines changed

4 files changed

+128
-2
lines changed

packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/common.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import {
2424
Plugin,
2525
Rule,
2626
RuleSetLoader,
27-
compilation,
2827
debug,
2928
} from 'webpack';
3029
import { RawSource } from 'webpack-sources';
@@ -40,6 +39,7 @@ import {
4039
} from '../../../utils/environment-options';
4140
import {
4241
BundleBudgetPlugin,
42+
DedupeModuleResolvePlugin,
4343
NamedLazyChunksPlugin,
4444
OptimizeCssWebpackPlugin,
4545
ScriptsWebpackPlugin,
@@ -475,7 +475,10 @@ export function getCommonConfig(wco: WebpackConfigOptions): Configuration {
475475
extensions: ['.ts', '.tsx', '.mjs', '.js'],
476476
symlinks: !buildOptions.preserveSymlinks,
477477
modules: [wco.tsConfig.options.baseUrl || projectRoot, 'node_modules'],
478-
plugins: [PnpWebpackPlugin],
478+
plugins: [
479+
PnpWebpackPlugin,
480+
new DedupeModuleResolvePlugin({ verbose: buildOptions.verbose }),
481+
],
479482
},
480483
resolveLoader: {
481484
symlinks: !buildOptions.preserveSymlinks,
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
interface NormalModuleFactoryRequest {
10+
request: string;
11+
context: {
12+
issuer: string;
13+
};
14+
relativePath: string;
15+
path: string;
16+
descriptionFileData: {
17+
name: string;
18+
version: string;
19+
};
20+
descriptionFileRoot: string;
21+
descriptionFilePath: string;
22+
}
23+
24+
export interface DedupeModuleResolvePluginOptions {
25+
verbose?: boolean;
26+
}
27+
28+
/**
29+
* DedupeModuleResolvePlugin is a webpack resolver plugin which dedupes modules with the same name and versions
30+
* that are laid out in different parts of the node_modules tree.
31+
*
32+
* This is needed because Webpack relies on package managers to hoist modules and doesn't have any deduping logic.
33+
*/
34+
export class DedupeModuleResolvePlugin {
35+
modules = new Map<string, NormalModuleFactoryRequest>();
36+
37+
constructor(private options?: DedupeModuleResolvePluginOptions) { }
38+
39+
// tslint:disable-next-line: no-any
40+
apply(resolver: any) {
41+
resolver
42+
.getHook('before-described-relative')
43+
.tapPromise('DedupeModuleResolvePlugin', async (request: NormalModuleFactoryRequest) => {
44+
if (request.relativePath !== '.') {
45+
return;
46+
}
47+
48+
const moduleId = request.descriptionFileData.name + '@' + request.descriptionFileData.version;
49+
const prevResolvedModule = this.modules.get(moduleId);
50+
51+
if (!prevResolvedModule) {
52+
// This is the first time we visit this module.
53+
this.modules.set(moduleId, request);
54+
55+
return;
56+
}
57+
58+
const {
59+
path,
60+
descriptionFilePath,
61+
descriptionFileRoot,
62+
} = prevResolvedModule;
63+
64+
if (request.path === path) {
65+
// No deduping needed.
66+
// Current path and previously resolved path are the same.
67+
return;
68+
}
69+
70+
if (this.options?.verbose) {
71+
// tslint:disable-next-line: no-console
72+
console.warn(`[DedupeModuleResolvePlugin]: ${request.path} -> ${path}`);
73+
}
74+
75+
// Alter current request with previously resolved module.
76+
request.path = path;
77+
request.descriptionFileRoot = descriptionFileRoot;
78+
request.descriptionFilePath = descriptionFilePath;
79+
});
80+
}
81+
}

packages/angular_devkit/build_angular/src/angular-cli-files/plugins/webpack.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export { ScriptsWebpackPlugin, ScriptsWebpackPluginOptions } from './scripts-web
1414
export { SuppressExtractedTextChunksWebpackPlugin } from './suppress-entry-chunks-webpack-plugin';
1515
export { RemoveHashPlugin, RemoveHashPluginOptions } from './remove-hash-plugin';
1616
export { NamedLazyChunksPlugin } from './named-chunks-plugin';
17+
export { DedupeModuleResolvePlugin } from './dedupe-module-resolve-plugin';
1718
export { CommonJsUsageWarnPlugin } from './common-js-usage-warn-plugin';
1819
export {
1920
default as PostcssCliResources,
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { expectFileToMatch, writeFile } from '../../utils/fs';
2+
import { ng, silentNpm } from '../../utils/process';
3+
import { updateJsonFile } from '../../utils/project';
4+
import { expectToFail } from '../../utils/utils';
5+
6+
export default async function () {
7+
// Force duplicate modules
8+
await updateJsonFile('package.json', json => {
9+
json.dependencies = {
10+
...json.dependencies,
11+
'tslib': '2.0.0',
12+
'tslib-1': 'npm:[email protected]',
13+
'tslib-1-copy': 'npm:[email protected]',
14+
};
15+
});
16+
17+
await silentNpm('install');
18+
19+
await writeFile('./src/main.ts',
20+
`
21+
import { __assign as __assign_0 } from 'tslib';
22+
import { __assign as __assign_1 } from 'tslib-1';
23+
import { __assign as __assign_2 } from 'tslib-1-copy';
24+
25+
console.log({
26+
__assign_0,
27+
__assign_1,
28+
__assign_2,
29+
})
30+
`);
31+
32+
const { stderr } = await ng('build', '--verbose', '--no-vendor-chunk');
33+
if (!/\[DedupeModuleResolvePlugin\]:.+\/node_modules\/tslib-1-copy -> .+\/node_modules\/tslib-1/.test(stderr)) {
34+
throw new Error('Expected stderr to contain [DedupeModuleResolvePlugin] log for tslib.');
35+
}
36+
37+
const outFile = 'dist/test-project/main.js';
38+
await expectFileToMatch(outFile, './node_modules/tslib/tslib.es6.js');
39+
await expectFileToMatch(outFile, './node_modules/tslib-1/tslib.es6.js');
40+
await expectToFail(() => expectFileToMatch(outFile, './node_modules/tslib-1-copy/tslib.es6.js'));
41+
}

0 commit comments

Comments
 (0)