Skip to content

Commit 2ade868

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 9d10749 commit 2ade868

File tree

11 files changed

+274
-26
lines changed

11 files changed

+274
-26
lines changed

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,15 @@
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';
3232

3333
/**
@@ -201,6 +201,7 @@ function createChunkOptimizationFailureMessage(message: string): Message {
201201
* @param sourcemap A boolean or 'hidden' to control sourcemap generation.
202202
* @returns A promise that resolves to the updated build result with optimized chunks.
203203
*/
204+
// eslint-disable-next-line max-lines-per-function
204205
export async function optimizeChunks(
205206
original: BundleContextResult,
206207
sourcemap: boolean | 'hidden',
@@ -297,7 +298,7 @@ export async function optimizeChunks(
297298
} else {
298299
bundle = await rollup({
299300
input: mainFile,
300-
plugins: plugins as any,
301+
plugins: plugins as Plugin[],
301302
});
302303

303304
const result = await bundle.generate({

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

Lines changed: 38 additions & 12 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,
@@ -149,6 +139,8 @@ export async function executeBuild(
149139
);
150140
executionResult.addWarnings(bundlingResult.warnings);
151141

142+
let chunksOptimized = false;
143+
152144
// Add used external component style referenced files to be watched
153145
if (options.externalRuntimeStyles) {
154146
executionResult.extraWatchFiles.push(...componentStyleBundler.collectReferencedFiles());
@@ -161,6 +153,39 @@ export async function executeBuild(
161153
return executionResult;
162154
}
163155

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

272297
// Perform i18n translation inlining if enabled
273298
if (i18nOptions.shouldInline) {
274-
const result = await inlineI18n(metafile, options, executionResult, initialFiles);
299+
const result = await inlineI18n(metafile, options, executionResult, initialFiles, chunksOptimized);
275300
executionResult.addErrors(result.errors);
276301
executionResult.addWarnings(result.warnings);
277302
executionResult.addPrerenderedRoutes(result.prerenderedRoutes);
@@ -284,6 +309,7 @@ export async function executeBuild(
284309
initialFiles,
285310
// Set lang attribute to the defined source locale if present
286311
i18nOptions.hasDefinedSourceLocale ? i18nOptions.sourceLocale : undefined,
312+
chunksOptimized,
287313
);
288314

289315
// Deduplicate and add errors and warnings

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export async function executePostBundleSteps(
5050
assetFiles: BuildOutputAsset[],
5151
initialFiles: Map<string, InitialFileRecord>,
5252
locale: string | undefined,
53+
chunksOptimized: boolean,
5354
): Promise<{
5455
errors: string[];
5556
warnings: string[];
@@ -124,6 +125,7 @@ export async function executePostBundleSteps(
124125
initialFilesPaths,
125126
metafile,
126127
publicPath,
128+
chunksOptimized,
127129
);
128130

129131
additionalOutputFiles.push(
@@ -207,6 +209,7 @@ export async function executePostBundleSteps(
207209
initialFilesPaths,
208210
metafile,
209211
publicPath,
212+
chunksOptimized,
210213
);
211214

212215
for (const chunk of serverAssetsChunks) {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export async function inlineI18n(
3434
options: NormalizedApplicationBuildOptions,
3535
executionResult: ExecutionResult,
3636
initialFiles: Map<string, InitialFileRecord>,
37+
chunksOptimized: boolean,
3738
): Promise<{
3839
errors: string[];
3940
warnings: string[];
@@ -97,6 +98,7 @@ export async function inlineI18n(
9798
executionResult.assetFiles,
9899
initialFiles,
99100
locale,
101+
chunksOptimized,
100102
);
101103

102104
localeOutputFiles.push(...additionalOutputFiles);

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) ? 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 & 3 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';
@@ -137,6 +136,7 @@ export function generateAngularServerAppManifest(
137136
initialFiles: Set<string>,
138137
metafile: Metafile,
139138
publicPath: string | undefined,
139+
chunksOptimized: boolean,
140140
): {
141141
manifestContent: string;
142142
serverAssetsChunks: BuildOutputFile[];
@@ -168,9 +168,9 @@ export function generateAngularServerAppManifest(
168168
}
169169

170170
// 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.
171+
// When chunks are optimized the metadata is no longer correct and thus we cannot generate the mappings.
172172
const entryPointToBrowserMapping =
173-
routes?.length || shouldOptimizeChunks
173+
routes?.length || chunksOptimized
174174
? undefined
175175
: generateLazyLoadedFilesMappings(metafile, initialFiles, publicPath);
176176

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)