From 1fb5105c28c65f1d840330b0888e6237eb76b2f8 Mon Sep 17 00:00:00 2001 From: Julien Saguet Date: Sun, 19 Jul 2026 09:46:19 +0200 Subject: [PATCH] feat(@angular/build): enable chunk optimization for server builds The chunk optimization pass was disabled when a server entry point was present due to a concern that renamed browser chunks would cause incorrect preloading. The preload metadata (route tree preload arrays and entryPointToBrowserMapping in the server app manifest) is generated after the optimization pass from the optimized metafile, so renamed and merged chunks stay consistent with the emitted files. A behavior test now asserts that the server manifest only references browser chunks that exist after optimization and that every lazy route retains its entry point mapping. The server-routes-preload-links e2e test now verifies that every route of an optimized build emits preload links that resolve successfully, instead of only checking the root route. --- .../src/builders/application/execute-build.ts | 5 +- .../chunk-optimization-server_spec.ts | 187 ++++++++++++++++++ .../server-routes-preload-links.ts | 38 ++-- 3 files changed, 208 insertions(+), 22 deletions(-) create mode 100644 packages/angular/build/src/builders/application/tests/behavior/chunk-optimization-server_spec.ts diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index 857ef564f52a..21beb57bf478 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -175,10 +175,7 @@ export async function executeBuild( // Only run if the number of lazy chunks meets the configured threshold. // This avoids overhead for small projects with few chunks. - - // TODO: Remove this log once chunk optimization is supported for server builds as this - // causes the file to be renamed and thus causes incorrect preloading. - if (!options.serverEntryPoint && lazyChunksCount >= optimizeChunksThreshold) { + if (lazyChunksCount >= optimizeChunksThreshold) { const { optimizeChunks } = await import('./chunk-optimizer'); const optimizationResult = await profileAsync('OPTIMIZE_CHUNKS', () => optimizeChunks( diff --git a/packages/angular/build/src/builders/application/tests/behavior/chunk-optimization-server_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/chunk-optimization-server_spec.ts new file mode 100644 index 000000000000..76ecbbc4329e --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/chunk-optimization-server_spec.ts @@ -0,0 +1,187 @@ +/** + * @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 { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +/** + * Fixture application with a server entry point and four lazy routes. + * Four lazy chunks exceed the default chunk optimization threshold (3), + * so the optimization pass runs without requiring the + * `NG_BUILD_OPTIMIZE_CHUNKS` environment variable (which is captured at + * module load time and cannot be toggled per spec). + * + * `shared.ts` is imported statically by both `main.ts` and two of the lazy + * components. esbuild emits such modules as a separate `chunk-*.js` shared + * chunk, while the chunk optimizer merges entry-reachable modules back into + * the main chunk. The absence of `chunk-*.js` files is therefore used as a + * signal that the optimization pass actually ran. + */ +const LAZY_ROUTE_NAMES = ['lazy-a', 'lazy-b', 'lazy-c', 'lazy-d'] as const; + +function lazyComponentSource(name: string, useShared: boolean): string { + const className = name.replace(/(^|-)(\w)/g, (_, __, c: string) => c.toUpperCase()); + + return ` + import { Component } from '@angular/core'; + ${useShared ? `import { sharedValue } from '../shared';` : ''} + + @Component({ + selector: 'app-${name}', + template: '

${name} works! ${useShared ? '{{ shared }}' : ''}

', + }) + export default class ${className}Component { + ${useShared ? `shared = sharedValue();` : ''} + } + `; +} + +const serverLazyRoutesFiles: Record = { + 'src/shared.ts': ` + export function sharedValue(): string { + return 'shared-' + Date.now().toString(36); + } + `, + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + + export const routes: Routes = [ + ${LAZY_ROUTE_NAMES.map( + (name) => `{ path: '${name}', loadComponent: () => import('./${name}.component') },`, + ).join('\n ')} + ]; + `, + ...Object.fromEntries( + LAZY_ROUTE_NAMES.map((name, index) => [ + `src/app/${name}.component.ts`, + lazyComponentSource(name, index < 2), + ]), + ), + 'src/app/app.component.ts': ` + import { Component } from '@angular/core'; + import { RouterOutlet } from '@angular/router'; + import { sharedValue } from '../shared'; + + @Component({ + selector: 'app-root', + imports: [RouterOutlet], + template: '

{{ shared }}

', + }) + export class AppComponent { + shared = sharedValue(); + } + `, + 'src/app/app.config.ts': ` + import { ApplicationConfig } from '@angular/core'; + import { provideRouter } from '@angular/router'; + import { routes } from './app.routes'; + + export const appConfig: ApplicationConfig = { + providers: [provideRouter(routes)], + }; + `, + 'src/main.ts': ` + import { bootstrapApplication } from '@angular/platform-browser'; + import { AppComponent } from './app/app.component'; + import { appConfig } from './app/app.config'; + + bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err)); + `, + 'src/main.server.ts': ` + import { mergeApplicationConfig } from '@angular/core'; + import { bootstrapApplication, BootstrapContext } from '@angular/platform-browser'; + import { provideServerRendering } from '@angular/platform-server'; + import { AppComponent } from './app/app.component'; + import { appConfig } from './app/app.config'; + + const serverConfig = mergeApplicationConfig(appConfig, { + providers: [provideServerRendering()], + }); + + const bootstrap = (context: BootstrapContext) => + bootstrapApplication(AppComponent, serverConfig, context); + + export default bootstrap; + `, +}; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Chunk optimization with a server entry point"', () => { + beforeEach(async () => { + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('main.server.ts'); + + return JSON.stringify(tsConfig); + }); + + await harness.writeFiles(serverLazyRoutesFiles); + }); + + it('generates a server manifest consistent with the optimized browser chunks', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: true, + polyfills: ['zone.js'], + optimization: true, + // Name lazy chunks after their route entry points so that only shared + // chunks use the `chunk-` prefix, which the assertions below rely on. + namedChunks: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // The chunk optimizer merges entry-reachable shared modules back into the + // main chunk. A remaining `chunk-*.js` shared chunk indicates the + // optimization pass did not run and this test would be vacuous. + expect(harness.hasFileMatch('dist/browser', /^chunk-/)).toBeFalse(); + + const manifestContent = harness.readFile('dist/server/angular-app-manifest.mjs'); + const mappingSource = /entryPointToBrowserMapping: (\{[\s\S]*?\n\})/.exec(manifestContent); + expect(mappingSource) + .withContext('entryPointToBrowserMapping should be present in the server manifest') + .not.toBeNull(); + + const mapping = JSON.parse(mappingSource![1]) as Record; + + // Every lazy route entry point must retain a mapping entry after optimization. + for (const name of LAZY_ROUTE_NAMES) { + const key = Object.keys(mapping).find((entryPoint) => + entryPoint.endsWith(`${name}.component.ts`), + ); + expect(key) + .withContext(`mapping entry for lazy route '${name}' should exist`) + .toBeDefined(); + } + + // Every browser file referenced by the mapping must exist on disk. + for (const files of Object.values(mapping)) { + for (const file of files) { + expect(harness.hasFile(`dist/browser/${file}`)) + .withContext(`mapped browser file '${file}' should exist`) + .toBeTrue(); + } + } + + // All scripts referenced by the index HTML must exist on disk. + const indexContent = harness.readFile('dist/browser/index.csr.html'); + const scriptRefs = [ + ...indexContent.matchAll(/<(?:script src|link rel="modulepreload" href)="([^"]+)"/g), + ].map((match) => match[1]); + expect(scriptRefs.length).toBeGreaterThan(0); + for (const file of scriptRefs) { + expect(harness.hasFile(`dist/browser/${file}`)) + .withContext(`index.html referenced file '${file}' should exist`) + .toBeTrue(); + } + }); + }); +}); 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 4094ee4351b9..5eeef8888860 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 @@ -117,8 +117,7 @@ export default async function () { // Test both vite and `ng build` await runTests(await ngServe()); - // Disable chunk optimization to ensure specific chunks like `ssg.routes` are not merged. - // This test asserts on specific chunk names which optimization may change. + // Test with chunk optimization explicitly disabled await execWithEnv('ng', ['build', '--output-mode=server'], { ...process.env, NG_BUILD_OPTIMIZE_CHUNKS: 'false', @@ -126,18 +125,8 @@ export default async function () { 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); + await runTests(await spawnServer()); } const RESPONSE_EXPECTS: Record< @@ -189,11 +178,6 @@ async function runTests(port: number): Promise { for (const match of matches) { assert.match(text, match, `Response for '${pathname}': ${match} was not matched in content.`); - - // Ensure that the url is correct and it's a 200. - const link = text.match(match)?.[1]; - const preloadRes = await fetch(`http://localhost:${port}/${link}`); - assert.equal(preloadRes.status, 200); } for (const match of notMatches) { @@ -203,6 +187,24 @@ async function runTests(port: number): Promise { `Response for '${pathname}': ${match} was matched in content.`, ); } + + // Every emitted preload link must resolve successfully. + const preloadLinks = [...text.matchAll(//g)].map( + (match) => match[1], + ); + assert.ok( + preloadLinks.length > 0, + `Response for '${pathname}' should contain at least one modulepreload link.`, + ); + + for (const link of preloadLinks) { + const preloadRes = await fetch(new URL(link, `http://localhost:${port}/`)); + assert.equal( + preloadRes.status, + 200, + `Preload link '${link}' for '${pathname}' should resolve.`, + ); + } } }