From 069174676ac1bfe6d1827df8b96cad928bfa2f87 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 24 Jun 2024 12:42:20 -0400 Subject: [PATCH] refactor(@angular/build): add experimental chunk optimizer for production 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. --- package.json | 2 +- packages/angular/build/BUILD.bazel | 1 + packages/angular/build/package.json | 1 + .../builders/application/chunk-optimizer.ts | 211 ++++++++++++++++++ .../src/builders/application/execute-build.ts | 14 +- .../build/src/utils/environment-options.ts | 4 + .../e2e/tests/build/chunk-optimizer.ts | 19 ++ tests/legacy-cli/e2e/tests/build/material.ts | 23 +- yarn.lock | 5 +- 9 files changed, 274 insertions(+), 6 deletions(-) create mode 100644 packages/angular/build/src/builders/application/chunk-optimizer.ts create mode 100644 tests/legacy-cli/e2e/tests/build/chunk-optimizer.ts diff --git a/package.json b/package.json index 8eb082e4531a..81b91aecd77f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/angular/build/BUILD.bazel b/packages/angular/build/BUILD.bazel index 829d86e44c48..9c337ffa1675 100644 --- a/packages/angular/build/BUILD.bazel +++ b/packages/angular/build/BUILD.bazel @@ -89,6 +89,7 @@ ts_library( "@npm//picomatch", "@npm//piscina", "@npm//postcss", + "@npm//rollup", "@npm//sass", "@npm//semver", "@npm//tslib", diff --git a/packages/angular/build/package.json b/packages/angular/build/package.json index 1085b0f172ef..81a6120dc2e7 100644 --- a/packages/angular/build/package.json +++ b/packages/angular/build/package.json @@ -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", diff --git a/packages/angular/build/src/builders/application/chunk-optimizer.ts b/packages/angular/build/src/builders/application/chunk-optimizer.ts new file mode 100644 index 000000000000..ab19f5757b6c --- /dev/null +++ b/packages/angular/build/src/builders/application/chunk-optimizer.ts @@ -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 { + // 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 = {}; + const maps: Record = {}; + 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(); + + 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 = {}; + 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; +} diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index 5bb6ace1cc02..08f3934e28d1 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -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'; @@ -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); diff --git a/packages/angular/build/src/utils/environment-options.ts b/packages/angular/build/src/utils/environment-options.ts index c1e330b37963..26d211b3a77d 100644 --- a/packages/angular/build/src/utils/environment-options.ts +++ b/packages/angular/build/src/utils/environment-options.ts @@ -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); diff --git a/tests/legacy-cli/e2e/tests/build/chunk-optimizer.ts b/tests/legacy-cli/e2e/tests/build/chunk-optimizer.ts new file mode 100644 index 000000000000..edc43729718e --- /dev/null +++ b/tests/legacy-cli/e2e/tests/build/chunk-optimizer.ts @@ -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/); +} diff --git a/tests/legacy-cli/e2e/tests/build/material.ts b/tests/legacy-cli/e2e/tests/build/material.ts index 62c9697a82d7..64f8a1ae4c2f 100644 --- a/tests/legacy-cli/e2e/tests/build/material.ts +++ b/tests/legacy-cli/e2e/tests/build/material.ts @@ -1,4 +1,5 @@ -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 { @@ -6,7 +7,7 @@ import { 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'); @@ -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', + }); + } } diff --git a/yarn.lock b/yarn.lock index 190e2e2a2686..35f9309efe62 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" @@ -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" @@ -15623,7 +15624,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: