Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/angular/build/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ ts_project(
":node_modules/piscina",
":node_modules/postcss",
":node_modules/rolldown",
":node_modules/rollup",
":node_modules/sass",
":node_modules/source-map-support",
":node_modules/tinyglobby",
Expand Down
3 changes: 2 additions & 1 deletion packages/angular/build/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"parse5-html-rewriting-stream": "8.0.0",
"picomatch": "4.0.4",
"piscina": "5.1.4",
"rolldown": "1.0.0-rc.12",
"rollup": "4.60.0",
"sass": "1.98.0",
"semver": "7.7.4",
"source-map-support": "0.5.21",
Expand All @@ -55,6 +55,7 @@
"less": "4.6.4",
"ng-packagr": "22.0.0-next.1",
"postcss": "8.5.8",
"rolldown": "1.0.0-rc.12",
"rxjs": "7.8.2",
"vitest": "4.1.2"
},
Expand Down
165 changes: 113 additions & 52 deletions packages/angular/build/src/builders/application/chunk-optimizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,58 @@

import type { Message, Metafile } from 'esbuild';
import assert from 'node:assert';
import { type OutputAsset, type OutputChunk, rolldown } from 'rolldown';
import { type Plugin, rollup } from 'rollup';
import {
BuildOutputFile,
BuildOutputFileType,
BundleContextResult,
InitialFileRecord,
} from '../../tools/esbuild/bundler-context';
import { createOutputFile } from '../../tools/esbuild/utils';
import { useRolldownChunks } from '../../utils/environment-options';
import { assertIsError } from '../../utils/error';
import { toPosixPath } from '../../utils/path';

/**
* Converts the output of a rolldown build into an esbuild-compatible metafile.
* @param rolldownOutput The output of a rolldown build.
* Represents a minimal subset of a Rollup/Rolldown output asset.
* This is manually defined to avoid hard dependencies on both bundlers' types
* and to ensure compatibility since Rolldown and Rollup types have slight differences
* but share these core properties.
*/
interface OutputAsset {
type: 'asset';
fileName: string;
source: string | Uint8Array;
}

/**
* Represents a minimal subset of a Rollup/Rolldown output chunk.
* This is manually defined to avoid hard dependencies on both bundlers' types
* and to ensure compatibility since Rolldown and Rollup types have slight differences
* but share these core properties.
*/
interface OutputChunk {
type: 'chunk';
fileName: string;
code: string;
modules: Record<string, { renderedLength: number }>;
imports: string[];
dynamicImports?: string[];
exports: string[];
isEntry: boolean;
facadeModuleId: string | null | undefined;
map?: { toString(): string } | null;
sourcemapFileName?: string | null;
}

/**
* Converts the output of a bundle build into an esbuild-compatible metafile.
* @param bundleOutput The output of a bundle build.
* @param originalMetafile The original esbuild metafile from the build.
* @returns An esbuild-compatible metafile.
*/
function rolldownToEsbuildMetafile(
rolldownOutput: (OutputChunk | OutputAsset)[],
function bundleOutputToEsbuildMetafile(
bundleOutput: (OutputChunk | OutputAsset)[],
originalMetafile: Metafile,
): Metafile {
const newMetafile: Metafile = {
Expand All @@ -52,7 +86,7 @@ function rolldownToEsbuildMetafile(
);
}

for (const chunk of rolldownOutput) {
for (const chunk of bundleOutput) {
if (chunk.type === 'asset') {
newMetafile.outputs[chunk.fileName] = {
bytes:
Expand Down Expand Up @@ -104,15 +138,23 @@ function rolldownToEsbuildMetafile(
...(chunk.dynamicImports?.map((path) => ({ path, kind: 'dynamic-import' as const })) ?? []),
];

let entryPoint: string | undefined;
if (chunk.facadeModuleId) {
const posixFacadeModuleId = toPosixPath(chunk.facadeModuleId);
for (const [outputPath, output] of Object.entries(originalMetafile.outputs)) {
if (posixFacadeModuleId.endsWith(outputPath)) {
entryPoint = output.entryPoint;
break;
}
}
}

newMetafile.outputs[chunk.fileName] = {
bytes: Buffer.byteLength(chunk.code, 'utf8'),
inputs: newOutputInputs,
imports,
exports: chunk.exports ?? [],
entryPoint:
chunk.isEntry && chunk.facadeModuleId
? originalMetafile.outputs[chunk.facadeModuleId]?.entryPoint
: undefined,
entryPoint,
};
}

Expand Down Expand Up @@ -168,6 +210,7 @@ function createChunkOptimizationFailureMessage(message: string): Message {
* @param sourcemap A boolean or 'hidden' to control sourcemap generation.
* @returns A promise that resolves to the updated build result with optimized chunks.
*/
// eslint-disable-next-line max-lines-per-function
export async function optimizeChunks(
original: BundleContextResult,
sourcemap: boolean | 'hidden',
Expand Down Expand Up @@ -214,49 +257,67 @@ export async function optimizeChunks(
const usedChunks = new Set<string>();

let bundle;
let optimizedOutput;
let optimizedOutput: (OutputChunk | OutputAsset)[];
try {
bundle = await rolldown({
input: mainFile,
plugins: [
{
name: 'angular-bundle',
resolveId(source) {
// Remove leading `./` if present
const file = source[0] === '.' && source[1] === '/' ? source.slice(2) : source;

if (chunks[file]) {
return file;
}

// All other identifiers are considered external to maintain behavior
return { id: source, external: true };
},
load(id) {
assert(
chunks[id],
`Angular chunk content should always be present in chunk optimizer [${id}].`,
);

usedChunks.add(id);

const result = {
code: chunks[id].text,
map: maps[id]?.text,
};

return result;
},
const plugins = [
{
name: 'angular-bundle',
resolveId(source: string) {
// Remove leading `./` if present
const file = source[0] === '.' && source[1] === '/' ? source.slice(2) : source;

if (chunks[file]) {
return file;
}

// All other identifiers are considered external to maintain behavior
return { id: source, external: true };
},
load(id: string) {
assert(
chunks[id],
`Angular chunk content should always be present in chunk optimizer [${id}].`,
);

usedChunks.add(id);

const result = {
code: chunks[id].text,
map: maps[id]?.text,
};

return result;
},
],
});

const result = await bundle.generate({
minify: { mangle: false, compress: false },
sourcemap,
chunkFileNames: (chunkInfo) => `${chunkInfo.name.replace(/-[a-zA-Z0-9]{8}$/, '')}-[hash].js`,
});
optimizedOutput = result.output;
},
];

if (useRolldownChunks) {
const { rolldown } = await import('rolldown');
bundle = await rolldown({
input: mainFile,
plugins,
});

const result = await bundle.generate({
minify: { mangle: false, compress: false },
sourcemap,
chunkFileNames: (chunkInfo) =>
`${chunkInfo.name.replace(/-[a-zA-Z0-9]{8}$/, '')}-[hash].js`,
});
optimizedOutput = result.output;
} else {
bundle = await rollup({
input: mainFile,
plugins: plugins as Plugin[],
});

const result = await bundle.generate({
sourcemap,
chunkFileNames: (chunkInfo) =>
`${chunkInfo.name.replace(/-[a-zA-Z0-9]{8}$/, '')}-[hash].js`,
});
optimizedOutput = result.output;
}
} catch (e) {
assertIsError(e);

Expand All @@ -269,7 +330,7 @@ export async function optimizeChunks(
}

// Update metafile
const newMetafile = rolldownToEsbuildMetafile(optimizedOutput, original.metafile);
const newMetafile = bundleOutputToEsbuildMetafile(optimizedOutput, original.metafile);
// Add back the outputs that were not part of the optimization
for (const [path, output] of Object.entries(original.metafile.outputs)) {
if (usedChunks.has(path)) {
Expand Down
43 changes: 32 additions & 11 deletions packages/angular/build/src/builders/application/execute-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
transformSupportedBrowsersToTargets,
} from '../../tools/esbuild/utils';
import { BudgetCalculatorResult, checkBudgets } from '../../utils/bundle-calculator';
import { shouldOptimizeChunks } from '../../utils/environment-options';
import { optimizeChunksThreshold } from '../../utils/environment-options';
import { resolveAssets } from '../../utils/resolve-assets';
import {
SERVER_APP_ENGINE_MANIFEST_FILENAME,
Expand Down Expand Up @@ -131,16 +131,6 @@ export async function executeBuild(
bundlingResult = BundlerContext.mergeResults([bundlingResult, ...componentResults]);
}

if (options.optimizationOptions.scripts && shouldOptimizeChunks) {
const { optimizeChunks } = await import('./chunk-optimizer');
bundlingResult = await profileAsync('OPTIMIZE_CHUNKS', () =>
optimizeChunks(
bundlingResult,
options.sourcemapOptions.scripts ? !options.sourcemapOptions.hidden || 'hidden' : false,
),
);
}

const executionResult = new ExecutionResult(
bundlerContexts,
componentStyleBundler,
Expand All @@ -161,6 +151,37 @@ export async function executeBuild(
return executionResult;
}

// Optimize chunks if enabled and threshold is met.
// This pass uses Rollup/Rolldown to further optimize chunks generated by esbuild.
if (options.optimizationOptions.scripts) {
// Count lazy chunks (files not needed for initial load).
// Advanced chunk optimization is most beneficial when there are multiple lazy chunks.
const { metafile, initialFiles } = bundlingResult;
const lazyChunksCount = Object.keys(metafile.outputs).filter(
(path) => path.endsWith('.js') && !initialFiles.has(path),
).length;

// Only run if the number of lazy chunks meets the configured threshold.
// This avoids overhead for small projects with few chunks.
if (lazyChunksCount >= optimizeChunksThreshold) {
const { optimizeChunks } = await import('./chunk-optimizer');
const optimizationResult = await profileAsync('OPTIMIZE_CHUNKS', () =>
optimizeChunks(
bundlingResult,
options.sourcemapOptions.scripts ? !options.sourcemapOptions.hidden || 'hidden' : false,
),
);

if (optimizationResult.errors) {
executionResult.addErrors(optimizationResult.errors);

return executionResult;
}

bundlingResult = optimizationResult;
}
}

// Analyze external imports if external options are enabled
if (options.externalPackages || bundlingResult.externalConfiguration) {
const {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { platform } from 'node:os';
import path from 'node:path';
import type { ExistingRawSourceMap } from 'rolldown';

import type {
BrowserConfigOptions,
InlineConfig,
Expand All @@ -24,6 +24,12 @@ import { toPosixPath } from '../../../../utils/path';
import type { ResultFile } from '../../../application/results';
import type { NormalizedUnitTestBuilderOptions } from '../../options';

interface ExistingRawSourceMap {
sources?: string[];
sourcesContent?: string[];
mappings?: string;
}

type VitestPlugins = Awaited<ReturnType<typeof VitestPlugin>>;

interface PluginOptions {
Expand Down
30 changes: 29 additions & 1 deletion packages/angular/build/src/utils/environment-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ export const shouldBeautify = debugOptimize.beautify;
*/
export const allowMinify = debugOptimize.minify;

/**
* Allows using Rolldown for chunk optimization instead of Rollup.
* This is useful for debugging and testing scenarios.
*/
export const useRolldownChunks = parseTristate(process.env['NG_BUILD_CHUNKS_ROLLDOWN']) ?? false;

/**
* Some environments, like CircleCI which use Docker report a number of CPUs by the host and not the count of available.
* This cause `Error: Call retries were exceeded` errors when trying to use them.
Expand Down Expand Up @@ -149,7 +155,29 @@ export const useJSONBuildLogs = parseTristate(process.env['NG_BUILD_LOGS_JSON'])
/**
* When `NG_BUILD_OPTIMIZE_CHUNKS` is enabled, the build will optimize chunks.
*/
export const shouldOptimizeChunks = parseTristate(process.env['NG_BUILD_OPTIMIZE_CHUNKS']) === true;
/**
* The threshold of lazy chunks required to enable the chunk optimization pass.
* Can be configured via the `NG_BUILD_OPTIMIZE_CHUNKS` environment variable.
* - `false` or `0` disables the feature.
* - `true` or `1` forces the feature on (threshold 0).
* - A number sets the specific threshold.
* - Default is 3.
*/
const optimizeChunksEnv = process.env['NG_BUILD_OPTIMIZE_CHUNKS'];
export const optimizeChunksThreshold = (() => {
if (optimizeChunksEnv === undefined) {
return 3;
}
if (optimizeChunksEnv === 'false' || optimizeChunksEnv === '0') {
return Infinity;
}
if (optimizeChunksEnv === 'true' || optimizeChunksEnv === '1') {
return 0;
}
const num = Number.parseInt(optimizeChunksEnv, 10);

return Number.isNaN(num) || num < 0 ? 3 : num;
})();

/**
* When `NG_HMR_CSTYLES` is enabled, component styles will be hot-reloaded.
Expand Down
Loading
Loading