Skip to content

Commit 9458788

Browse files
committed
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.
1 parent e4cbf33 commit 9458788

3 files changed

Lines changed: 222 additions & 13 deletions

File tree

packages/angular/build/src/builders/application/execute-build.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -175,10 +175,7 @@ export async function executeBuild(
175175

176176
// Only run if the number of lazy chunks meets the configured threshold.
177177
// This avoids overhead for small projects with few chunks.
178-
179-
// TODO: Remove this log once chunk optimization is supported for server builds as this
180-
// causes the file to be renamed and thus causes incorrect preloading.
181-
if (!options.serverEntryPoint && lazyChunksCount >= optimizeChunksThreshold) {
178+
if (lazyChunksCount >= optimizeChunksThreshold) {
182179
const { optimizeChunks } = await import('./chunk-optimizer');
183180
const optimizationResult = await profileAsync('OPTIMIZE_CHUNKS', () =>
184181
optimizeChunks(
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { buildApplication } from '../../index';
10+
import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup';
11+
12+
/**
13+
* Fixture application with a server entry point and four lazy routes.
14+
* Four lazy chunks exceed the default chunk optimization threshold (3),
15+
* so the optimization pass runs without requiring the
16+
* `NG_BUILD_OPTIMIZE_CHUNKS` environment variable (which is captured at
17+
* module load time and cannot be toggled per spec).
18+
*
19+
* `shared.ts` is imported statically by both `main.ts` and two of the lazy
20+
* components. esbuild emits such modules as a separate `chunk-*.js` shared
21+
* chunk, while the chunk optimizer merges entry-reachable modules back into
22+
* the main chunk. The absence of `chunk-*.js` files is therefore used as a
23+
* signal that the optimization pass actually ran.
24+
*/
25+
const LAZY_ROUTE_NAMES = ['lazy-a', 'lazy-b', 'lazy-c', 'lazy-d'] as const;
26+
27+
function lazyComponentSource(name: string, useShared: boolean): string {
28+
const className = name.replace(/(^|-)(\w)/g, (_, __, c: string) => c.toUpperCase());
29+
30+
return `
31+
import { Component } from '@angular/core';
32+
${useShared ? `import { sharedValue } from '../shared';` : ''}
33+
34+
@Component({
35+
selector: 'app-${name}',
36+
template: '<p>${name} works! ${useShared ? '{{ shared }}' : ''}</p>',
37+
})
38+
export default class ${className}Component {
39+
${useShared ? `shared = sharedValue();` : ''}
40+
}
41+
`;
42+
}
43+
44+
const serverLazyRoutesFiles: Record<string, string> = {
45+
'src/shared.ts': `
46+
export function sharedValue(): string {
47+
return 'shared-' + Date.now().toString(36);
48+
}
49+
`,
50+
'src/app/app.routes.ts': `
51+
import { Routes } from '@angular/router';
52+
53+
export const routes: Routes = [
54+
${LAZY_ROUTE_NAMES.map(
55+
(name) => `{ path: '${name}', loadComponent: () => import('./${name}.component') },`,
56+
).join('\n ')}
57+
];
58+
`,
59+
...Object.fromEntries(
60+
LAZY_ROUTE_NAMES.map((name, index) => [
61+
`src/app/${name}.component.ts`,
62+
lazyComponentSource(name, index < 2),
63+
]),
64+
),
65+
'src/app/app.component.ts': `
66+
import { Component } from '@angular/core';
67+
import { RouterOutlet } from '@angular/router';
68+
import { sharedValue } from '../shared';
69+
70+
@Component({
71+
selector: 'app-root',
72+
imports: [RouterOutlet],
73+
template: '<p>{{ shared }}</p><router-outlet></router-outlet>',
74+
})
75+
export class AppComponent {
76+
shared = sharedValue();
77+
}
78+
`,
79+
'src/app/app.config.ts': `
80+
import { ApplicationConfig } from '@angular/core';
81+
import { provideRouter } from '@angular/router';
82+
import { routes } from './app.routes';
83+
84+
export const appConfig: ApplicationConfig = {
85+
providers: [provideRouter(routes)],
86+
};
87+
`,
88+
'src/main.ts': `
89+
import { bootstrapApplication } from '@angular/platform-browser';
90+
import { AppComponent } from './app/app.component';
91+
import { appConfig } from './app/app.config';
92+
93+
bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err));
94+
`,
95+
'src/main.server.ts': `
96+
import { mergeApplicationConfig } from '@angular/core';
97+
import { bootstrapApplication, BootstrapContext } from '@angular/platform-browser';
98+
import { provideServerRendering } from '@angular/platform-server';
99+
import { AppComponent } from './app/app.component';
100+
import { appConfig } from './app/app.config';
101+
102+
const serverConfig = mergeApplicationConfig(appConfig, {
103+
providers: [provideServerRendering()],
104+
});
105+
106+
const bootstrap = (context: BootstrapContext) =>
107+
bootstrapApplication(AppComponent, serverConfig, context);
108+
109+
export default bootstrap;
110+
`,
111+
};
112+
113+
describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
114+
describe('Behavior: "Chunk optimization with a server entry point"', () => {
115+
beforeEach(async () => {
116+
await harness.modifyFile('src/tsconfig.app.json', (content) => {
117+
const tsConfig = JSON.parse(content);
118+
tsConfig.files ??= [];
119+
tsConfig.files.push('main.server.ts');
120+
121+
return JSON.stringify(tsConfig);
122+
});
123+
124+
await harness.writeFiles(serverLazyRoutesFiles);
125+
});
126+
127+
it('generates a server manifest consistent with the optimized browser chunks', async () => {
128+
harness.useTarget('build', {
129+
...BASE_OPTIONS,
130+
server: 'src/main.server.ts',
131+
ssr: true,
132+
polyfills: ['zone.js'],
133+
optimization: true,
134+
// Name lazy chunks after their route entry points so that only shared
135+
// chunks use the `chunk-` prefix, which the assertions below rely on.
136+
namedChunks: true,
137+
});
138+
139+
const { result } = await harness.executeOnce();
140+
expect(result?.success).toBeTrue();
141+
142+
// The chunk optimizer merges entry-reachable shared modules back into the
143+
// main chunk. A remaining `chunk-*.js` shared chunk indicates the
144+
// optimization pass did not run and this test would be vacuous.
145+
expect(harness.hasFileMatch('dist/browser', /^chunk-/)).toBeFalse();
146+
147+
const manifestContent = harness.readFile('dist/server/angular-app-manifest.mjs');
148+
const mappingSource = /entryPointToBrowserMapping: (\{[\s\S]*?\n\}),/.exec(manifestContent);
149+
expect(mappingSource)
150+
.withContext('entryPointToBrowserMapping should be present in the server manifest')
151+
.not.toBeNull();
152+
153+
const mapping = JSON.parse(mappingSource![1]) as Record<string, string[]>;
154+
155+
// Every lazy route entry point must retain a mapping entry after optimization.
156+
for (const name of LAZY_ROUTE_NAMES) {
157+
const key = Object.keys(mapping).find((entryPoint) =>
158+
entryPoint.endsWith(`${name}.component.ts`),
159+
);
160+
expect(key)
161+
.withContext(`mapping entry for lazy route '${name}' should exist`)
162+
.toBeDefined();
163+
}
164+
165+
// Every browser file referenced by the mapping must exist on disk.
166+
for (const files of Object.values(mapping)) {
167+
for (const file of files) {
168+
expect(harness.hasFile(`dist/browser/${file}`))
169+
.withContext(`mapped browser file '${file}' should exist`)
170+
.toBeTrue();
171+
}
172+
}
173+
174+
// All scripts referenced by the index HTML must exist on disk.
175+
const indexContent = harness.readFile('dist/browser/index.csr.html');
176+
const scriptRefs = [
177+
...indexContent.matchAll(/<(?:script src|link rel="modulepreload" href)="([^"]+)"/g),
178+
].map((match) => match[1]);
179+
expect(scriptRefs.length).toBeGreaterThan(0);
180+
for (const file of scriptRefs) {
181+
expect(harness.hasFile(`dist/browser/${file}`))
182+
.withContext(`index.html referenced file '${file}' should exist`)
183+
.toBeTrue();
184+
}
185+
});
186+
});
187+
});

tests/e2e/tests/build/server-rendering/server-routes-preload-links.ts

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -125,19 +125,44 @@ export default async function () {
125125
});
126126
await runTests(await spawnServer());
127127

128-
// Test with default build behavior (chunk optimization enabled)
129-
// Only check the preload for the first entry (home)
128+
// Test with default build behavior (chunk optimization enabled).
129+
// Optimization may merge or rename chunks, so instead of asserting specific
130+
// chunk names, verify that every route emits at least one preload link and
131+
// that every emitted preload link resolves to an actual file.
130132
await ng('build', '--output-mode=server');
131133
const defaultServerPort = await spawnServer();
132134

133-
const res = await fetch(`http://localhost:${defaultServerPort}/`);
134-
const text = await res.text();
135-
const homeMatch = /<link rel="modulepreload" href="(home-[a-zA-Z0-9_]{8}\.js)">/;
136-
assert.match(text, homeMatch, `Response for '/': ${homeMatch} was not matched in content.`);
135+
for (const pathname of Object.keys(RESPONSE_EXPECTS)) {
136+
const res = await fetch(`http://localhost:${defaultServerPort}${pathname}`);
137+
assert.equal(res.status, 200, `Response for '${pathname}' should be 200.`);
138+
const text = await res.text();
139+
140+
const preloadLinks = [...text.matchAll(/<link rel="modulepreload" href="([^"]+)">/g)].map(
141+
(match) => match[1],
142+
);
143+
assert.ok(
144+
preloadLinks.length > 0,
145+
`Optimized response for '${pathname}' should contain at least one modulepreload link.`,
146+
);
147+
148+
for (const link of preloadLinks) {
149+
const preloadRes = await fetch(`http://localhost:${defaultServerPort}/${link}`);
150+
assert.equal(
151+
preloadRes.status,
152+
200,
153+
`Optimized preload link '${link}' for '${pathname}' should resolve.`,
154+
);
155+
}
156+
}
137157

138-
const link = text.match(homeMatch)?.[1];
139-
const preloadRes = await fetch(`http://localhost:${defaultServerPort}/${link}`);
140-
assert.equal(preloadRes.status, 200);
158+
// The lazy route chunk for the root path retains its name through optimization.
159+
const homeRes = await fetch(`http://localhost:${defaultServerPort}/`);
160+
const homeMatch = /<link rel="modulepreload" href="(home-[a-zA-Z0-9_-]{8}\.js)">/;
161+
assert.match(
162+
await homeRes.text(),
163+
homeMatch,
164+
`Response for '/': ${homeMatch} was not matched in content.`,
165+
);
141166
}
142167

143168
const RESPONSE_EXPECTS: Record<

0 commit comments

Comments
 (0)