Skip to content

Commit 981e868

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 981e868

File tree

11 files changed

+298
-35
lines changed

11 files changed

+298
-35
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 output of Object.values(originalMetafile.outputs)) {
145+
if (output.entryPoint && posixFacadeModuleId.endsWith(output.entryPoint)) {
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: 44 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,13 @@ 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(
300+
metafile,
301+
options,
302+
executionResult,
303+
initialFiles,
304+
chunksOptimized,
305+
);
275306
executionResult.addErrors(result.errors);
276307
executionResult.addWarnings(result.warnings);
277308
executionResult.addPrerenderedRoutes(result.prerenderedRoutes);
@@ -284,6 +315,7 @@ export async function executeBuild(
284315
initialFiles,
285316
// Set lang attribute to the defined source locale if present
286317
i18nOptions.hasDefinedSourceLocale ? i18nOptions.sourceLocale : undefined,
318+
chunksOptimized,
287319
);
288320

289321
// 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: 4 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';
@@ -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,11 +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.
172-
const entryPointToBrowserMapping =
173-
routes?.length || shouldOptimizeChunks
174-
? undefined
175-
: generateLazyLoadedFilesMappings(metafile, initialFiles, publicPath);
171+
const entryPointToBrowserMapping = routes?.length
172+
? undefined
173+
: generateLazyLoadedFilesMappings(metafile, initialFiles, publicPath);
176174

177175
const manifestContent = `
178176
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):

0 commit comments

Comments
 (0)