Skip to content

Commit

Permalink
refactor(@angular/build): add experimental chunk optimizer for produc…
Browse files Browse the repository at this point in the history
…tion application builds

An experimental chunk optimizer is now available for initial usage.
To enable the optimization, script optimization must be enabled as well as
an environment variable `NG_BUILD_OPTIMIZE_CHUNKS=1`. This build step uses
`rollup` internally to process the build files directly in memory. The main
bundling performs all resolution, bundling, and tree-shaking of the application.
The chunk optimizer step then only needs to access the in-memory built files and does not
need to perform any disk access or module resolution. This allows the step to be
performed fairly quickly but it does add time to the overall production build.
The `NG_BUILD_DEBUG_PERF=1` environment variable can be used to view how long the step
takes within a build via the `OPTIMIZE_CHUNKS` entry. In the future, this optimization
step may be automatically enabled based on initial file entry count and size.
There are several current known issues:
1) Bundle budgets for named lazy chunks may not work as expected.
2) The console output may not show names (files will be present) for lazy chunk files.
3) The stats file (`--stats-json` option) will not exactly reflect the final written application files. This is similar to the current behavior of the `browser` builder with Webpack's stat file.
  • Loading branch information
clydin committed Jun 28, 2024
1 parent 7982100 commit a5f1b91
Show file tree
Hide file tree
Showing 9 changed files with 274 additions and 6 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@
"puppeteer": "18.2.1",
"quicktype-core": "23.0.170",
"resolve-url-loader": "5.0.0",
"rollup": "~4.18.0",
"rollup": "4.18.0",
"rollup-plugin-sourcemaps": "^0.6.0",
"rxjs": "7.8.1",
"sass": "1.77.6",
Expand Down
1 change: 1 addition & 0 deletions packages/angular/build/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ ts_library(
"@npm//picomatch",
"@npm//piscina",
"@npm//postcss",
"@npm//rollup",
"@npm//sass",
"@npm//semver",
"@npm//tslib",
Expand Down
1 change: 1 addition & 0 deletions packages/angular/build/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"parse5-html-rewriting-stream": "7.0.0",
"picomatch": "4.0.2",
"piscina": "4.6.1",
"rollup": "4.18.0",
"sass": "1.77.6",
"semver": "7.6.2",
"undici": "6.19.2",
Expand Down
211 changes: 211 additions & 0 deletions packages/angular/build/src/builders/application/chunk-optimizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import assert from 'node:assert';
import { rollup } from 'rollup';
import {
BuildOutputFile,
BuildOutputFileType,
BundleContextResult,
InitialFileRecord,
} from '../../tools/esbuild/bundler-context';
import { createOutputFile } from '../../tools/esbuild/utils';
import { assertIsError } from '../../utils/error';

export async function optimizeChunks(
original: BundleContextResult,
sourcemap: boolean | 'hidden',
): Promise<BundleContextResult> {
// Failed builds cannot be optimized
if (original.errors) {
return original;
}

// Find the main browser entrypoint
let mainFile;
for (const [file, record] of original.initialFiles) {
if (
record.name === 'main' &&
record.entrypoint &&
!record.serverFile &&
record.type === 'script'
) {
mainFile = file;
break;
}
}

// No action required if no browser main entrypoint
if (!mainFile) {
return original;
}

const chunks: Record<string, BuildOutputFile> = {};
const maps: Record<string, BuildOutputFile> = {};
for (const originalFile of original.outputFiles) {
if (originalFile.type !== BuildOutputFileType.Browser) {
continue;
}

if (originalFile.path.endsWith('.js')) {
chunks[originalFile.path] = originalFile;
} else if (originalFile.path.endsWith('.js.map')) {
// Create mapping of JS file to sourcemap content
maps[originalFile.path.slice(0, -4)] = originalFile;
}
}

const usedChunks = new Set<string>();

let bundle;
let optimizedOutput;
try {
bundle = await rollup({
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 result = await bundle.generate({
compact: true,
sourcemap,
chunkFileNames(chunkInfo) {
// Do not add hash to file name if already present
return /-[a-zA-Z0-9]{8}$/.test(chunkInfo.name) ? '[name].js' : '[name]-[hash].js';
},
});
optimizedOutput = result.output;
} catch (e) {
assertIsError(e);

return {
errors: [
// Most of these fields are not actually needed for printing the error
{
id: '',
text: 'Chunk optimization failed',
detail: undefined,
pluginName: '',
location: null,
notes: [
{
text: e.message,
location: null,
},
],
},
],
warnings: original.warnings,
};
} finally {
await bundle?.close();
}

// Remove used chunks and associated sourcemaps from the original result
original.outputFiles = original.outputFiles.filter(
(file) =>
!usedChunks.has(file.path) &&
!(file.path.endsWith('.map') && usedChunks.has(file.path.slice(0, -4))),
);

// Add new optimized chunks
const importsPerFile: Record<string, string[]> = {};
for (const optimizedFile of optimizedOutput) {
if (optimizedFile.type !== 'chunk') {
continue;
}

importsPerFile[optimizedFile.fileName] = optimizedFile.imports;

original.outputFiles.push(
createOutputFile(optimizedFile.fileName, optimizedFile.code, BuildOutputFileType.Browser),
);
if (optimizedFile.map && optimizedFile.sourcemapFileName) {
original.outputFiles.push(
createOutputFile(
optimizedFile.sourcemapFileName,
optimizedFile.map.toString(),
BuildOutputFileType.Browser,
),
);
}
}

// Update initial files to reflect optimized chunks
const entriesToAnalyze: [string, InitialFileRecord][] = [];
for (const usedFile of usedChunks) {
// Leave the main file since its information did not change
if (usedFile === mainFile) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
entriesToAnalyze.push([mainFile, original.initialFiles.get(mainFile)!]);
continue;
}

// Remove all other used chunks
original.initialFiles.delete(usedFile);
}

// Analyze for transitive initial files
let currentEntry;
while ((currentEntry = entriesToAnalyze.pop())) {
const [entryPath, entryRecord] = currentEntry;

for (const importPath of importsPerFile[entryPath]) {
const existingRecord = original.initialFiles.get(importPath);
if (existingRecord) {
// Store the smallest value depth
if (existingRecord.depth > entryRecord.depth + 1) {
existingRecord.depth = entryRecord.depth + 1;
}

continue;
}

const record: InitialFileRecord = {
type: 'script',
entrypoint: false,
external: false,
serverFile: false,
depth: entryRecord.depth + 1,
};

entriesToAnalyze.push([importPath, record]);
}
}

return original;
}
14 changes: 13 additions & 1 deletion packages/angular/build/src/builders/application/execute-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@ import { BuildOutputFileType, BundlerContext } from '../../tools/esbuild/bundler
import { ExecutionResult, RebuildState } from '../../tools/esbuild/bundler-execution-result';
import { checkCommonJSModules } from '../../tools/esbuild/commonjs-checker';
import { extractLicenses } from '../../tools/esbuild/license-extractor';
import { profileAsync } from '../../tools/esbuild/profiling';
import { calculateEstimatedTransferSizes, logBuildStats } from '../../tools/esbuild/utils';
import { BudgetCalculatorResult, checkBudgets } from '../../utils/bundle-calculator';
import { shouldOptimizeChunks } from '../../utils/environment-options';
import { resolveAssets } from '../../utils/resolve-assets';
import { getSupportedBrowsers } from '../../utils/supported-browsers';
import { optimizeChunks } from './chunk-optimizer';
import { executePostBundleSteps } from './execute-post-bundle';
import { inlineI18n, loadActiveTranslations } from './i18n';
import { NormalizedApplicationBuildOptions } from './options';
Expand Down Expand Up @@ -59,11 +62,20 @@ export async function executeBuild(
bundlerContexts = setupBundlerContexts(options, browsers, codeBundleCache);
}

const bundlingResult = await BundlerContext.bundleAll(
let bundlingResult = await BundlerContext.bundleAll(
bundlerContexts,
rebuildState?.fileChanges.all,
);

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

const executionResult = new ExecutionResult(bundlerContexts, codeBundleCache);
executionResult.addWarnings(bundlingResult.warnings);

Expand Down
4 changes: 4 additions & 0 deletions packages/angular/build/src/utils/environment-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,7 @@ export const useTypeChecking =
const buildLogsJsonVariable = process.env['NG_BUILD_LOGS_JSON'];
export const useJSONBuildLogs =
isPresent(buildLogsJsonVariable) && isEnabled(buildLogsJsonVariable);

const optimizeChunksVariable = process.env['NG_BUILD_OPTIMIZE_CHUNKS'];
export const shouldOptimizeChunks =
isPresent(optimizeChunksVariable) && isEnabled(optimizeChunksVariable);
19 changes: 19 additions & 0 deletions tests/legacy-cli/e2e/tests/build/chunk-optimizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { execWithEnv } from '../../utils/process';

/**
* AOT builds with chunk optimizer should contain generated component definitions.
* This is currently testing that the generated code is propagating through the
* chunk optimization step.
*/
export default async function () {
await execWithEnv('ng', ['build', '--output-hashing=none'], {
...process.env,
NG_BUILD_OPTIMIZE_CHUNKS: '1',
NG_BUILD_MANGLE: '0',
});

const content = await readFile('dist/test-project/browser/main.js', 'utf-8');
assert.match(content, /\\u0275\\u0275defineComponent/);
}
23 changes: 21 additions & 2 deletions tests/legacy-cli/e2e/tests/build/material.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { appendFile } from 'node:fs/promises';
import assert from 'node:assert/strict';
import { appendFile, readdir } from 'node:fs/promises';
import { getGlobalVariable } from '../../utils/env';
import { readFile, replaceInFile } from '../../utils/fs';
import {
getActivePackageManager,
installPackage,
installWorkspacePackages,
} from '../../utils/packages';
import { ng } from '../../utils/process';
import { execWithEnv, ng } from '../../utils/process';
import { isPrereleaseCli, updateJsonFile } from '../../utils/project';

const snapshots = require('../../ng-snapshot/package.json');
Expand Down Expand Up @@ -89,4 +90,22 @@ export default async function () {
);

await ng('e2e', '--configuration=production');

const usingApplicationBuilder = getGlobalVariable('argv')['esbuild'];
if (usingApplicationBuilder) {
// Test with chunk optimizations to reduce async animations chunk file count
await execWithEnv('ng', ['build'], {
...process.env,
NG_BUILD_OPTIMIZE_CHUNKS: '1',
});
const distFiles = await readdir('dist/test-project/browser');
const jsCount = distFiles.filter((file) => file.endsWith('.js')).length;
// 3 = polyfills, main, and one lazy chunk
assert.equal(jsCount, 3);

await execWithEnv('ng', ['e2e', '--configuration=production'], {
...process.env,
NG_BUILD_OPTIMIZE_CHUNKS: '1',
});
}
}
5 changes: 3 additions & 2 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,7 @@ __metadata:
parse5-html-rewriting-stream: "npm:7.0.0"
picomatch: "npm:4.0.2"
piscina: "npm:4.6.1"
rollup: "npm:4.18.0"
sass: "npm:1.77.6"
semver: "npm:7.6.2"
undici: "npm:6.19.2"
Expand Down Expand Up @@ -763,7 +764,7 @@ __metadata:
puppeteer: "npm:18.2.1"
quicktype-core: "npm:23.0.170"
resolve-url-loader: "npm:5.0.0"
rollup: "npm:~4.18.0"
rollup: "npm:4.18.0"
rollup-plugin-sourcemaps: "npm:^0.6.0"
rxjs: "npm:7.8.1"
sass: "npm:1.77.6"
Expand Down Expand Up @@ -15663,7 +15664,7 @@ __metadata:
languageName: node
linkType: hard

"rollup@npm:^4.13.0, rollup@npm:^4.18.0, rollup@npm:^4.4.0, rollup@npm:~4.18.0":
"rollup@npm:4.18.0, rollup@npm:^4.13.0, rollup@npm:^4.18.0, rollup@npm:^4.4.0":
version: 4.18.0
resolution: "rollup@npm:4.18.0"
dependencies:
Expand Down

0 comments on commit a5f1b91

Please sign in to comment.