From 6752f131f2df71e889e96f761e861cd93577858c Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:13:20 -0400 Subject: [PATCH] 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. --- .../utils/index-file/inline-critical-css.ts | 283 ++++++++++-------- 1 file changed, 160 insertions(+), 123 deletions(-) diff --git a/packages/angular/build/src/utils/index-file/inline-critical-css.ts b/packages/angular/build/src/utils/index-file/inline-critical-css.ts index e5106f39cf6b..716d34bf037e 100644 --- a/packages/angular/build/src/utils/index-file/inline-critical-css.ts +++ b/packages/angular/build/src/utils/index-file/inline-critical-css.ts @@ -6,7 +6,6 @@ * found in the LICENSE file at https://angular.dev/license */ -import Beasties from 'beasties'; import { readFile } from 'node:fs/promises'; /** @@ -95,147 +94,184 @@ interface PartialDocument { querySelector(selector: string): PartialHTMLElement | null; } -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ - -// We use Typescript declaration merging because `embedLinkedStylesheet` it's not declared in -// the `Beasties` types which means that we can't call the `super` implementation. interface BeastiesBase { embedLinkedStylesheet(link: PartialHTMLElement, document: PartialDocument): Promise; + readFile(path: string): Promise; + process(html: string): Promise; } -class BeastiesBase extends Beasties {} -/* eslint-enable @typescript-eslint/no-unsafe-declaration-merging */ - -class BeastiesExtended extends BeastiesBase { - readonly warnings: string[] = []; - readonly errors: string[] = []; - private addedCspScriptsDocuments = new WeakSet(); - private documentNonces = new WeakMap(); - - constructor( - private readonly optionsExtended: InlineCriticalCssProcessorOptions & - InlineCriticalCssProcessOptions, - ) { - super({ - logger: { - warn: (s: string) => this.warnings.push(s), - error: (s: string) => this.errors.push(s), - info: () => {}, - }, - logLevel: 'warn', - path: optionsExtended.outputPath, - publicPath: optionsExtended.deployUrl, - compress: !!optionsExtended.minify, - pruneSource: false, - reduceInlineStyles: false, - mergeStylesheets: false, - // Note: if `preload` changes to anything other than `media`, the logic in - // `embedLinkedStylesheet` will have to be updated. - preload: 'media', - noscriptFallback: true, - inlineFonts: true, - }); - } - public override readFile(path: string): Promise { - const readAsset = this.optionsExtended.readAsset; +interface BeastiesExtendedInstance { + readonly warnings: string[]; + readonly errors: string[]; + process(html: string): Promise; +} - return readAsset ? readAsset(path) : readFile(path, 'utf-8'); +let beastiesClassPromise: + | Promise< + new ( + options: InlineCriticalCssProcessorOptions & InlineCriticalCssProcessOptions, + ) => BeastiesExtendedInstance + > + | undefined; + +async function getBeastiesClass(): Promise< + new ( + options: InlineCriticalCssProcessorOptions & InlineCriticalCssProcessOptions, + ) => BeastiesExtendedInstance +> { + if (beastiesClassPromise) { + return beastiesClassPromise; } - /** - * Override of the Beasties `embedLinkedStylesheet` method - * that makes it work with Angular's CSP APIs. - */ - override async embedLinkedStylesheet( - link: PartialHTMLElement, - document: PartialDocument, - ): Promise { - if (link.getAttribute('media') === 'print' && link.next?.name === 'noscript') { - // Workaround for https://github.com/GoogleChromeLabs/critters/issues/64 - // NB: this is only needed for the webpack based builders. - const media = link.getAttribute('onload')?.match(MEDIA_SET_HANDLER_PATTERN); - if (media) { - link.removeAttribute('onload'); - link.setAttribute('media', media[1]); - link?.next?.remove(); - } - } - - const returnValue = await super.embedLinkedStylesheet(link, document); - const cspNonce = this.findCspNonce(document); - - if (cspNonce || this.optionsExtended.autoCsp) { - const beastiesMedia = link.getAttribute('onload')?.match(MEDIA_SET_HANDLER_PATTERN); + beastiesClassPromise = import('beasties').then(({ default: Beasties }) => { + return class BeastiesExtended + extends (Beasties as unknown as new (options: unknown) => BeastiesBase) + implements BeastiesExtendedInstance + { + private _warnings?: string[]; + private _errors?: string[]; - if (beastiesMedia) { - // If there's a Beasties-generated `onload` handler and the file has an Angular CSP nonce, - // we have to remove the handler, because it's incompatible with CSP. We save the value - // in a different attribute and we generate a script tag with the nonce that uses - // `addEventListener` to apply the media query instead. - link.removeAttribute('onload'); - link.setAttribute(CSP_MEDIA_ATTR, beastiesMedia[1]); - this.conditionallyInsertCspLoadingScript(document, cspNonce, link); + get warnings(): string[] { + return (this._warnings ??= []); } - // Ideally we would hook in at the time Beasties inserts the `style` tags, but there isn't - // a way of doing that at the moment so we fall back to doing it any time a `link` tag is - // inserted. We mitigate it by only iterating the direct children of the `` which - // should be pretty shallow. - if (cspNonce) { - document.head.children.forEach((child) => { - if (child.tagName === 'style' && !child.hasAttribute('nonce')) { - child.setAttribute('nonce', cspNonce); - } + get errors(): string[] { + return (this._errors ??= []); + } + private addedCspScriptsDocuments = new WeakSet(); + private documentNonces = new WeakMap(); + + constructor( + private readonly optionsExtended: InlineCriticalCssProcessorOptions & + InlineCriticalCssProcessOptions, + ) { + super({ + logger: { + warn: (s: string) => this.warnings.push(s), + error: (s: string) => this.errors.push(s), + info: () => {}, + }, + logLevel: 'warn', + path: optionsExtended.outputPath, + publicPath: optionsExtended.deployUrl, + compress: !!optionsExtended.minify, + pruneSource: false, + reduceInlineStyles: false, + mergeStylesheets: false, + // Note: if `preload` changes to anything other than `media`, the logic in + // `embedLinkedStylesheet` will have to be updated. + preload: 'media', + noscriptFallback: true, + inlineFonts: true, }); } - } - return returnValue; - } + public override readFile(path: string): Promise { + const readAsset = this.optionsExtended.readAsset; - /** - * Finds the CSP nonce for a specific document. - */ - private findCspNonce(document: PartialDocument): string | null { - if (this.documentNonces.has(document)) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return this.documentNonces.get(document)!; - } + return readAsset ? readAsset(path) : readFile(path, 'utf-8'); + } - // HTML attribute are case-insensitive, but the parser used by Beasties is case-sensitive. - const nonceElement = document.querySelector('[ngCspNonce], [ngcspnonce]'); - const cspNonce = - nonceElement?.getAttribute('ngCspNonce') || nonceElement?.getAttribute('ngcspnonce') || null; + /** + * Override of the Beasties `embedLinkedStylesheet` method + * that makes it work with Angular's CSP APIs. + */ + override async embedLinkedStylesheet( + link: PartialHTMLElement, + document: PartialDocument, + ): Promise { + if (link.getAttribute('media') === 'print' && link.next?.name === 'noscript') { + // Workaround for https://github.com/GoogleChromeLabs/critters/issues/64 + // NB: this is only needed for the webpack based builders. + const media = link.getAttribute('onload')?.match(MEDIA_SET_HANDLER_PATTERN); + if (media) { + link.removeAttribute('onload'); + link.setAttribute('media', media[1]); + link?.next?.remove(); + } + } + + const returnValue = await super.embedLinkedStylesheet(link, document); + const cspNonce = this.findCspNonce(document); + + if (cspNonce || this.optionsExtended.autoCsp) { + const beastiesMedia = link.getAttribute('onload')?.match(MEDIA_SET_HANDLER_PATTERN); + + if (beastiesMedia) { + // If there's a Beasties-generated `onload` handler and the file has an Angular CSP nonce, + // we have to remove the handler, because it's incompatible with CSP. We save the value + // in a different attribute and we generate a script tag with the nonce that uses + // `addEventListener` to apply the media query instead. + link.removeAttribute('onload'); + link.setAttribute(CSP_MEDIA_ATTR, beastiesMedia[1]); + this.conditionallyInsertCspLoadingScript(document, cspNonce, link); + } - this.documentNonces.set(document, cspNonce); + // Ideally we would hook in at the time Beasties inserts the `style` tags, but there isn't + // a way of doing that at the moment so we fall back to doing it any time a `link` tag is + // inserted. We mitigate it by only iterating the direct children of the `` which + // should be pretty shallow. + if (cspNonce) { + document.head.children.forEach((child) => { + if (child.tagName === 'style' && !child.hasAttribute('nonce')) { + child.setAttribute('nonce', cspNonce); + } + }); + } + } - return cspNonce; - } + return returnValue; + } - /** - * Inserts the `script` tag that swaps the critical CSS at runtime, - * if one hasn't been inserted into the document already. - */ - private conditionallyInsertCspLoadingScript( - document: PartialDocument, - nonce: string | null, - link: PartialHTMLElement, - ): void { - if (this.addedCspScriptsDocuments.has(document)) { - return; - } + /** + * Finds the CSP nonce for a specific document. + */ + private findCspNonce(document: PartialDocument): string | null { + if (this.documentNonces.has(document)) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.documentNonces.get(document)!; + } + + // HTML attribute are case-insensitive, but the parser used by Beasties is case-sensitive. + const nonceElement = document.querySelector('[ngCspNonce], [ngcspnonce]'); + const cspNonce = + nonceElement?.getAttribute('ngCspNonce') || + nonceElement?.getAttribute('ngcspnonce') || + null; + + this.documentNonces.set(document, cspNonce); + + return cspNonce; + } - const script = document.createElement('script'); - script.textContent = LINK_LOAD_SCRIPT_CONTENT; - if (nonce) { - script.setAttribute('nonce', nonce); - } + /** + * Inserts the `script` tag that swaps the critical CSS at runtime, + * if one hasn't been inserted into the document already. + */ + private conditionallyInsertCspLoadingScript( + document: PartialDocument, + nonce: string | null, + link: PartialHTMLElement, + ): void { + if (this.addedCspScriptsDocuments.has(document)) { + return; + } + + const script = document.createElement('script'); + script.textContent = LINK_LOAD_SCRIPT_CONTENT; + if (nonce) { + script.setAttribute('nonce', nonce); + } + + // Prepend the script to the head since it needs to + // run as early as possible, before the `link` tags. + document.head.insertBefore(script, link); + this.addedCspScriptsDocuments.add(document); + } + }; + }); - // Prepend the script to the head since it needs to - // run as early as possible, before the `link` tags. - document.head.insertBefore(script, link); - this.addedCspScriptsDocuments.add(document); - } + return beastiesClassPromise; } export class InlineCriticalCssProcessor { @@ -245,7 +281,8 @@ export class InlineCriticalCssProcessor { html: string, options: InlineCriticalCssProcessOptions, ): Promise<{ content: string; warnings: string[]; errors: string[] }> { - const beasties = new BeastiesExtended({ ...this.options, ...options }); + const BeastiesClass = await getBeastiesClass(); + const beasties = new BeastiesClass({ ...this.options, ...options }); const content = await beasties.process(html); return {