From 2361ada9a37ed5fdcc826a4e6b8b6921fd33e4b2 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:13:52 +0000 Subject: [PATCH] perf(@angular/build): share sass directory and resolution caches across stylesheets Previously, Sass resolution caches (resolutionCache and packageRootCache) in sass-language.ts and the filesystem directory entry cache (directoryCache) in sass-service.ts were created anew for every individual stylesheet compilation request. When compiling applications that use component styles importing shared design tokens or library stylesheets (such as @angular/material or deep-imported @material/* packages), rebasing importers repeatedly invoked synchronous fs.readdirSync across the same node module package directories, and re-executed esbuild resolution calls. To eliminate redundant disk I/O and resolution overhead: - Hoist directoryCache to an instance property of SassCompiler, persisting directory listings across compile calls and clearing them upon compiler shutdown in close(). - Hoist resolutionCache and packageRootCache to module-scoped caches in sass-language.ts, clearing them during shutdownSassWorkerPool(). - Contextualize relative import resolution cache keys using the containing URL to preserve correctness while allowing package specifiers to resolve once across all stylesheets. In benchmarks on an application with 50 component SCSS stylesheets importing @angular/material, synchronous fs.readdirSync calls dropped from 2,900 to 205 (-92.9%), build.resolve calls dropped from 100 to 2 (-98.0%), and compilation time improved by up to 26.4%. --- .../esbuild/stylesheets/sass-language.ts | 39 ++++++++++++------- .../build/src/tools/sass/sass-service.ts | 12 +++++- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/sass-language.ts b/packages/angular/build/src/tools/esbuild/stylesheets/sass-language.ts index ad2961cc2b2e..537b3004c4b3 100644 --- a/packages/angular/build/src/tools/esbuild/stylesheets/sass-language.ts +++ b/packages/angular/build/src/tools/esbuild/stylesheets/sass-language.ts @@ -16,12 +16,16 @@ import { StylesheetLanguage, StylesheetPluginOptions } from './stylesheet-plugin let sassService: SassCompiler | undefined; let sassServicePromise: Promise | undefined; +let resolutionCache: MemoryCache | undefined; +let packageRootCache: MemoryCache | undefined; function isSassException(error: unknown): error is Exception { return !!error && typeof error === 'object' && 'sassMessage' in error; } export function shutdownSassWorkerPool(): void { + resolutionCache = undefined; + packageRootCache = undefined; if (sassService) { void sassService.close(); sassService = undefined; @@ -91,14 +95,15 @@ async function compileString( } } - // Cache is currently local to individual compile requests. - // Caching follows Sass behavior where a given url will always resolve to the same value - // regardless of its importer's path. + // Caching follows Sass behavior where a given package url will always resolve to the same value + // regardless of its importer's path. Relative paths are qualified with the containing URL. // A null value indicates that the cached resolution attempt failed to find a location and // later stage resolution should be attempted. This avoids potentially expensive repeat // failing resolution attempts. - const resolutionCache = new MemoryCache(); - const packageRootCache = new MemoryCache(); + resolutionCache ??= new MemoryCache(); + packageRootCache ??= new MemoryCache(); + const currentResolutionCache = resolutionCache; + const currentPackageRootCache = packageRootCache; const warnings: PartialMessage[] = []; const { silenceDeprecations, futureDeprecations, fatalDeprecations } = options.sass ?? {}; @@ -116,8 +121,12 @@ async function compileString( quietDeps: true, importers: [ { - findFileUrl: (url, options) => - resolutionCache.getOrCreate(url, async () => { + findFileUrl: (url, options) => { + const cacheKey = url.startsWith('pkg:') + ? url + : `${options.containingUrl?.href ?? ''}:${url}`; + + return currentResolutionCache.getOrCreate(cacheKey, async () => { const result = await resolveUrl(url, options); if (result.path) { return pathToFileURL(result.path); @@ -128,12 +137,15 @@ async function compileString( // Caching package root locations is particularly beneficial for `@material/*` packages // which extensively use deep imports. - const packageRoot = await packageRootCache.getOrCreate(packageName, async () => { - // Use the required presence of a package root `package.json` file to resolve the location - const packageResult = await resolveUrl(packageName + '/package.json', options); + const packageRoot = await currentPackageRootCache.getOrCreate( + packageName, + async () => { + // Use the required presence of a package root `package.json` file to resolve the location + const packageResult = await resolveUrl(packageName + '/package.json', options); - return packageResult.path ? dirname(packageResult.path) : null; - }); + return packageResult.path ? dirname(packageResult.path) : null; + }, + ); // Package not found could be because of an error or the specifier is intended to be found // via a later stage of the resolution process (`loadPaths`, etc.). @@ -145,7 +157,8 @@ async function compileString( // Not found return null; - }), + }); + }, }, ], logger: { diff --git a/packages/angular/build/src/tools/sass/sass-service.ts b/packages/angular/build/src/tools/sass/sass-service.ts index c3d6cf991526..8ce65dd7e2f2 100644 --- a/packages/angular/build/src/tools/sass/sass-service.ts +++ b/packages/angular/build/src/tools/sass/sass-service.ts @@ -45,6 +45,7 @@ function isFileImporter(value: Importers): value is FileImporter { export class SassCompiler { #asyncCompiler: AsyncCompiler | undefined; #asyncCompilerPromise: Promise | undefined; + readonly #directoryCache = new Map(); constructor(private readonly rebase = false) {} @@ -119,7 +120,7 @@ export class SassCompiler { (Importer<'async'> | FileImporter<'async'> | NodePackageImporter)[] | undefined; let loadPaths = options.loadPaths; const entryDirectory = url ? dirname(fileURLToPath(url)) : process.cwd(); - const directoryCache = new Map(); + const directoryCache = this.#directoryCache; const rebaseSourceMaps = options.sourceMap ? new Map() : undefined; if (importers?.length) { @@ -187,11 +188,20 @@ export class SassCompiler { return result; } + /** + * Clear the directory cache. + */ + clearCache(): void { + this.#directoryCache.clear(); + } + /** * Shutdown the Sass compiler. * @returns A void promise that resolves when closing is complete. */ async close(): Promise { + this.clearCache(); + if (this.#asyncCompilerPromise) { try { await this.#ensureAsyncCompiler();