Skip to content

Commit 23e3d44

Browse files
authored
perf(@angular/ssr): pre-compile critical CSS plans at build time
Previously, critical CSS inlining in `@angular/ssr` was executed dynamically at runtime by parsing HTML and stylesheets on each request (or caching processed HTML in an in-memory LRU cache keyed by SHA-256 content hashes). This incurred significant CPU and latency overhead during request processing and precluded efficient stream processing.
1 parent 54d712f commit 23e3d44

28 files changed

Lines changed: 347 additions & 1269 deletions

package.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,6 @@
6666
"@eslint/compat": "2.1.0",
6767
"@eslint/eslintrc": "3.3.6",
6868
"@eslint/js": "10.0.1",
69-
"@rollup/plugin-alias": "^6.0.0",
7069
"@rollup/plugin-commonjs": "^29.0.0",
7170
"@rollup/plugin-json": "^6.1.0",
7271
"@rollup/plugin-node-resolve": "16.0.3",
@@ -122,14 +121,12 @@
122121
"puppeteer": "25.9.0",
123122
"quicktype-core": "26.0.0",
124123
"rollup": "4.63.0",
125-
"rollup-license-plugin": "~3.2.0",
126124
"rollup-plugin-dts": "6.5.1",
127125
"rollup-plugin-sourcemaps2": "0.5.8",
128126
"semver": "7.8.5",
129127
"source-map-support": "0.5.21",
130128
"tslib": "2.8.1",
131129
"undici": "8.10.0",
132-
"unenv": "^1.10.0",
133130
"verdaccio": "6.10.0",
134131
"verdaccio-auth-memory": "^13.0.0",
135132
"zone.js": "^0.16.0"

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ export async function executePostBundleSteps(
123123
// Create server manifest
124124
const initialFilesPaths = new Set(initialFiles.keys());
125125
if (serverEntryPoint && (outputMode || prerenderOptions || appShellOptions || ssrOptions)) {
126-
const { manifestContent, serverAssetsChunks } = generateAngularServerAppManifest(
126+
const { manifestContent, serverAssetsChunks } = await generateAngularServerAppManifest(
127127
additionalHtmlOutputFiles,
128128
outputFiles,
129129
optimizationOptions.styles.inlineCritical ?? false,
@@ -209,7 +209,7 @@ export async function executePostBundleSteps(
209209
const manifest = additionalOutputFiles.find((f) => f.path === SERVER_APP_MANIFEST_FILENAME);
210210
assert(manifest, `${SERVER_APP_MANIFEST_FILENAME} was not found in output files.`);
211211

212-
const { manifestContent, serverAssetsChunks } = generateAngularServerAppManifest(
212+
const { manifestContent, serverAssetsChunks } = await generateAngularServerAppManifest(
213213
additionalHtmlOutputFiles,
214214
outputFiles,
215215
optimizationOptions.styles.inlineCritical ?? false,

packages/angular/build/src/utils/index-file/nonce.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export async function addNonce(html: string): Promise<string> {
4242
}
4343

4444
/** Finds the Angular nonce in an HTML string. */
45-
async function findNonce(html: string): Promise<string | null> {
45+
export async function findNonce(html: string): Promise<string | null> {
4646
// Inexpensive check to avoid parsing the HTML when we're sure there's no nonce.
4747
if (!NONCE_ATTR_PATTERN.test(html)) {
4848
return null;

packages/angular/build/src/utils/server-rendering/manifest.ts

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,17 @@
99
import type { Metafile } from 'esbuild';
1010
import { Buffer } from 'node:buffer';
1111
import { extname } from 'node:path';
12-
import { NormalizedApplicationBuildOptions } from '../../builders/application/options';
12+
import {
13+
INDEX_HTML_SERVER,
14+
NormalizedApplicationBuildOptions,
15+
} from '../../builders/application/options';
1316
import {
1417
type BuildOutputFile,
1518
BuildOutputFileType,
1619
createOutputFile,
1720
} from '../../tools/esbuild/bundler-files';
21+
import { findNonce } from '../index-file/nonce';
22+
import { joinUrlParts } from '../url';
1823

1924
export const SERVER_APP_MANIFEST_FILENAME = 'angular-app-manifest.mjs';
2025
export const SERVER_APP_ENGINE_MANIFEST_FILENAME = 'angular-app-engine-manifest.mjs';
@@ -33,11 +38,6 @@ export const SERVER_GENERATED_EXTERNALS = new Set([
3338
'./' + SERVER_APP_ENGINE_MANIFEST_FILENAME,
3439
]);
3540

36-
interface FilesMapping {
37-
path: string;
38-
dynamicImport: boolean;
39-
}
40-
4141
const MAIN_SERVER_OUTPUT_FILENAME = 'main.server.mjs';
4242

4343
/**
@@ -143,7 +143,7 @@ export default {
143143
* - `manifestContent`: A string of the SSR manifest content.
144144
* - `serverAssetsChunks`: An array of build output files containing the generated assets for the server.
145145
*/
146-
export function generateAngularServerAppManifest(
146+
export async function generateAngularServerAppManifest(
147147
additionalHtmlOutputFiles: Map<string, BuildOutputFile>,
148148
outputFiles: BuildOutputFile[],
149149
inlineCriticalCss: boolean,
@@ -153,16 +153,23 @@ export function generateAngularServerAppManifest(
153153
initialFiles: Set<string>,
154154
metafile: Metafile,
155155
publicPath: string | undefined,
156-
): {
156+
): Promise<{
157157
manifestContent: string;
158158
serverAssetsChunks: BuildOutputFile[];
159-
} {
159+
}> {
160160
const serverAssetsChunks: BuildOutputFile[] = [];
161161
const serverAssets: Record<string, string> = {};
162+
const criticalCssPlans: unknown[] = [];
163+
let nonce: string | undefined;
164+
165+
// TODO(alanagius): This is done here as we do not use module resolution bundler/node16
166+
const { compileSheet, encodePlan } = (await import(
167+
'beasties/compiler' as string
168+
)) as typeof import('beasties/compiler', { with: { 'resolution-mode': 'import' } });
162169

163170
for (const file of [...additionalHtmlOutputFiles.values(), ...outputFiles]) {
164171
const extension = extname(file.path);
165-
if (extension === '.html' || (inlineCriticalCss && extension === '.css')) {
172+
if (extension === '.html') {
166173
const jsChunkFilePath = `assets-chunks/${file.path.replace(/[./]/g, '_')}.mjs`;
167174
const escapedContent = escapeUnsafeChars(file.text);
168175

@@ -185,9 +192,19 @@ export function generateAngularServerAppManifest(
185192

186193
serverAssets[file.path] =
187194
`{size: ${size}, hash: '${file.hash}', text: () => import('./${jsChunkFilePath}').then(m => m.default)}`;
195+
} else if (inlineCriticalCss && extension === '.css') {
196+
const sheet = compileSheet(file.text, {
197+
href: joinUrlParts(publicPath ?? '', file.path),
198+
});
199+
criticalCssPlans.push(encodePlan(sheet));
188200
}
189201
}
190202

203+
const indexHtml = additionalHtmlOutputFiles.get(INDEX_HTML_SERVER)?.text;
204+
if (indexHtml) {
205+
nonce = (await findNonce(indexHtml)) ?? undefined;
206+
}
207+
191208
// When routes have been extracted, mappings are no longer needed, as preloads will be included in the metadata.
192209
const entryPointToBrowserMapping = routes?.length
193210
? undefined
@@ -196,9 +213,10 @@ export function generateAngularServerAppManifest(
196213
const manifestContent = `
197214
export default {
198215
bootstrap: () => import('./main.server.mjs').then(m => m.default),
199-
inlineCriticalCss: ${inlineCriticalCss},
200216
baseHref: '${baseHref}',
201-
locale: ${JSON.stringify(locale)},
217+
${criticalCssPlans.length ? ` criticalCssPlans: ${JSON.stringify(criticalCssPlans)},\n` : ''}${
218+
nonce ? ` nonce: ${JSON.stringify(nonce)},\n` : ''
219+
} locale: ${JSON.stringify(locale)},
202220
routes: ${JSON.stringify(routes, undefined, 2)},
203221
entryPointToBrowserMapping: ${JSON.stringify(entryPointToBrowserMapping, undefined, 2)},
204222
assets: {
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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 type { Metafile } from 'esbuild';
10+
import { BuildOutputFileType, createOutputFile } from '../../tools/esbuild/bundler-files';
11+
import { initializeHash } from '../hash';
12+
import { generateAngularServerAppManifest } from './manifest';
13+
14+
describe('generateAngularServerAppManifest', () => {
15+
beforeAll(async () => {
16+
await initializeHash();
17+
});
18+
19+
const dummyMetafile = { inputs: {}, outputs: {} } as unknown as Metafile;
20+
21+
it('should include criticalCssPlans when inlineCriticalCss is true', async () => {
22+
const additionalHtml = new Map([
23+
[
24+
'index.server.html',
25+
createOutputFile(
26+
'index.server.html',
27+
'<html><body><app-root></app-root></body></html>',
28+
BuildOutputFileType.ServerApplication,
29+
),
30+
],
31+
]);
32+
const outputFiles = [
33+
createOutputFile('styles.css', 'h1 { color: blue; }', BuildOutputFileType.Browser),
34+
];
35+
36+
const { manifestContent } = await generateAngularServerAppManifest(
37+
additionalHtml,
38+
outputFiles,
39+
true,
40+
undefined,
41+
undefined,
42+
'/',
43+
new Set(),
44+
dummyMetafile,
45+
undefined,
46+
);
47+
48+
expect(manifestContent).toContain('criticalCssPlans: [');
49+
expect(manifestContent).toContain('styles.css');
50+
expect(manifestContent).not.toContain('nonce:');
51+
});
52+
53+
it('should not include criticalCssPlans when inlineCriticalCss is false', async () => {
54+
const additionalHtml = new Map([
55+
[
56+
'index.server.html',
57+
createOutputFile(
58+
'index.server.html',
59+
'<html><body><app-root></app-root></body></html>',
60+
BuildOutputFileType.ServerApplication,
61+
),
62+
],
63+
]);
64+
const outputFiles = [
65+
createOutputFile('styles.css', 'h1 { color: blue; }', BuildOutputFileType.Browser),
66+
];
67+
68+
const { manifestContent } = await generateAngularServerAppManifest(
69+
additionalHtml,
70+
outputFiles,
71+
false,
72+
undefined,
73+
undefined,
74+
'/',
75+
new Set(),
76+
dummyMetafile,
77+
undefined,
78+
);
79+
80+
expect(manifestContent).not.toContain('criticalCssPlans:');
81+
});
82+
83+
it('should extract template nonce from index HTML when present', async () => {
84+
const additionalHtml = new Map([
85+
[
86+
'index.server.html',
87+
createOutputFile(
88+
'index.server.html',
89+
'<html><body><app-root ngCspNonce="{% nonce %}"></app-root></body></html>',
90+
BuildOutputFileType.ServerApplication,
91+
),
92+
],
93+
]);
94+
const outputFiles = [
95+
createOutputFile('styles.css', 'h1 { color: blue; }', BuildOutputFileType.Browser),
96+
];
97+
98+
const { manifestContent } = await generateAngularServerAppManifest(
99+
additionalHtml,
100+
outputFiles,
101+
true,
102+
undefined,
103+
undefined,
104+
'/',
105+
new Set(),
106+
dummyMetafile,
107+
undefined,
108+
);
109+
110+
expect(manifestContent).toContain('nonce: "{% nonce %}"');
111+
});
112+
113+
it('should not include css files in serverAssetsChunks or assets', async () => {
114+
const additionalHtml = new Map([
115+
[
116+
'index.server.html',
117+
createOutputFile(
118+
'index.server.html',
119+
'<html><body></body></html>',
120+
BuildOutputFileType.ServerApplication,
121+
),
122+
],
123+
]);
124+
const outputFiles = [
125+
createOutputFile('styles.css', 'h1 { color: blue; }', BuildOutputFileType.Browser),
126+
];
127+
128+
const { manifestContent, serverAssetsChunks } = await generateAngularServerAppManifest(
129+
additionalHtml,
130+
outputFiles,
131+
true,
132+
undefined,
133+
undefined,
134+
'/',
135+
new Set(),
136+
dummyMetafile,
137+
undefined,
138+
);
139+
140+
expect(serverAssetsChunks.some((chunk) => chunk.path.includes('styles'))).toBeFalse();
141+
expect(manifestContent).not.toContain("'styles.css':");
142+
});
143+
});

packages/angular/ssr/BUILD.bazel

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,43 +22,30 @@ ts_project(
2222
"--lib",
2323
"dom.iterable,dom,es2022",
2424
],
25-
data = [
26-
"//packages/angular/ssr/third_party/beasties:beasties_bundled",
27-
],
2825
source_map = True,
2926
tsconfig = "//:build-tsconfig-esm",
3027
deps = [
28+
":node_modules/beasties",
3129
"//:node_modules/@angular/common",
3230
"//:node_modules/@angular/core",
3331
"//:node_modules/@angular/platform-browser",
3432
"//:node_modules/@angular/platform-server",
3533
"//:node_modules/@angular/router",
3634
"//:node_modules/tslib",
37-
"//packages/angular/ssr/third_party/beasties:beasties_dts",
3835
],
3936
)
4037

4138
ng_package(
4239
name = "npm_package",
4340
srcs = [
4441
":package.json",
45-
"//packages/angular/ssr/third_party/beasties:beasties_bundled",
4642
],
4743
externals = [
4844
"@angular/ssr",
4945
"@angular/ssr/node",
50-
"../../third_party/beasties",
5146
],
52-
extra_substitutions = {
53-
# Needed for ssr.d.ts file
54-
"\\./third_party/beasties": "../third_party/beasties",
55-
# Needed for the FESM file.
56-
"\\./(.+)/packages/angular/ssr/third_party/beasties": "../third_party/beasties/index.js",
57-
},
5847
nested_packages = [
5948
"//packages/angular/ssr/schematics:pkg",
60-
# Included directly as the generated types reference the types file in this location.
61-
"//packages/angular/ssr/third_party/beasties:beasties_dts",
6249
],
6350
package = "@angular/ssr",
6451
readme_md = ":README.md",

packages/angular/ssr/node/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,6 @@ ts_project(
2121
"//:node_modules/@angular/platform-server",
2222
"//:node_modules/@types/node",
2323
"//packages/angular/ssr",
24+
"//packages/angular/ssr:node_modules/beasties",
2425
],
2526
)

0 commit comments

Comments
 (0)