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..4d72606640f7 100644 --- a/packages/angular/build/src/builders/application/chunk-optimizer.ts +++ b/packages/angular/build/src/builders/application/chunk-optimizer.ts @@ -19,7 +19,7 @@ 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, @@ -27,16 +27,50 @@ 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'; /** - * 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 +86,7 @@ function rolldownToEsbuildMetafile( ); } - for (const chunk of rolldownOutput) { + for (const chunk of bundleOutput) { if (chunk.type === 'asset') { newMetafile.outputs[chunk.fileName] = { bytes: @@ -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, }; } @@ -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', @@ -214,49 +257,67 @@ 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 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); @@ -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)) { 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 d36f8a05ffa6..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 @@ -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, @@ -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>; interface PluginOptions { diff --git a/packages/angular/build/src/utils/environment-options.ts b/packages/angular/build/src/utils/environment-options.ts index 80f71d56c119..b6a486f8f528 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. @@ -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. 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/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.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/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); } 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<