Skip to content

Commit 31f8133

Browse files
committed
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. This commit updates the critical CSS workflow to pre-compile stylesheet plans during the build step via Beasties' compiler (`compileSheet` and `encodePlan`). These compact plans are stored in the server application manifest (`criticalCssPlans`) along with any CSP nonce. At runtime, `AngularServerApp` initializes Beasties' runtime processor with the pre-compiled plans, allowing fast single-pass string and stream processing without re-parsing stylesheets, calculating content hashes, or maintaining a runtime LRU cache. Consequently, `InlineCriticalCssProcessor`, `LRUCache`, and `crypto.ts` utility classes have been removed from `@angular/ssr`.
1 parent 760c822 commit 31f8133

38 files changed

Lines changed: 818 additions & 1447 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/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
"@inquirer/confirm": "6.2.0",
2525
"@parcel/watcher": "2.6.0",
2626
"@vitejs/plugin-basic-ssl": "2.3.0",
27-
"beasties": "0.5.0",
27+
"beasties": "0.5.1",
2828
"browserslist": "^4.26.0",
2929
"chokidar": "5.0.0",
3030
"esbuild": "0.28.2",

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/private.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,6 @@ export {
8080
type IndexHtmlTransform,
8181
} from './utils/index-file/index-html-generator';
8282
export type { FileInfo } from './utils/index-file/augment-index-html';
83-
export {
84-
InlineCriticalCssProcessor,
85-
type InlineCriticalCssProcessorOptions,
86-
} from './utils/index-file/inline-critical-css';
8783
export { loadProxyConfiguration } from './utils/load-proxy-config';
8884
export { type TranslationLoader, createTranslationLoader } from './utils/load-translations';
8985
export { purgeStaleBuildCache } from './utils/purge-cache';

packages/angular/build/src/utils/index-file/index-html-generator.ts

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { NormalizedOptimizationOptions } from '../normalize-optimization';
1313
import { addEventDispatchContract } from './add-event-dispatch-contract';
1414
import { CrossOriginValue, Entrypoint, FileInfo, augmentIndexHtml } from './augment-index-html';
1515
import { autoCsp } from './auto-csp';
16-
import { InlineCriticalCssProcessor } from './inline-critical-css';
16+
import { inlineCriticalCss } from './inline-critical-css';
1717
import { InlineFontsProcessor } from './inline-fonts';
1818
import { addNgcmAttribute } from './ngcm-attribute';
1919
import { addNonce } from './nonce';
@@ -89,7 +89,7 @@ export class IndexHtmlGenerator {
8989

9090
// CSR plugins
9191
if (options?.optimization?.styles?.inlineCritical) {
92-
this.csrPlugins.push(inlineCriticalCssPlugin(this, !!options.autoCsp));
92+
this.csrPlugins.push(inlineCriticalCssPlugin(this));
9393
}
9494

9595
this.csrPlugins.push(addNoncePlugin());
@@ -213,19 +213,11 @@ function inlineFontsPlugin({ options }: IndexHtmlGenerator): IndexHtmlGeneratorP
213213
return (html) => inlineFontsProcessor.process(html);
214214
}
215215

216-
function inlineCriticalCssPlugin(
217-
generator: IndexHtmlGenerator,
218-
autoCsp: boolean,
219-
): IndexHtmlGeneratorPlugin {
220-
const inlineCriticalCssProcessor = new InlineCriticalCssProcessor({
221-
minify: generator.options.optimization?.styles.minify,
222-
deployUrl: generator.options.deployUrl,
223-
readAsset: (filePath) => generator.readAsset(filePath),
224-
autoCsp,
225-
outputPath: generator.options.outputPath,
226-
});
216+
function inlineCriticalCssPlugin(generator: IndexHtmlGenerator): IndexHtmlGeneratorPlugin {
217+
const { outputPath, deployUrl, optimization } = generator.options;
218+
const { minify = false } = optimization?.styles ?? {};
227219

228-
return (html) => inlineCriticalCssProcessor.process(html);
220+
return (html) => inlineCriticalCss(html, outputPath, deployUrl, minify, generator.readAsset);
229221
}
230222

231223
function addNoncePlugin(): IndexHtmlGeneratorPlugin {

packages/angular/build/src/utils/index-file/inline-critical-css.ts

Lines changed: 58 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -6,91 +6,68 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import { readFile } from 'node:fs/promises';
10-
11-
export interface InlineCriticalCssProcessorOptions {
12-
minify?: boolean;
13-
deployUrl?: string;
14-
readAsset?: (path: string) => Promise<string>;
15-
autoCsp?: boolean;
16-
outputPath?: string;
17-
}
18-
19-
export class InlineCriticalCssProcessor {
20-
constructor(protected readonly options: InlineCriticalCssProcessorOptions) {}
21-
22-
async process(html: string): Promise<{ content: string; warnings: string[]; errors: string[] }> {
23-
const warnings: string[] = [];
24-
const errors: string[] = [];
25-
const { outputPath, deployUrl, minify = false, readAsset } = this.options;
26-
27-
const { default: Beasties } = await import('beasties');
28-
29-
const beasties = new Beasties({
30-
logger: {
31-
warn: (s: string) => warnings.push(s),
32-
error: (s: string) => errors.push(s),
33-
info: () => {},
34-
},
35-
logLevel: 'warn',
36-
path: outputPath,
37-
publicPath: deployUrl,
38-
compress: minify,
39-
pruneSource: false,
40-
reduceInlineStyles: false,
41-
mergeStylesheets: false,
42-
preload: 'media-script',
43-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
44-
nonce: ((document: any) => {
45-
const nonceElement = document.querySelector('[ngCspNonce], [ngcspnonce]');
46-
const cspNonce =
47-
nonceElement?.getAttribute('ngCspNonce') || nonceElement?.getAttribute('ngcspnonce');
9+
/**
10+
* Generates a stylesheet containing the critical CSS for the given HTML content.
11+
*
12+
* @param html - The HTML content to process.
13+
* @param outputPath - The output path for the generated stylesheet.
14+
* @param deployUrl - The deploy URL for the generated stylesheet.
15+
* @param minify - Whether to minify the generated stylesheet.
16+
* @param readAsset - A function that reads an asset from the given file path.
17+
* @returns A promise that resolves to an object containing the generated stylesheet content,
18+
* warnings, and errors.
19+
*/
20+
export async function inlineCriticalCss(
21+
html: string,
22+
outputPath: string,
23+
deployUrl: string | undefined,
24+
minify: boolean,
25+
readAsset: (file: string) => Promise<string>,
26+
): Promise<{
27+
content: string;
28+
warnings: string[];
29+
errors: string[];
30+
}> {
31+
const { default: Beasties } = await import('beasties');
4832

49-
return cspNonce;
50-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
51-
}) as any,
52-
noscriptFallback: true,
53-
inlineFonts: true,
54-
});
33+
const warnings: string[] = [];
34+
const errors: string[] = [];
5535

56-
beasties.readFile = (path) => {
57-
return readAsset ? readAsset(path) : readFile(path, 'utf-8');
58-
};
36+
const beasties = new Beasties({
37+
logger: {
38+
warn: (s: string) => warnings.push(s),
39+
error: (s: string) => errors.push(s),
40+
info: () => {},
41+
},
42+
logLevel: 'warn',
43+
path: outputPath,
44+
publicPath: deployUrl,
45+
compress: minify,
46+
pruneSource: false,
47+
reduceInlineStyles: false,
48+
mergeStylesheets: false,
49+
preload: 'media-script',
50+
nonce: (document) => {
51+
const nonceElement = document.querySelector('[ngCspNonce], [ngcspnonce]');
52+
const cspNonce =
53+
nonceElement?.getAttribute('ngCspNonce') || nonceElement?.getAttribute('ngcspnonce');
5954

60-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
61-
const beastiesInternal = beasties as any;
62-
const initialEmbedLinkedStylesheet =
63-
beastiesInternal.embedLinkedStylesheet.bind(beastiesInternal);
64-
beastiesInternal.embedLinkedStylesheet = async (link: unknown, document: unknown) => {
65-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
66-
const linkEl = link as any;
67-
const beastiesMedia = linkEl.getAttribute('data-beasties-media');
68-
if (beastiesMedia) {
69-
linkEl.removeAttribute('data-beasties-media');
70-
linkEl.setAttribute('media', beastiesMedia);
71-
if (linkEl.next?.name === 'noscript') {
72-
linkEl.next.remove();
73-
}
74-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
75-
(document as any).querySelectorAll('script').forEach((script: any) => {
76-
if (script.textContent?.includes('data-beasties-media')) {
77-
script.remove();
78-
}
79-
});
80-
}
55+
return cspNonce;
56+
},
57+
noscriptFallback: true,
58+
inlineFonts: true,
59+
});
8160

82-
return initialEmbedLinkedStylesheet(link, document);
83-
};
61+
beasties.readFile = readAsset;
8462

85-
const content = await beasties.process(html);
63+
const content = await beasties.process(html);
8664

87-
return {
88-
// Clean up value from value less attributes.
89-
// This is caused because parse5 always requires attributes to have a string value.
90-
// nomodule="" defer="" -> nomodule defer.
91-
content: content.replace(/(\s(?:defer|nomodule))=""/g, '$1'),
92-
errors,
93-
warnings,
94-
};
95-
}
65+
return {
66+
// Clean up value from value less attributes.
67+
// This is caused because parse5 always requires attributes to have a string value.
68+
// nomodule="" defer="" -> nomodule defer.
69+
content: content.replace(/(\s(?:defer|nomodule))=""/g, '$1'),
70+
errors,
71+
warnings,
72+
};
9673
}

0 commit comments

Comments
 (0)