Skip to content

Commit 4575265

Browse files
committed
fix(@angular/build): exclude all entrypoints of a library from prebundling
The configuration now ensures that when a package is listed for exclusion, all paths within that package including sub-paths like `@foo/bar/baz` are marked as external and not prebundled by the development server. For example, specifying `@foo/bar` in the exclude list will prevent the development server from bundling any files from the `@foo/bar` package, including its sub-paths such as `@foo/bar/baz`. This aligns with esbuild external option behaviour https://esbuild.github.io/api/#external Closes #29170 (cherry picked from commit f0dd60b)
1 parent 78297ee commit 4575265

File tree

9 files changed

+121
-39
lines changed

9 files changed

+121
-39
lines changed

packages/angular/build/src/builders/application/execute-build.ts

+42-14
Original file line numberDiff line numberDiff line change
@@ -163,21 +163,49 @@ export async function executeBuild(
163163
// Analyze external imports if external options are enabled
164164
if (options.externalPackages || bundlingResult.externalConfiguration) {
165165
const {
166-
externalConfiguration,
167-
externalImports: { browser, server },
166+
externalConfiguration = [],
167+
externalImports: { browser = [], server = [] },
168168
} = bundlingResult;
169-
const implicitBrowser = browser ? [...browser] : [];
170-
const implicitServer = server ? [...server] : [];
171-
// TODO: Implement wildcard externalConfiguration filtering
172-
executionResult.setExternalMetadata(
173-
externalConfiguration
174-
? implicitBrowser.filter((value) => !externalConfiguration.includes(value))
175-
: implicitBrowser,
176-
externalConfiguration
177-
? implicitServer.filter((value) => !externalConfiguration.includes(value))
178-
: implicitServer,
179-
externalConfiguration,
180-
);
169+
// Similar to esbuild, --external:@foo/bar automatically implies --external:@foo/bar/*,
170+
// which matches import paths like @foo/bar/baz.
171+
// This means all paths within the @foo/bar package are also marked as external.
172+
const exclusionsPrefixes = externalConfiguration.map((exclusion) => exclusion + '/');
173+
const exclusions = new Set(externalConfiguration);
174+
const explicitExternal = new Set<string>();
175+
176+
const isExplicitExternal = (dep: string): boolean => {
177+
if (exclusions.has(dep)) {
178+
return true;
179+
}
180+
181+
for (const prefix of exclusionsPrefixes) {
182+
if (dep.startsWith(prefix)) {
183+
return true;
184+
}
185+
}
186+
187+
return false;
188+
};
189+
190+
const implicitBrowser: string[] = [];
191+
for (const dep of browser) {
192+
if (isExplicitExternal(dep)) {
193+
explicitExternal.add(dep);
194+
} else {
195+
implicitBrowser.push(dep);
196+
}
197+
}
198+
199+
const implicitServer: string[] = [];
200+
for (const dep of server) {
201+
if (isExplicitExternal(dep)) {
202+
explicitExternal.add(dep);
203+
} else {
204+
implicitServer.push(dep);
205+
}
206+
}
207+
208+
executionResult.setExternalMetadata(implicitBrowser, implicitServer, [...explicitExternal]);
181209
}
182210

183211
const { metafile, initialFiles, outputFiles } = bundlingResult;

packages/angular/build/src/builders/application/options.ts

+28-2
Original file line numberDiff line numberDiff line change
@@ -433,15 +433,21 @@ export async function normalizeOptions(
433433
baseHref,
434434
cacheOptions,
435435
crossOrigin,
436-
externalDependencies,
436+
externalDependencies: normalizeExternals(externalDependencies),
437+
externalPackages:
438+
typeof externalPackages === 'object'
439+
? {
440+
...externalPackages,
441+
exclude: normalizeExternals(externalPackages.exclude),
442+
}
443+
: externalPackages,
437444
extractLicenses,
438445
inlineStyleLanguage,
439446
jit: !aot,
440447
stats: !!statsJson,
441448
polyfills: polyfills === undefined || Array.isArray(polyfills) ? polyfills : [polyfills],
442449
poll,
443450
progress,
444-
externalPackages,
445451
preserveSymlinks,
446452
stylePreprocessorOptions,
447453
subresourceIntegrity,
@@ -677,3 +683,23 @@ export function getLocaleBaseHref(
677683

678684
return baseHrefSuffix !== '' ? urlJoin(baseHref, baseHrefSuffix) : undefined;
679685
}
686+
687+
/**
688+
* Normalizes an array of external dependency paths by ensuring that
689+
* wildcard patterns (`/*`) are removed from package names.
690+
*
691+
* This avoids the need to handle this normalization repeatedly in our plugins,
692+
* as esbuild already treats `--external:@foo/bar` as implicitly including
693+
* `--external:@foo/bar/*`. By standardizing the input, we ensure consistency
694+
* and reduce redundant checks across our plugins.
695+
*
696+
* @param value - An optional array of dependency paths to normalize.
697+
* @returns A new array with wildcard patterns removed from package names, or `undefined` if input is `undefined`.
698+
*/
699+
function normalizeExternals(value: string[] | undefined): string[] | undefined {
700+
if (!value) {
701+
return undefined;
702+
}
703+
704+
return [...new Set(value.map((d) => (d.endsWith('/*') ? d.slice(0, -2) : d)))];
705+
}

packages/angular/build/src/builders/application/schema.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@
196196
"additionalProperties": false
197197
},
198198
"externalDependencies": {
199-
"description": "Exclude the listed external dependencies from being bundled into the bundle. Instead, the created bundle relies on these dependencies to be available during runtime.",
199+
"description": "Exclude the listed external dependencies from being bundled into the bundle. Instead, the created bundle relies on these dependencies to be available during runtime. Note: `@foo/bar` marks all paths within the `@foo/bar` package as external, including sub-paths like `@foo/bar/baz`.",
200200
"type": "array",
201201
"items": {
202202
"type": "string"

packages/angular/build/src/builders/dev-server/schema.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@
115115
"type": "object",
116116
"properties": {
117117
"exclude": {
118-
"description": "List of package imports that should not be prebundled by the development server. The packages will be bundled into the application code itself.",
118+
"description": "List of package imports that should not be prebundled by the development server. The packages will be bundled into the application code itself. Note: specifying `@foo/bar` marks all paths within the `@foo/bar` package as excluded, including sub-paths like `@foo/bar/baz`.",
119119
"type": "array",
120120
"items": { "type": "string" }
121121
}

packages/angular/build/src/builders/dev-server/tests/behavior/build-external-dependencies_spec.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupT
4848

4949
it('respects import specifiers when using baseHref with trailing slash', async () => {
5050
setupTarget(harness, {
51-
externalDependencies: ['rxjs', 'rxjs/operators'],
51+
externalDependencies: ['rxjs'],
5252
baseHref: '/test/',
5353
});
5454

@@ -67,7 +67,7 @@ describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupT
6767

6868
it('respects import specifiers when using baseHref without trailing slash', async () => {
6969
setupTarget(harness, {
70-
externalDependencies: ['rxjs', 'rxjs/operators'],
70+
externalDependencies: ['rxjs/*'],
7171
baseHref: '/test',
7272
});
7373

packages/angular/build/src/tools/esbuild/bundler-context.ts

+6-7
Original file line numberDiff line numberDiff line change
@@ -359,17 +359,16 @@ export class BundlerContext {
359359
// Collect all external package names
360360
const externalImports = new Set<string>();
361361
for (const { imports } of Object.values(result.metafile.outputs)) {
362-
for (const importData of imports) {
362+
for (const { external, kind, path } of imports) {
363363
if (
364-
!importData.external ||
365-
SERVER_GENERATED_EXTERNALS.has(importData.path) ||
366-
(importData.kind !== 'import-statement' &&
367-
importData.kind !== 'dynamic-import' &&
368-
importData.kind !== 'require-call')
364+
!external ||
365+
SERVER_GENERATED_EXTERNALS.has(path) ||
366+
(kind !== 'import-statement' && kind !== 'dynamic-import' && kind !== 'require-call')
369367
) {
370368
continue;
371369
}
372-
externalImports.add(importData.path);
370+
371+
externalImports.add(path);
373372
}
374373
}
375374

packages/angular/build/src/tools/esbuild/bundler-execution-result.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -127,9 +127,9 @@ export class ExecutionResult {
127127
setExternalMetadata(
128128
implicitBrowser: string[],
129129
implicitServer: string[],
130-
explicit: string[] | undefined,
130+
explicit: string[],
131131
): void {
132-
this.externalMetadata = { implicitBrowser, implicitServer, explicit: explicit ?? [] };
132+
this.externalMetadata = { implicitBrowser, implicitServer, explicit };
133133
}
134134

135135
get output() {

packages/angular/build/src/tools/esbuild/external-packages-plugin.ts

+38-9
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,14 @@ const EXTERNAL_PACKAGE_RESOLUTION = Symbol('EXTERNAL_PACKAGE_RESOLUTION');
1919
* @returns An esbuild plugin.
2020
*/
2121
export function createExternalPackagesPlugin(options?: { exclude?: string[] }): Plugin {
22-
const exclusions = options?.exclude?.length ? new Set(options.exclude) : undefined;
22+
const exclusions = new Set<string>(options?.exclude);
23+
// Similar to esbuild, --external:@foo/bar automatically implies --external:@foo/bar/*,
24+
// which matches import paths like @foo/bar/baz.
25+
// This means all paths within the @foo/bar package are also marked as external.
26+
const exclusionsPrefixes = options?.exclude?.map((exclusion) => exclusion + '/') ?? [];
27+
const seenExclusions: Set<string> = new Set();
28+
const seenExternals = new Set<string>();
29+
const seenNonExclusions: Set<string> = new Set();
2330

2431
return {
2532
name: 'angular-external-packages',
@@ -33,7 +40,7 @@ export function createExternalPackagesPlugin(options?: { exclude?: string[] }):
3340
.map(([key]) => key);
3441

3542
// Safe to use native packages external option if no loader options or exclusions present
36-
if (!exclusions && !loaderOptionKeys?.length) {
43+
if (!exclusions.size && !loaderOptionKeys?.length) {
3744
build.initialOptions.packages = 'external';
3845

3946
return;
@@ -47,10 +54,26 @@ export function createExternalPackagesPlugin(options?: { exclude?: string[] }):
4754
return null;
4855
}
4956

50-
if (exclusions?.has(args.path)) {
57+
if (seenExternals.has(args.path)) {
58+
return { external: true };
59+
}
60+
61+
if (exclusions.has(args.path) || seenExclusions.has(args.path)) {
5162
return null;
5263
}
5364

65+
if (!seenNonExclusions.has(args.path)) {
66+
for (const exclusion of exclusionsPrefixes) {
67+
if (args.path.startsWith(exclusion)) {
68+
seenExclusions.add(args.path);
69+
70+
return null;
71+
}
72+
}
73+
74+
seenNonExclusions.add(args.path);
75+
}
76+
5477
const { importer, kind, resolveDir, namespace, pluginData = {} } = args;
5578
pluginData[EXTERNAL_PACKAGE_RESOLUTION] = true;
5679

@@ -62,22 +85,28 @@ export function createExternalPackagesPlugin(options?: { exclude?: string[] }):
6285
resolveDir,
6386
});
6487

65-
// Return result if unable to resolve or explicitly marked external (externalDependencies option)
66-
if (!result.path || result.external) {
88+
// Return result if unable to resolve
89+
if (!result.path) {
6790
return result;
6891
}
6992

93+
// Return if explicitly marked external (externalDependencies option)
94+
if (result.external) {
95+
seenExternals.add(args.path);
96+
97+
return { external: true };
98+
}
99+
70100
// Allow customized loaders to run against configured paths regardless of location
71101
if (loaderFileExtensions.has(extname(result.path))) {
72102
return result;
73103
}
74104

75105
// Mark paths from a node modules directory as external
76106
if (/[\\/]node_modules[\\/]/.test(result.path)) {
77-
return {
78-
path: args.path,
79-
external: true,
80-
};
107+
seenExternals.add(args.path);
108+
109+
return { external: true };
81110
}
82111

83112
// Otherwise return original result

packages/angular/build/src/tools/vite/plugins/id-prefix-plugin.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export function createRemoveIdPrefixPlugin(externals: string[]): Plugin {
2727
return;
2828
}
2929

30-
const escapedExternals = externals.map(escapeRegexSpecialChars);
30+
const escapedExternals = externals.map((e) => escapeRegexSpecialChars(e) + '(?:/.+)?');
3131
const prefixedExternalRegex = new RegExp(
3232
`${resolvedConfig.base}${VITE_ID_PREFIX}(${escapedExternals.join('|')})`,
3333
'g',

0 commit comments

Comments
 (0)