Skip to content

Commit 612c528

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 16e3f75 commit 612c528

File tree

6 files changed

+249
-13
lines changed

6 files changed

+249
-13
lines changed

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/utils/environment-options.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,28 @@ 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+
return Number.isNaN(num) ? 3 : num;
179+
})();
159180

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

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.component').then(m => m.LazyAComponent),
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.component').then(m => m.LazyAComponent),
47+
},`,
48+
`path: 'lazy-a',
49+
loadComponent: () => import('./lazy-a/lazy-a.component').then(m => m.LazyAComponent),
50+
},
51+
{
52+
path: 'lazy-b',
53+
loadComponent: () => import('./lazy-b/lazy-b.component').then(m => m.LazyBComponent),
54+
},
55+
{
56+
path: 'lazy-c',
57+
loadComponent: () => import('./lazy-c/lazy-c.component').then(m => m.LazyCComponent),
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+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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+
6+
export default async function () {
7+
// Case 1: 2 lazy chunks (below threshold of 3) -> should NOT optimize by default
8+
await ng('generate', 'component', 'lazy-a');
9+
await ng('generate', 'component', 'lazy-b');
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.component').then(m => m.LazyAComponent),
17+
},
18+
{
19+
path: 'lazy-b',
20+
loadComponent: () => import('./lazy-b/lazy-b.component').then(m => m.LazyBComponent),
21+
},
22+
];`,
23+
);
24+
25+
// Build without explicit flag (should use default threshold of 3)
26+
await ng('build', '--output-hashing=none');
27+
const files2 = await readdir('dist/test-project/browser');
28+
const jsFiles2 = files2.filter((f) => f.endsWith('.js'));
29+
30+
// Build with forced optimization to see if it COULD reduce chunks
31+
await execWithEnv('ng', ['build', '--output-hashing=none'], {
32+
...process.env,
33+
NG_BUILD_OPTIMIZE_CHUNKS: 'true',
34+
});
35+
const files2Opt = await readdir('dist/test-project/browser');
36+
const jsFiles2Opt = files2Opt.filter((f) => f.endsWith('.js'));
37+
38+
// If forced optimization reduces chunks, then default should have MORE chunks (since it didn't run).
39+
// If forced optimization doesn't reduce chunks, they will be equal.
40+
// So we assert that default is NOT fewer than forced.
41+
assert.ok(
42+
jsFiles2.length >= jsFiles2Opt.length,
43+
`Expected default build with 2 lazy chunks to NOT be optimized. Default: ${jsFiles2.length}, Forced: ${jsFiles2Opt.length}`,
44+
);
45+
46+
// Case 2: 3 lazy chunks (at threshold of 3) -> should optimize by default
47+
await ng('generate', 'component', 'lazy-c');
48+
await replaceInFile(
49+
'src/app/app.routes.ts',
50+
`path: 'lazy-b',
51+
loadComponent: () => import('./lazy-b/lazy-b.component').then(m => m.LazyBComponent),
52+
},`,
53+
`path: 'lazy-b',
54+
loadComponent: () => import('./lazy-b/lazy-b.component').then(m => m.LazyBComponent),
55+
},
56+
{
57+
path: 'lazy-c',
58+
loadComponent: () => import('./lazy-c/lazy-c.component').then(m => m.LazyCComponent),
59+
},`,
60+
);
61+
62+
// Build without explicit flag (should use default threshold of 3)
63+
await ng('build', '--output-hashing=none');
64+
const files3 = await readdir('dist/test-project/browser');
65+
const jsFiles3 = files3.filter((f) => f.endsWith('.js'));
66+
67+
// Build with explicit disable
68+
await execWithEnv('ng', ['build', '--output-hashing=none'], {
69+
...process.env,
70+
NG_BUILD_OPTIMIZE_CHUNKS: 'false',
71+
});
72+
const files3Unopt = await readdir('dist/test-project/browser');
73+
const jsFiles3Unopt = files3Unopt.filter((f) => f.endsWith('.js'));
74+
75+
// Expect default build to be optimized (fewer chunks than explicitly disabled)
76+
assert.ok(
77+
jsFiles3.length < jsFiles3Unopt.length,
78+
`Expected default build with 3 lazy chunks to be optimized. Default: ${jsFiles3.length}, Unoptimized: ${jsFiles3Unopt.length}`,
79+
);
80+
}

tests/e2e/tests/build/chunk-optimizer-lazy.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@ export default async function () {
2828
);
2929

3030
// Build without chunk optimization
31-
await ng('build', '--output-hashing=none');
31+
await execWithEnv('ng', ['build', '--output-hashing=none'], {
32+
...process.env,
33+
NG_BUILD_OPTIMIZE_CHUNKS: 'false',
34+
});
3235
const unoptimizedFiles = await readdir('dist/test-project/browser');
3336
const unoptimizedJsFiles = unoptimizedFiles.filter((f) => f.endsWith('.js'));
3437

0 commit comments

Comments
 (0)