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.`, + ); + } } }