From ee12a56533ac8afe7d58e71381c0fe871ef02326 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:46:08 -0400 Subject: [PATCH 1/2] refactor(@angular/build): switch chunk optimizer to rollup by default This commit transitions the chunk optimization logic in the application builder from the experimental Rolldown bundler to the stable Rollup bundler. Rollup is now used by default, while support for the NG_BUILD_CHUNKS_ROLLDOWN environment variable has been added to allow opting back into Rolldown for testing and debugging. To make Rolldown truly optional for end users, it has been moved from dependencies to devDependencies, and is now loaded via dynamic import only when requested. --- packages/angular/build/BUILD.bazel | 1 + packages/angular/build/package.json | 3 +- .../builders/application/chunk-optimizer.ts | 145 ++++++++++++------ .../unit-test/runners/vitest/plugins.ts | 8 +- .../build/src/utils/environment-options.ts | 6 + pnpm-lock.yaml | 9 +- tests/e2e/tests/build/chunk-optimizer.ts | 2 +- 7 files changed, 120 insertions(+), 54 deletions(-) diff --git a/packages/angular/build/BUILD.bazel b/packages/angular/build/BUILD.bazel index 5303f6763020..52eb43f9472c 100644 --- a/packages/angular/build/BUILD.bazel +++ b/packages/angular/build/BUILD.bazel @@ -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", diff --git a/packages/angular/build/package.json b/packages/angular/build/package.json index b0d4a1197d74..754d1a56c672 100644 --- a/packages/angular/build/package.json +++ b/packages/angular/build/package.json @@ -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", @@ -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" }, diff --git a/packages/angular/build/src/builders/application/chunk-optimizer.ts b/packages/angular/build/src/builders/application/chunk-optimizer.ts index e6827479b784..1c33e9197240 100644 --- a/packages/angular/build/src/builders/application/chunk-optimizer.ts +++ b/packages/angular/build/src/builders/application/chunk-optimizer.ts @@ -19,7 +19,8 @@ import type { Message, Metafile } from 'esbuild'; import assert from 'node:assert'; -import { type OutputAsset, type OutputChunk, rolldown } from 'rolldown'; +import { rollup } from 'rollup'; +import { useRolldownChunks } from '../../utils/environment-options'; import { BuildOutputFile, BuildOutputFileType, @@ -30,13 +31,45 @@ import { createOutputFile } from '../../tools/esbuild/utils'; import { assertIsError } from '../../utils/error'; /** - * 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; + 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 = { @@ -52,7 +85,7 @@ function rolldownToEsbuildMetafile( ); } - for (const chunk of rolldownOutput) { + for (const chunk of bundleOutput) { if (chunk.type === 'asset') { newMetafile.outputs[chunk.fileName] = { bytes: @@ -214,49 +247,65 @@ export async function optimizeChunks( const usedChunks = new Set(); 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 any, + }); + + 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); @@ -269,7 +318,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)) { diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts index d36f8a05ffa6..b7ed54f277ad 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts @@ -11,7 +11,13 @@ 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'; + +interface ExistingRawSourceMap { + sources?: string[]; + sourcesContent?: string[]; + mappings?: string; +} + import type { BrowserConfigOptions, InlineConfig, diff --git a/packages/angular/build/src/utils/environment-options.ts b/packages/angular/build/src/utils/environment-options.ts index 80f71d56c119..3a93b0acd28a 100644 --- a/packages/angular/build/src/utils/environment-options.ts +++ b/packages/angular/build/src/utils/environment-options.ts @@ -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. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6060ae0515ee..2782041fc4a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -391,9 +391,9 @@ importers: piscina: specifier: 5.1.4 version: 5.1.4 - rolldown: - specifier: 1.0.0-rc.12 - version: 1.0.0-rc.12 + rollup: + specifier: 4.60.0 + version: 4.60.0 sass: specifier: 1.98.0 version: 1.98.0 @@ -434,6 +434,9 @@ importers: postcss: specifier: 8.5.8 version: 8.5.8 + rolldown: + specifier: 1.0.0-rc.12 + version: 1.0.0-rc.12 rxjs: specifier: 7.8.2 version: 7.8.2 diff --git a/tests/e2e/tests/build/chunk-optimizer.ts b/tests/e2e/tests/build/chunk-optimizer.ts index 366eaa7b4f3d..fff428b47263 100644 --- a/tests/e2e/tests/build/chunk-optimizer.ts +++ b/tests/e2e/tests/build/chunk-optimizer.ts @@ -15,5 +15,5 @@ export default async function () { }); const content = await readFile('dist/test-project/browser/main.js', 'utf-8'); - assert.match(content, /ɵɵdefineComponent/u); + assert.match(content, /(ɵɵ|\\u0275\\u0275)defineComponent/u); } From 2b8e08d6ccefe0f9ae7dd97f4ff6d7481fe08ec4 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 9 Apr 2026 03:22:06 -0400 Subject: [PATCH 2/2] 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. --- .../builders/application/chunk-optimizer.ts | 30 +++-- .../src/builders/application/execute-build.ts | 43 +++++-- .../unit-test/runners/vitest/plugins.ts | 12 +- .../build/src/utils/environment-options.ts | 24 +++- .../src/utils/server-rendering/manifest.ts | 9 +- tests/e2e.bzl | 2 + tests/e2e/tests/build/chunk-optimizer-env.ts | 109 ++++++++++++++++++ .../tests/build/chunk-optimizer-heuristic.ts | 80 +++++++++++++ tests/e2e/tests/build/chunk-optimizer-lazy.ts | 5 +- .../server-routes-preload-links.ts | 23 +++- 10 files changed, 301 insertions(+), 36 deletions(-) create mode 100644 tests/e2e/tests/build/chunk-optimizer-env.ts create mode 100644 tests/e2e/tests/build/chunk-optimizer-heuristic.ts diff --git a/packages/angular/build/src/builders/application/chunk-optimizer.ts b/packages/angular/build/src/builders/application/chunk-optimizer.ts index 1c33e9197240..4d72606640f7 100644 --- a/packages/angular/build/src/builders/application/chunk-optimizer.ts +++ b/packages/angular/build/src/builders/application/chunk-optimizer.ts @@ -19,8 +19,7 @@ import type { Message, Metafile } from 'esbuild'; import assert from 'node:assert'; -import { rollup } from 'rollup'; -import { useRolldownChunks } from '../../utils/environment-options'; +import { type Plugin, rollup } from 'rollup'; import { BuildOutputFile, BuildOutputFileType, @@ -28,7 +27,9 @@ import { 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'; /** * Represents a minimal subset of a Rollup/Rolldown output asset. @@ -137,15 +138,23 @@ function bundleOutputToEsbuildMetafile( ...(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, }; } @@ -201,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', @@ -291,18 +301,20 @@ export async function optimizeChunks( const result = await bundle.generate({ minify: { mangle: false, compress: false }, sourcemap, - chunkFileNames: (chunkInfo) => `${chunkInfo.name.replace(/-[a-zA-Z0-9]{8}$/, '')}-[hash].js`, + chunkFileNames: (chunkInfo) => + `${chunkInfo.name.replace(/-[a-zA-Z0-9]{8}$/, '')}-[hash].js`, }); optimizedOutput = result.output; } else { bundle = await rollup({ input: mainFile, - plugins: plugins as any, + plugins: plugins as Plugin[], }); const result = await bundle.generate({ sourcemap, - chunkFileNames: (chunkInfo) => `${chunkInfo.name.replace(/-[a-zA-Z0-9]{8}$/, '')}-[hash].js`, + chunkFileNames: (chunkInfo) => + `${chunkInfo.name.replace(/-[a-zA-Z0-9]{8}$/, '')}-[hash].js`, }); optimizedOutput = result.output; } diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index aaddc5b6ef7e..3ca4a45f4b9a 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -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, @@ -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, @@ -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 { diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts index b7ed54f277ad..489084fb0e8f 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts @@ -12,12 +12,6 @@ import { createRequire } from 'node:module'; import { platform } from 'node:os'; import path from 'node:path'; -interface ExistingRawSourceMap { - sources?: string[]; - sourcesContent?: string[]; - mappings?: string; -} - import type { BrowserConfigOptions, InlineConfig, @@ -30,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>; interface PluginOptions { diff --git a/packages/angular/build/src/utils/environment-options.ts b/packages/angular/build/src/utils/environment-options.ts index 3a93b0acd28a..b6a486f8f528 100644 --- a/packages/angular/build/src/utils/environment-options.ts +++ b/packages/angular/build/src/utils/environment-options.ts @@ -155,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. diff --git a/packages/angular/build/src/utils/server-rendering/manifest.ts b/packages/angular/build/src/utils/server-rendering/manifest.ts index 34c2e334b52c..41e40d3d1dd5 100644 --- a/packages/angular/build/src/utils/server-rendering/manifest.ts +++ b/packages/angular/build/src/utils/server-rendering/manifest.ts @@ -12,7 +12,6 @@ import { runInThisContext } from 'node:vm'; import { NormalizedApplicationBuildOptions } from '../../builders/application/options'; import { type BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context'; import { createOutputFile } from '../../tools/esbuild/utils'; -import { shouldOptimizeChunks } from '../environment-options'; export const SERVER_APP_MANIFEST_FILENAME = 'angular-app-manifest.mjs'; export const SERVER_APP_ENGINE_MANIFEST_FILENAME = 'angular-app-engine-manifest.mjs'; @@ -168,11 +167,9 @@ export function generateAngularServerAppManifest( } // When routes have been extracted, mappings are no longer needed, as preloads will be included in the metadata. - // When shouldOptimizeChunks is enabled the metadata is no longer correct and thus we cannot generate the mappings. - const entryPointToBrowserMapping = - routes?.length || shouldOptimizeChunks - ? undefined - : generateLazyLoadedFilesMappings(metafile, initialFiles, publicPath); + const entryPointToBrowserMapping = routes?.length + ? undefined + : generateLazyLoadedFilesMappings(metafile, initialFiles, publicPath); const manifestContent = ` export default { diff --git a/tests/e2e.bzl b/tests/e2e.bzl index 02897672a9d3..2c80375c580f 100644 --- a/tests/e2e.bzl +++ b/tests/e2e.bzl @@ -57,6 +57,8 @@ WEBPACK_IGNORE_TESTS = [ "tests/build/incremental-watch.js", "tests/build/chunk-optimizer.js", "tests/build/chunk-optimizer-lazy.js", + "tests/build/chunk-optimizer-heuristic.js", + "tests/build/chunk-optimizer-env.js", ] def _to_glob(patterns): diff --git a/tests/e2e/tests/build/chunk-optimizer-env.ts b/tests/e2e/tests/build/chunk-optimizer-env.ts new file mode 100644 index 000000000000..a7814ee7ac5c --- /dev/null +++ b/tests/e2e/tests/build/chunk-optimizer-env.ts @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import { readdir } from 'node:fs/promises'; +import { replaceInFile } from '../../utils/fs'; +import { execWithEnv, ng } from '../../utils/process'; +import { installPackage, uninstallPackage } from '../../utils/packages'; + +export default async function () { + // Case 1: Force on with true/1 with 1 lazy chunk + await ng('generate', 'component', 'lazy-a'); + await replaceInFile( + 'src/app/app.routes.ts', + 'routes: Routes = [];', + `routes: Routes = [ + { + path: 'lazy-a', + loadComponent: () => import('./lazy-a/lazy-a').then(m => m.LazyA), + }, + ];`, + ); + + // Build with forced optimization + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: 'true', + }); + const files1Opt = await readdir('dist/test-project/browser'); + const jsFiles1Opt = files1Opt.filter((f) => f.endsWith('.js')); + + // Build with forced off + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: 'false', + }); + const files1Unopt = await readdir('dist/test-project/browser'); + const jsFiles1Unopt = files1Unopt.filter((f) => f.endsWith('.js')); + + // We just verify it runs without error. + // With 1 chunk it might not be able to optimize further, so counts might be equal. + + // Case 2: Force off with false/0 with 3 lazy chunks + await ng('generate', 'component', 'lazy-b'); + await ng('generate', 'component', 'lazy-c'); + await replaceInFile( + 'src/app/app.routes.ts', + `path: 'lazy-a', + loadComponent: () => import('./lazy-a/lazy-a').then(m => m.LazyA), + },`, + `path: 'lazy-a', + loadComponent: () => import('./lazy-a/lazy-a').then(m => m.LazyA), + }, + { + path: 'lazy-b', + loadComponent: () => import('./lazy-b/lazy-b').then(m => m.LazyB), + }, + { + path: 'lazy-c', + loadComponent: () => import('./lazy-c/lazy-c').then(m => m.LazyC), + },`, + ); + + // Build with forced off + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: 'false', + }); + const files3Unopt = await readdir('dist/test-project/browser'); + const jsFiles3Unopt = files3Unopt.filter((f) => f.endsWith('.js')); + + // Build with default (should optimize because 3 chunks) + await ng('build', '--output-hashing=none'); + const files3Default = await readdir('dist/test-project/browser'); + const jsFiles3Default = files3Default.filter((f) => f.endsWith('.js')); + + assert.ok( + jsFiles3Default.length < jsFiles3Unopt.length, + `Expected default build (3 chunks) to be optimized compared to forced off. Default: ${jsFiles3Default.length}, Forced Off: ${jsFiles3Unopt.length}`, + ); + + // Case 3: Custom threshold + // Set threshold to 4 with 3 chunks -> should NOT optimize! + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: '4', + }); + const files3Thresh4 = await readdir('dist/test-project/browser'); + const jsFiles3Thresh4 = files3Thresh4.filter((f) => f.endsWith('.js')); + + assert.ok( + jsFiles3Thresh4.length >= jsFiles3Unopt.length, + `Expected build with threshold 4 and 3 chunks to NOT be optimized. Thresh 4: ${jsFiles3Thresh4.length}, Unoptimized: ${jsFiles3Unopt.length}`, + ); + + // Case 4: Opt into Rolldown + await installPackage('rolldown@1.0.0-rc.12'); + try { + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_CHUNKS_ROLLDOWN: '1', + NG_BUILD_OPTIMIZE_CHUNKS: 'true', + }); + const filesRolldown = await readdir('dist/test-project/browser'); + const jsFilesRolldown = filesRolldown.filter((f) => f.endsWith('.js')); + + assert.ok(jsFilesRolldown.length > 0, 'Expected Rolldown build to produce output files.'); + } finally { + // Clean up + await uninstallPackage('rolldown'); + } +} diff --git a/tests/e2e/tests/build/chunk-optimizer-heuristic.ts b/tests/e2e/tests/build/chunk-optimizer-heuristic.ts new file mode 100644 index 000000000000..f8db4220699a --- /dev/null +++ b/tests/e2e/tests/build/chunk-optimizer-heuristic.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { readdir } from 'node:fs/promises'; +import { replaceInFile } from '../../utils/fs'; +import { execWithEnv, ng } from '../../utils/process'; + +export default async function () { + // Case 1: 2 lazy chunks (below threshold of 3) -> should NOT optimize by default + await ng('generate', 'component', 'lazy-a'); + await ng('generate', 'component', 'lazy-b'); + await replaceInFile( + 'src/app/app.routes.ts', + 'routes: Routes = [];', + `routes: Routes = [ + { + path: 'lazy-a', + loadComponent: () => import('./lazy-a/lazy-a').then(m => m.LazyA), + }, + { + path: 'lazy-b', + loadComponent: () => import('./lazy-b/lazy-b').then(m => m.LazyB), + }, + ];`, + ); + + // Build without explicit flag (should use default threshold of 3) + await ng('build', '--output-hashing=none'); + const files2 = await readdir('dist/test-project/browser'); + const jsFiles2 = files2.filter((f) => f.endsWith('.js')); + + // Build with forced optimization to see if it COULD reduce chunks + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: 'true', + }); + const files2Opt = await readdir('dist/test-project/browser'); + const jsFiles2Opt = files2Opt.filter((f) => f.endsWith('.js')); + + // If forced optimization reduces chunks, then default should have MORE chunks (since it didn't run). + // If forced optimization doesn't reduce chunks, they will be equal. + // So we assert that default is NOT fewer than forced. + assert.ok( + jsFiles2.length >= jsFiles2Opt.length, + `Expected default build with 2 lazy chunks to NOT be optimized. Default: ${jsFiles2.length}, Forced: ${jsFiles2Opt.length}`, + ); + + // Case 2: 3 lazy chunks (at threshold of 3) -> should optimize by default + await ng('generate', 'component', 'lazy-c'); + await replaceInFile( + 'src/app/app.routes.ts', + `path: 'lazy-b', + loadComponent: () => import('./lazy-b/lazy-b').then(m => m.LazyB), + },`, + `path: 'lazy-b', + loadComponent: () => import('./lazy-b/lazy-b').then(m => m.LazyB), + }, + { + path: 'lazy-c', + loadComponent: () => import('./lazy-c/lazy-c').then(m => m.LazyC), + },`, + ); + + // Build without explicit flag (should use default threshold of 3) + await ng('build', '--output-hashing=none'); + const files3 = await readdir('dist/test-project/browser'); + const jsFiles3 = files3.filter((f) => f.endsWith('.js')); + + // Build with explicit disable + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: 'false', + }); + const files3Unopt = await readdir('dist/test-project/browser'); + const jsFiles3Unopt = files3Unopt.filter((f) => f.endsWith('.js')); + + // Expect default build to be optimized (fewer chunks than explicitly disabled) + assert.ok( + jsFiles3.length < jsFiles3Unopt.length, + `Expected default build with 3 lazy chunks to be optimized. Default: ${jsFiles3.length}, Unoptimized: ${jsFiles3Unopt.length}`, + ); +} diff --git a/tests/e2e/tests/build/chunk-optimizer-lazy.ts b/tests/e2e/tests/build/chunk-optimizer-lazy.ts index 7f57e6d88e68..9b51c74d0898 100644 --- a/tests/e2e/tests/build/chunk-optimizer-lazy.ts +++ b/tests/e2e/tests/build/chunk-optimizer-lazy.ts @@ -28,7 +28,10 @@ export default async function () { ); // Build without chunk optimization - await ng('build', '--output-hashing=none'); + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: 'false', + }); const unoptimizedFiles = await readdir('dist/test-project/browser'); const unoptimizedJsFiles = unoptimizedFiles.filter((f) => f.endsWith('.js')); diff --git a/tests/e2e/tests/build/server-rendering/server-routes-preload-links.ts b/tests/e2e/tests/build/server-rendering/server-routes-preload-links.ts index fe316e3cd157..5b2f1257d41f 100644 --- a/tests/e2e/tests/build/server-rendering/server-routes-preload-links.ts +++ b/tests/e2e/tests/build/server-rendering/server-routes-preload-links.ts @@ -1,6 +1,6 @@ import assert from 'node:assert'; import { replaceInFile, writeMultipleFiles } from '../../../utils/fs'; -import { execAndWaitForOutputToMatch, ng, noSilentNg, silentNg } from '../../../utils/process'; +import { execAndWaitForOutputToMatch, execWithEnv, ng, silentNg } from '../../../utils/process'; import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages'; import { ngServe, updateJsonFile, useSha } from '../../../utils/project'; import { getGlobalVariable } from '../../../utils/env'; @@ -117,8 +117,27 @@ export default async function () { // Test both vite and `ng build` await runTests(await ngServe()); - await noSilentNg('build', '--output-mode=server'); + // Disable chunk optimization to ensure specific chunks like `ssg.routes` are not merged. + // This test asserts on specific chunk names which optimization may change. + await execWithEnv('ng', ['build', '--output-mode=server'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: 'false', + }); await runTests(await spawnServer()); + + // Test with default build behavior (chunk optimization enabled) + // Only check the preload for the first entry (home) + await ng('build', '--output-mode=server'); + const defaultServerPort = await spawnServer(); + + const res = await fetch(`http://localhost:${defaultServerPort}/`); + const text = await res.text(); + const homeMatch = //; + assert.match(text, homeMatch, `Response for '/': ${homeMatch} was not matched in content.`); + + const link = text.match(homeMatch)?.[1]; + const preloadRes = await fetch(`http://localhost:${defaultServerPort}/${link}`); + assert.equal(preloadRes.status, 200); } const RESPONSE_EXPECTS: Record<