Skip to content

Commit 6752f13

Browse files
committed
refactor(@angular/build): lazy load beasties in critical css processor
Beasties and its transitive dependencies (postcss, css-select, htmlparser2, domutils, dom-serializer, and postcss parsers) were previously imported statically at the top level of the critical CSS utility. This change converts beasties to a dynamic import that is resolved on first use within the process method of InlineCriticalCssProcessor. Deferring the loading of beasties eliminates synchronous module statting, file reading, and V8 bytecode parsing overhead from the initial CLI setup phase.
1 parent 71eca4c commit 6752f13

1 file changed

Lines changed: 160 additions & 123 deletions

File tree

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

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

9-
import Beasties from 'beasties';
109
import { readFile } from 'node:fs/promises';
1110

1211
/**
@@ -95,147 +94,184 @@ interface PartialDocument {
9594
querySelector(selector: string): PartialHTMLElement | null;
9695
}
9796

98-
/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */
99-
100-
// We use Typescript declaration merging because `embedLinkedStylesheet` it's not declared in
101-
// the `Beasties` types which means that we can't call the `super` implementation.
10297
interface BeastiesBase {
10398
embedLinkedStylesheet(link: PartialHTMLElement, document: PartialDocument): Promise<unknown>;
99+
readFile(path: string): Promise<string>;
100+
process(html: string): Promise<string>;
104101
}
105-
class BeastiesBase extends Beasties {}
106-
/* eslint-enable @typescript-eslint/no-unsafe-declaration-merging */
107-
108-
class BeastiesExtended extends BeastiesBase {
109-
readonly warnings: string[] = [];
110-
readonly errors: string[] = [];
111-
private addedCspScriptsDocuments = new WeakSet<PartialDocument>();
112-
private documentNonces = new WeakMap<PartialDocument, string | null>();
113-
114-
constructor(
115-
private readonly optionsExtended: InlineCriticalCssProcessorOptions &
116-
InlineCriticalCssProcessOptions,
117-
) {
118-
super({
119-
logger: {
120-
warn: (s: string) => this.warnings.push(s),
121-
error: (s: string) => this.errors.push(s),
122-
info: () => {},
123-
},
124-
logLevel: 'warn',
125-
path: optionsExtended.outputPath,
126-
publicPath: optionsExtended.deployUrl,
127-
compress: !!optionsExtended.minify,
128-
pruneSource: false,
129-
reduceInlineStyles: false,
130-
mergeStylesheets: false,
131-
// Note: if `preload` changes to anything other than `media`, the logic in
132-
// `embedLinkedStylesheet` will have to be updated.
133-
preload: 'media',
134-
noscriptFallback: true,
135-
inlineFonts: true,
136-
});
137-
}
138102

139-
public override readFile(path: string): Promise<string> {
140-
const readAsset = this.optionsExtended.readAsset;
103+
interface BeastiesExtendedInstance {
104+
readonly warnings: string[];
105+
readonly errors: string[];
106+
process(html: string): Promise<string>;
107+
}
141108

142-
return readAsset ? readAsset(path) : readFile(path, 'utf-8');
109+
let beastiesClassPromise:
110+
| Promise<
111+
new (
112+
options: InlineCriticalCssProcessorOptions & InlineCriticalCssProcessOptions,
113+
) => BeastiesExtendedInstance
114+
>
115+
| undefined;
116+
117+
async function getBeastiesClass(): Promise<
118+
new (
119+
options: InlineCriticalCssProcessorOptions & InlineCriticalCssProcessOptions,
120+
) => BeastiesExtendedInstance
121+
> {
122+
if (beastiesClassPromise) {
123+
return beastiesClassPromise;
143124
}
144125

145-
/**
146-
* Override of the Beasties `embedLinkedStylesheet` method
147-
* that makes it work with Angular's CSP APIs.
148-
*/
149-
override async embedLinkedStylesheet(
150-
link: PartialHTMLElement,
151-
document: PartialDocument,
152-
): Promise<unknown> {
153-
if (link.getAttribute('media') === 'print' && link.next?.name === 'noscript') {
154-
// Workaround for https://github.com/GoogleChromeLabs/critters/issues/64
155-
// NB: this is only needed for the webpack based builders.
156-
const media = link.getAttribute('onload')?.match(MEDIA_SET_HANDLER_PATTERN);
157-
if (media) {
158-
link.removeAttribute('onload');
159-
link.setAttribute('media', media[1]);
160-
link?.next?.remove();
161-
}
162-
}
163-
164-
const returnValue = await super.embedLinkedStylesheet(link, document);
165-
const cspNonce = this.findCspNonce(document);
166-
167-
if (cspNonce || this.optionsExtended.autoCsp) {
168-
const beastiesMedia = link.getAttribute('onload')?.match(MEDIA_SET_HANDLER_PATTERN);
126+
beastiesClassPromise = import('beasties').then(({ default: Beasties }) => {
127+
return class BeastiesExtended
128+
extends (Beasties as unknown as new (options: unknown) => BeastiesBase)
129+
implements BeastiesExtendedInstance
130+
{
131+
private _warnings?: string[];
132+
private _errors?: string[];
169133

170-
if (beastiesMedia) {
171-
// If there's a Beasties-generated `onload` handler and the file has an Angular CSP nonce,
172-
// we have to remove the handler, because it's incompatible with CSP. We save the value
173-
// in a different attribute and we generate a script tag with the nonce that uses
174-
// `addEventListener` to apply the media query instead.
175-
link.removeAttribute('onload');
176-
link.setAttribute(CSP_MEDIA_ATTR, beastiesMedia[1]);
177-
this.conditionallyInsertCspLoadingScript(document, cspNonce, link);
134+
get warnings(): string[] {
135+
return (this._warnings ??= []);
178136
}
179137

180-
// Ideally we would hook in at the time Beasties inserts the `style` tags, but there isn't
181-
// a way of doing that at the moment so we fall back to doing it any time a `link` tag is
182-
// inserted. We mitigate it by only iterating the direct children of the `<head>` which
183-
// should be pretty shallow.
184-
if (cspNonce) {
185-
document.head.children.forEach((child) => {
186-
if (child.tagName === 'style' && !child.hasAttribute('nonce')) {
187-
child.setAttribute('nonce', cspNonce);
188-
}
138+
get errors(): string[] {
139+
return (this._errors ??= []);
140+
}
141+
private addedCspScriptsDocuments = new WeakSet<PartialDocument>();
142+
private documentNonces = new WeakMap<PartialDocument, string | null>();
143+
144+
constructor(
145+
private readonly optionsExtended: InlineCriticalCssProcessorOptions &
146+
InlineCriticalCssProcessOptions,
147+
) {
148+
super({
149+
logger: {
150+
warn: (s: string) => this.warnings.push(s),
151+
error: (s: string) => this.errors.push(s),
152+
info: () => {},
153+
},
154+
logLevel: 'warn',
155+
path: optionsExtended.outputPath,
156+
publicPath: optionsExtended.deployUrl,
157+
compress: !!optionsExtended.minify,
158+
pruneSource: false,
159+
reduceInlineStyles: false,
160+
mergeStylesheets: false,
161+
// Note: if `preload` changes to anything other than `media`, the logic in
162+
// `embedLinkedStylesheet` will have to be updated.
163+
preload: 'media',
164+
noscriptFallback: true,
165+
inlineFonts: true,
189166
});
190167
}
191-
}
192168

193-
return returnValue;
194-
}
169+
public override readFile(path: string): Promise<string> {
170+
const readAsset = this.optionsExtended.readAsset;
195171

196-
/**
197-
* Finds the CSP nonce for a specific document.
198-
*/
199-
private findCspNonce(document: PartialDocument): string | null {
200-
if (this.documentNonces.has(document)) {
201-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
202-
return this.documentNonces.get(document)!;
203-
}
172+
return readAsset ? readAsset(path) : readFile(path, 'utf-8');
173+
}
204174

205-
// HTML attribute are case-insensitive, but the parser used by Beasties is case-sensitive.
206-
const nonceElement = document.querySelector('[ngCspNonce], [ngcspnonce]');
207-
const cspNonce =
208-
nonceElement?.getAttribute('ngCspNonce') || nonceElement?.getAttribute('ngcspnonce') || null;
175+
/**
176+
* Override of the Beasties `embedLinkedStylesheet` method
177+
* that makes it work with Angular's CSP APIs.
178+
*/
179+
override async embedLinkedStylesheet(
180+
link: PartialHTMLElement,
181+
document: PartialDocument,
182+
): Promise<unknown> {
183+
if (link.getAttribute('media') === 'print' && link.next?.name === 'noscript') {
184+
// Workaround for https://github.com/GoogleChromeLabs/critters/issues/64
185+
// NB: this is only needed for the webpack based builders.
186+
const media = link.getAttribute('onload')?.match(MEDIA_SET_HANDLER_PATTERN);
187+
if (media) {
188+
link.removeAttribute('onload');
189+
link.setAttribute('media', media[1]);
190+
link?.next?.remove();
191+
}
192+
}
193+
194+
const returnValue = await super.embedLinkedStylesheet(link, document);
195+
const cspNonce = this.findCspNonce(document);
196+
197+
if (cspNonce || this.optionsExtended.autoCsp) {
198+
const beastiesMedia = link.getAttribute('onload')?.match(MEDIA_SET_HANDLER_PATTERN);
199+
200+
if (beastiesMedia) {
201+
// If there's a Beasties-generated `onload` handler and the file has an Angular CSP nonce,
202+
// we have to remove the handler, because it's incompatible with CSP. We save the value
203+
// in a different attribute and we generate a script tag with the nonce that uses
204+
// `addEventListener` to apply the media query instead.
205+
link.removeAttribute('onload');
206+
link.setAttribute(CSP_MEDIA_ATTR, beastiesMedia[1]);
207+
this.conditionallyInsertCspLoadingScript(document, cspNonce, link);
208+
}
209209

210-
this.documentNonces.set(document, cspNonce);
210+
// Ideally we would hook in at the time Beasties inserts the `style` tags, but there isn't
211+
// a way of doing that at the moment so we fall back to doing it any time a `link` tag is
212+
// inserted. We mitigate it by only iterating the direct children of the `<head>` which
213+
// should be pretty shallow.
214+
if (cspNonce) {
215+
document.head.children.forEach((child) => {
216+
if (child.tagName === 'style' && !child.hasAttribute('nonce')) {
217+
child.setAttribute('nonce', cspNonce);
218+
}
219+
});
220+
}
221+
}
211222

212-
return cspNonce;
213-
}
223+
return returnValue;
224+
}
214225

215-
/**
216-
* Inserts the `script` tag that swaps the critical CSS at runtime,
217-
* if one hasn't been inserted into the document already.
218-
*/
219-
private conditionallyInsertCspLoadingScript(
220-
document: PartialDocument,
221-
nonce: string | null,
222-
link: PartialHTMLElement,
223-
): void {
224-
if (this.addedCspScriptsDocuments.has(document)) {
225-
return;
226-
}
226+
/**
227+
* Finds the CSP nonce for a specific document.
228+
*/
229+
private findCspNonce(document: PartialDocument): string | null {
230+
if (this.documentNonces.has(document)) {
231+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
232+
return this.documentNonces.get(document)!;
233+
}
234+
235+
// HTML attribute are case-insensitive, but the parser used by Beasties is case-sensitive.
236+
const nonceElement = document.querySelector('[ngCspNonce], [ngcspnonce]');
237+
const cspNonce =
238+
nonceElement?.getAttribute('ngCspNonce') ||
239+
nonceElement?.getAttribute('ngcspnonce') ||
240+
null;
241+
242+
this.documentNonces.set(document, cspNonce);
243+
244+
return cspNonce;
245+
}
227246

228-
const script = document.createElement('script');
229-
script.textContent = LINK_LOAD_SCRIPT_CONTENT;
230-
if (nonce) {
231-
script.setAttribute('nonce', nonce);
232-
}
247+
/**
248+
* Inserts the `script` tag that swaps the critical CSS at runtime,
249+
* if one hasn't been inserted into the document already.
250+
*/
251+
private conditionallyInsertCspLoadingScript(
252+
document: PartialDocument,
253+
nonce: string | null,
254+
link: PartialHTMLElement,
255+
): void {
256+
if (this.addedCspScriptsDocuments.has(document)) {
257+
return;
258+
}
259+
260+
const script = document.createElement('script');
261+
script.textContent = LINK_LOAD_SCRIPT_CONTENT;
262+
if (nonce) {
263+
script.setAttribute('nonce', nonce);
264+
}
265+
266+
// Prepend the script to the head since it needs to
267+
// run as early as possible, before the `link` tags.
268+
document.head.insertBefore(script, link);
269+
this.addedCspScriptsDocuments.add(document);
270+
}
271+
};
272+
});
233273

234-
// Prepend the script to the head since it needs to
235-
// run as early as possible, before the `link` tags.
236-
document.head.insertBefore(script, link);
237-
this.addedCspScriptsDocuments.add(document);
238-
}
274+
return beastiesClassPromise;
239275
}
240276

241277
export class InlineCriticalCssProcessor {
@@ -245,7 +281,8 @@ export class InlineCriticalCssProcessor {
245281
html: string,
246282
options: InlineCriticalCssProcessOptions,
247283
): Promise<{ content: string; warnings: string[]; errors: string[] }> {
248-
const beasties = new BeastiesExtended({ ...this.options, ...options });
284+
const BeastiesClass = await getBeastiesClass();
285+
const beasties = new BeastiesClass({ ...this.options, ...options });
249286
const content = await beasties.process(html);
250287

251288
return {

0 commit comments

Comments
 (0)