Skip to content

Commit b1e4260

Browse files
committed
feat(@angular/build): enable chunk optimization by default with heuristics
Enable the advanced chunk optimization pass by default for applications with multiple lazy chunks to improve loading performance. A heuristic is introduced that automatically triggers this optimization when the build generates 3 or more lazy chunks. Developers can customize this behavior or disable it entirely using the NG_BUILD_OPTIMIZE_CHUNKS environment variable. Setting it to a number adjusts the threshold of lazy chunks required to trigger optimization, while setting it to false disables the feature if issues arise in specific projects.
1 parent ee12a56 commit b1e4260

File tree

10 files changed

+287
-36
lines changed

10 files changed

+287
-36
lines changed

packages/angular/build/src/builders/application/chunk-optimizer.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,17 @@
1919

2020
import type { Message, Metafile } from 'esbuild';
2121
import assert from 'node:assert';
22-
import { rollup } from 'rollup';
23-
import { useRolldownChunks } from '../../utils/environment-options';
22+
import { type Plugin, rollup } from 'rollup';
2423
import {
2524
BuildOutputFile,
2625
BuildOutputFileType,
2726
BundleContextResult,
2827
InitialFileRecord,
2928
} from '../../tools/esbuild/bundler-context';
3029
import { createOutputFile } from '../../tools/esbuild/utils';
30+
import { useRolldownChunks } from '../../utils/environment-options';
3131
import { assertIsError } from '../../utils/error';
32+
import { toPosixPath } from '../../utils/path';
3233

3334
/**
3435
* Represents a minimal subset of a Rollup/Rolldown output asset.
@@ -137,15 +138,23 @@ function bundleOutputToEsbuildMetafile(
137138
...(chunk.dynamicImports?.map((path) => ({ path, kind: 'dynamic-import' as const })) ?? []),
138139
];
139140

141+
let entryPoint: string | undefined;
142+
if (chunk.facadeModuleId) {
143+
const posixFacadeModuleId = toPosixPath(chunk.facadeModuleId);
144+
for (const [outputPath, output] of Object.entries(originalMetafile.outputs)) {
145+
if (posixFacadeModuleId.endsWith(outputPath)) {
146+
entryPoint = output.entryPoint;
147+
break;
148+
}
149+
}
150+
}
151+
140152
newMetafile.outputs[chunk.fileName] = {
141153
bytes: Buffer.byteLength(chunk.code, 'utf8'),
142154
inputs: newOutputInputs,
143155
imports,
144156
exports: chunk.exports ?? [],
145-
entryPoint:
146-
chunk.isEntry && chunk.facadeModuleId
147-
? originalMetafile.outputs[chunk.facadeModuleId]?.entryPoint
148-
: undefined,
157+
entryPoint,
149158
};
150159
}
151160

@@ -201,6 +210,7 @@ function createChunkOptimizationFailureMessage(message: string): Message {
201210
* @param sourcemap A boolean or 'hidden' to control sourcemap generation.
202211
* @returns A promise that resolves to the updated build result with optimized chunks.
203212
*/
213+
// eslint-disable-next-line max-lines-per-function
204214
export async function optimizeChunks(
205215
original: BundleContextResult,
206216
sourcemap: boolean | 'hidden',
@@ -291,18 +301,20 @@ export async function optimizeChunks(
291301
const result = await bundle.generate({
292302
minify: { mangle: false, compress: false },
293303
sourcemap,
294-
chunkFileNames: (chunkInfo) => `${chunkInfo.name.replace(/-[a-zA-Z0-9]{8}$/, '')}-[hash].js`,
304+
chunkFileNames: (chunkInfo) =>
305+
`${chunkInfo.name.replace(/-[a-zA-Z0-9]{8}$/, '')}-[hash].js`,
295306
});
296307
optimizedOutput = result.output;
297308
} else {
298309
bundle = await rollup({
299310
input: mainFile,
300-
plugins: plugins as any,
311+
plugins: plugins as Plugin[],
301312
});
302313

303314
const result = await bundle.generate({
304315
sourcemap,
305-
chunkFileNames: (chunkInfo) => `${chunkInfo.name.replace(/-[a-zA-Z0-9]{8}$/, '')}-[hash].js`,
316+
chunkFileNames: (chunkInfo) =>
317+
`${chunkInfo.name.replace(/-[a-zA-Z0-9]{8}$/, '')}-[hash].js`,
306318
});
307319
optimizedOutput = result.output;
308320
}

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

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
transformSupportedBrowsersToTargets,
2626
} from '../../tools/esbuild/utils';
2727
import { BudgetCalculatorResult, checkBudgets } from '../../utils/bundle-calculator';
28-
import { shouldOptimizeChunks } from '../../utils/environment-options';
28+
import { optimizeChunksThreshold } from '../../utils/environment-options';
2929
import { resolveAssets } from '../../utils/resolve-assets';
3030
import {
3131
SERVER_APP_ENGINE_MANIFEST_FILENAME,
@@ -131,16 +131,6 @@ export async function executeBuild(
131131
bundlingResult = BundlerContext.mergeResults([bundlingResult, ...componentResults]);
132132
}
133133

134-
if (options.optimizationOptions.scripts && shouldOptimizeChunks) {
135-
const { optimizeChunks } = await import('./chunk-optimizer');
136-
bundlingResult = await profileAsync('OPTIMIZE_CHUNKS', () =>
137-
optimizeChunks(
138-
bundlingResult,
139-
options.sourcemapOptions.scripts ? !options.sourcemapOptions.hidden || 'hidden' : false,
140-
),
141-
);
142-
}
143-
144134
const executionResult = new ExecutionResult(
145135
bundlerContexts,
146136
componentStyleBundler,
@@ -161,6 +151,37 @@ export async function executeBuild(
161151
return executionResult;
162152
}
163153

154+
// Optimize chunks if enabled and threshold is met.
155+
// This pass uses Rollup/Rolldown to further optimize chunks generated by esbuild.
156+
if (options.optimizationOptions.scripts) {
157+
// Count lazy chunks (files not needed for initial load).
158+
// Advanced chunk optimization is most beneficial when there are multiple lazy chunks.
159+
const { metafile, initialFiles } = bundlingResult;
160+
const lazyChunksCount = Object.keys(metafile.outputs).filter(
161+
(path) => path.endsWith('.js') && !initialFiles.has(path),
162+
).length;
163+
164+
// Only run if the number of lazy chunks meets the configured threshold.
165+
// This avoids overhead for small projects with few chunks.
166+
if (lazyChunksCount >= optimizeChunksThreshold) {
167+
const { optimizeChunks } = await import('./chunk-optimizer');
168+
const optimizationResult = await profileAsync('OPTIMIZE_CHUNKS', () =>
169+
optimizeChunks(
170+
bundlingResult,
171+
options.sourcemapOptions.scripts ? !options.sourcemapOptions.hidden || 'hidden' : false,
172+
),
173+
);
174+
175+
if (optimizationResult.errors) {
176+
executionResult.addErrors(optimizationResult.errors);
177+
178+
return executionResult;
179+
}
180+
181+
bundlingResult = optimizationResult;
182+
}
183+
}
184+
164185
// Analyze external imports if external options are enabled
165186
if (options.externalPackages || bundlingResult.externalConfiguration) {
166187
const {

packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,6 @@ import { createRequire } from 'node:module';
1212
import { platform } from 'node:os';
1313
import path from 'node:path';
1414

15-
interface ExistingRawSourceMap {
16-
sources?: string[];
17-
sourcesContent?: string[];
18-
mappings?: string;
19-
}
20-
2115
import type {
2216
BrowserConfigOptions,
2317
InlineConfig,
@@ -30,6 +24,12 @@ import { toPosixPath } from '../../../../utils/path';
3024
import type { ResultFile } from '../../../application/results';
3125
import type { NormalizedUnitTestBuilderOptions } from '../../options';
3226

27+
interface ExistingRawSourceMap {
28+
sources?: string[];
29+
sourcesContent?: string[];
30+
mappings?: string;
31+
}
32+
3333
type VitestPlugins = Awaited<ReturnType<typeof VitestPlugin>>;
3434

3535
interface PluginOptions {

packages/angular/build/src/utils/environment-options.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,29 @@ export const useJSONBuildLogs = parseTristate(process.env['NG_BUILD_LOGS_JSON'])
155155
/**
156156
* When `NG_BUILD_OPTIMIZE_CHUNKS` is enabled, the build will optimize chunks.
157157
*/
158-
export const shouldOptimizeChunks = parseTristate(process.env['NG_BUILD_OPTIMIZE_CHUNKS']) === true;
158+
/**
159+
* The threshold of lazy chunks required to enable the chunk optimization pass.
160+
* Can be configured via the `NG_BUILD_OPTIMIZE_CHUNKS` environment variable.
161+
* - `false` or `0` disables the feature.
162+
* - `true` or `1` forces the feature on (threshold 0).
163+
* - A number sets the specific threshold.
164+
* - Default is 3.
165+
*/
166+
const optimizeChunksEnv = process.env['NG_BUILD_OPTIMIZE_CHUNKS'];
167+
export const optimizeChunksThreshold = (() => {
168+
if (optimizeChunksEnv === undefined) {
169+
return 3;
170+
}
171+
if (optimizeChunksEnv === 'false' || optimizeChunksEnv === '0') {
172+
return Infinity;
173+
}
174+
if (optimizeChunksEnv === 'true' || optimizeChunksEnv === '1') {
175+
return 0;
176+
}
177+
const num = Number.parseInt(optimizeChunksEnv, 10);
178+
179+
return Number.isNaN(num) || num < 0 ? 3 : num;
180+
})();
159181

160182
/**
161183
* When `NG_HMR_CSTYLES` is enabled, component styles will be hot-reloaded.

packages/angular/build/src/utils/server-rendering/manifest.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import { runInThisContext } from 'node:vm';
1212
import { NormalizedApplicationBuildOptions } from '../../builders/application/options';
1313
import { type BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context';
1414
import { createOutputFile } from '../../tools/esbuild/utils';
15-
import { shouldOptimizeChunks } from '../environment-options';
1615

1716
export const SERVER_APP_MANIFEST_FILENAME = 'angular-app-manifest.mjs';
1817
export const SERVER_APP_ENGINE_MANIFEST_FILENAME = 'angular-app-engine-manifest.mjs';
@@ -168,11 +167,9 @@ export function generateAngularServerAppManifest(
168167
}
169168

170169
// When routes have been extracted, mappings are no longer needed, as preloads will be included in the metadata.
171-
// When shouldOptimizeChunks is enabled the metadata is no longer correct and thus we cannot generate the mappings.
172-
const entryPointToBrowserMapping =
173-
routes?.length || shouldOptimizeChunks
174-
? undefined
175-
: generateLazyLoadedFilesMappings(metafile, initialFiles, publicPath);
170+
const entryPointToBrowserMapping = routes?.length
171+
? undefined
172+
: generateLazyLoadedFilesMappings(metafile, initialFiles, publicPath);
176173

177174
const manifestContent = `
178175
export default {

tests/e2e.bzl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ WEBPACK_IGNORE_TESTS = [
5757
"tests/build/incremental-watch.js",
5858
"tests/build/chunk-optimizer.js",
5959
"tests/build/chunk-optimizer-lazy.js",
60+
"tests/build/chunk-optimizer-heuristic.js",
61+
"tests/build/chunk-optimizer-env.js",
6062
]
6163

6264
def _to_glob(patterns):
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import assert from 'node:assert/strict';
2+
import { readdir } from 'node:fs/promises';
3+
import { replaceInFile } from '../../utils/fs';
4+
import { execWithEnv, ng } from '../../utils/process';
5+
import { installPackage, uninstallPackage } from '../../utils/packages';
6+
7+
export default async function () {
8+
// Case 1: Force on with true/1 with 1 lazy chunk
9+
await ng('generate', 'component', 'lazy-a');
10+
await replaceInFile(
11+
'src/app/app.routes.ts',
12+
'routes: Routes = [];',
13+
`routes: Routes = [
14+
{
15+
path: 'lazy-a',
16+
loadComponent: () => import('./lazy-a/lazy-a').then(m => m.LazyA),
17+
},
18+
];`,
19+
);
20+
21+
// Build with forced optimization
22+
await execWithEnv('ng', ['build', '--output-hashing=none'], {
23+
...process.env,
24+
NG_BUILD_OPTIMIZE_CHUNKS: 'true',
25+
});
26+
const files1Opt = await readdir('dist/test-project/browser');
27+
const jsFiles1Opt = files1Opt.filter((f) => f.endsWith('.js'));
28+
29+
// Build with forced off
30+
await execWithEnv('ng', ['build', '--output-hashing=none'], {
31+
...process.env,
32+
NG_BUILD_OPTIMIZE_CHUNKS: 'false',
33+
});
34+
const files1Unopt = await readdir('dist/test-project/browser');
35+
const jsFiles1Unopt = files1Unopt.filter((f) => f.endsWith('.js'));
36+
37+
// We just verify it runs without error.
38+
// With 1 chunk it might not be able to optimize further, so counts might be equal.
39+
40+
// Case 2: Force off with false/0 with 3 lazy chunks
41+
await ng('generate', 'component', 'lazy-b');
42+
await ng('generate', 'component', 'lazy-c');
43+
await replaceInFile(
44+
'src/app/app.routes.ts',
45+
`path: 'lazy-a',
46+
loadComponent: () => import('./lazy-a/lazy-a').then(m => m.LazyA),
47+
},`,
48+
`path: 'lazy-a',
49+
loadComponent: () => import('./lazy-a/lazy-a').then(m => m.LazyA),
50+
},
51+
{
52+
path: 'lazy-b',
53+
loadComponent: () => import('./lazy-b/lazy-b').then(m => m.LazyB),
54+
},
55+
{
56+
path: 'lazy-c',
57+
loadComponent: () => import('./lazy-c/lazy-c').then(m => m.LazyC),
58+
},`,
59+
);
60+
61+
// Build with forced off
62+
await execWithEnv('ng', ['build', '--output-hashing=none'], {
63+
...process.env,
64+
NG_BUILD_OPTIMIZE_CHUNKS: 'false',
65+
});
66+
const files3Unopt = await readdir('dist/test-project/browser');
67+
const jsFiles3Unopt = files3Unopt.filter((f) => f.endsWith('.js'));
68+
69+
// Build with default (should optimize because 3 chunks)
70+
await ng('build', '--output-hashing=none');
71+
const files3Default = await readdir('dist/test-project/browser');
72+
const jsFiles3Default = files3Default.filter((f) => f.endsWith('.js'));
73+
74+
assert.ok(
75+
jsFiles3Default.length < jsFiles3Unopt.length,
76+
`Expected default build (3 chunks) to be optimized compared to forced off. Default: ${jsFiles3Default.length}, Forced Off: ${jsFiles3Unopt.length}`,
77+
);
78+
79+
// Case 3: Custom threshold
80+
// Set threshold to 4 with 3 chunks -> should NOT optimize!
81+
await execWithEnv('ng', ['build', '--output-hashing=none'], {
82+
...process.env,
83+
NG_BUILD_OPTIMIZE_CHUNKS: '4',
84+
});
85+
const files3Thresh4 = await readdir('dist/test-project/browser');
86+
const jsFiles3Thresh4 = files3Thresh4.filter((f) => f.endsWith('.js'));
87+
88+
assert.ok(
89+
jsFiles3Thresh4.length >= jsFiles3Unopt.length,
90+
`Expected build with threshold 4 and 3 chunks to NOT be optimized. Thresh 4: ${jsFiles3Thresh4.length}, Unoptimized: ${jsFiles3Unopt.length}`,
91+
);
92+
93+
// Case 4: Opt into Rolldown
94+
await installPackage('rolldown@1.0.0-rc.12');
95+
try {
96+
await execWithEnv('ng', ['build', '--output-hashing=none'], {
97+
...process.env,
98+
NG_BUILD_CHUNKS_ROLLDOWN: '1',
99+
NG_BUILD_OPTIMIZE_CHUNKS: 'true',
100+
});
101+
const filesRolldown = await readdir('dist/test-project/browser');
102+
const jsFilesRolldown = filesRolldown.filter((f) => f.endsWith('.js'));
103+
104+
assert.ok(jsFilesRolldown.length > 0, 'Expected Rolldown build to produce output files.');
105+
} finally {
106+
// Clean up
107+
await uninstallPackage('rolldown');
108+
}
109+
}

0 commit comments

Comments
 (0)