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: