Skip to content

Commit 2361ada

Browse files
committed
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%.
1 parent 6b20983 commit 2361ada

2 files changed

Lines changed: 37 additions & 14 deletions

File tree

packages/angular/build/src/tools/esbuild/stylesheets/sass-language.ts

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,16 @@ import { StylesheetLanguage, StylesheetPluginOptions } from './stylesheet-plugin
1616

1717
let sassService: SassCompiler | undefined;
1818
let sassServicePromise: Promise<SassCompiler> | undefined;
19+
let resolutionCache: MemoryCache<URL | null> | undefined;
20+
let packageRootCache: MemoryCache<string | null> | undefined;
1921

2022
function isSassException(error: unknown): error is Exception {
2123
return !!error && typeof error === 'object' && 'sassMessage' in error;
2224
}
2325

2426
export function shutdownSassWorkerPool(): void {
27+
resolutionCache = undefined;
28+
packageRootCache = undefined;
2529
if (sassService) {
2630
void sassService.close();
2731
sassService = undefined;
@@ -91,14 +95,15 @@ async function compileString(
9195
}
9296
}
9397

94-
// Cache is currently local to individual compile requests.
95-
// Caching follows Sass behavior where a given url will always resolve to the same value
96-
// regardless of its importer's path.
98+
// Caching follows Sass behavior where a given package url will always resolve to the same value
99+
// regardless of its importer's path. Relative paths are qualified with the containing URL.
97100
// A null value indicates that the cached resolution attempt failed to find a location and
98101
// later stage resolution should be attempted. This avoids potentially expensive repeat
99102
// failing resolution attempts.
100-
const resolutionCache = new MemoryCache<URL | null>();
101-
const packageRootCache = new MemoryCache<string | null>();
103+
resolutionCache ??= new MemoryCache<URL | null>();
104+
packageRootCache ??= new MemoryCache<string | null>();
105+
const currentResolutionCache = resolutionCache;
106+
const currentPackageRootCache = packageRootCache;
102107
const warnings: PartialMessage[] = [];
103108
const { silenceDeprecations, futureDeprecations, fatalDeprecations } = options.sass ?? {};
104109

@@ -116,8 +121,12 @@ async function compileString(
116121
quietDeps: true,
117122
importers: [
118123
{
119-
findFileUrl: (url, options) =>
120-
resolutionCache.getOrCreate(url, async () => {
124+
findFileUrl: (url, options) => {
125+
const cacheKey = url.startsWith('pkg:')
126+
? url
127+
: `${options.containingUrl?.href ?? ''}:${url}`;
128+
129+
return currentResolutionCache.getOrCreate(cacheKey, async () => {
121130
const result = await resolveUrl(url, options);
122131
if (result.path) {
123132
return pathToFileURL(result.path);
@@ -128,12 +137,15 @@ async function compileString(
128137

129138
// Caching package root locations is particularly beneficial for `@material/*` packages
130139
// which extensively use deep imports.
131-
const packageRoot = await packageRootCache.getOrCreate(packageName, async () => {
132-
// Use the required presence of a package root `package.json` file to resolve the location
133-
const packageResult = await resolveUrl(packageName + '/package.json', options);
140+
const packageRoot = await currentPackageRootCache.getOrCreate(
141+
packageName,
142+
async () => {
143+
// Use the required presence of a package root `package.json` file to resolve the location
144+
const packageResult = await resolveUrl(packageName + '/package.json', options);
134145

135-
return packageResult.path ? dirname(packageResult.path) : null;
136-
});
146+
return packageResult.path ? dirname(packageResult.path) : null;
147+
},
148+
);
137149

138150
// Package not found could be because of an error or the specifier is intended to be found
139151
// via a later stage of the resolution process (`loadPaths`, etc.).
@@ -145,7 +157,8 @@ async function compileString(
145157

146158
// Not found
147159
return null;
148-
}),
160+
});
161+
},
149162
},
150163
],
151164
logger: {

packages/angular/build/src/tools/sass/sass-service.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ function isFileImporter(value: Importers): value is FileImporter {
4545
export class SassCompiler {
4646
#asyncCompiler: AsyncCompiler | undefined;
4747
#asyncCompilerPromise: Promise<AsyncCompiler> | undefined;
48+
readonly #directoryCache = new Map<string, DirectoryEntry>();
4849

4950
constructor(private readonly rebase = false) {}
5051

@@ -119,7 +120,7 @@ export class SassCompiler {
119120
(Importer<'async'> | FileImporter<'async'> | NodePackageImporter)[] | undefined;
120121
let loadPaths = options.loadPaths;
121122
const entryDirectory = url ? dirname(fileURLToPath(url)) : process.cwd();
122-
const directoryCache = new Map<string, DirectoryEntry>();
123+
const directoryCache = this.#directoryCache;
123124
const rebaseSourceMaps = options.sourceMap ? new Map<string, DecodedSourceMap>() : undefined;
124125

125126
if (importers?.length) {
@@ -187,11 +188,20 @@ export class SassCompiler {
187188
return result;
188189
}
189190

191+
/**
192+
* Clear the directory cache.
193+
*/
194+
clearCache(): void {
195+
this.#directoryCache.clear();
196+
}
197+
190198
/**
191199
* Shutdown the Sass compiler.
192200
* @returns A void promise that resolves when closing is complete.
193201
*/
194202
async close(): Promise<void> {
203+
this.clearCache();
204+
195205
if (this.#asyncCompilerPromise) {
196206
try {
197207
await this.#ensureAsyncCompiler();

0 commit comments

Comments
 (0)