Skip to content

Commit c852690

Browse files
committed
fix(@angular/build): scope Sass package resolution caching to visible node_modules
Package specifiers were cached without any qualification, so the resolution made for one stylesheet was reused for every other stylesheet in the build. A stylesheet with a nested `node_modules` directory provided its resolution to stylesheets that cannot see it, a stylesheet without one provided its resolution to those that can, and a failed resolution was reused for a stylesheet that is able to resolve the package. Which of these occurred depended on the order the stylesheets were compiled in. Package resolutions and package roots are now keyed by the `node_modules` directories visible from the importing stylesheet. Stylesheets that search the same directories still share a single resolution, while stylesheets that search different directories no longer share one.
1 parent 8c43889 commit c852690

2 files changed

Lines changed: 180 additions & 15 deletions

File tree

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

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@
77
*/
88

99
import type { OnLoadResult, PartialMessage, PartialNote, ResolveResult } from 'esbuild';
10+
import { existsSync } from 'node:fs';
1011
import { dirname, join } from 'node:path';
1112
import { fileURLToPath, pathToFileURL } from 'node:url';
12-
import type { CanonicalizeContext, CompileResult, Exception, Syntax } from 'sass-embedded';
13+
import type { CompileResult, Exception, Syntax } from 'sass-embedded';
1314
import type { SassCompiler } from '../../sass/sass-service';
1415
import { MemoryCache } from '../cache';
1516
import { StylesheetLanguage, StylesheetPluginOptions } from './stylesheet-plugin-factory';
@@ -18,6 +19,7 @@ let sassService: SassCompiler | undefined;
1819
let sassServicePromise: Promise<SassCompiler> | undefined;
1920
let resolutionCache: MemoryCache<URL | null> | undefined;
2021
let packageRootCache: MemoryCache<string | null> | undefined;
22+
let nodeModulesChainCache: Map<string, string> | undefined;
2123

2224
function isSassException(error: unknown): error is Exception {
2325
return !!error && typeof error === 'object' && 'sassMessage' in error;
@@ -26,6 +28,7 @@ function isSassException(error: unknown): error is Exception {
2628
export function resetSassWorkerPoolCaches(): void {
2729
resolutionCache?.clear();
2830
packageRootCache?.clear();
31+
nodeModulesChainCache?.clear();
2932
if (sassService) {
3033
sassService.clearCache();
3134
} else if (sassServicePromise) {
@@ -50,12 +53,7 @@ export const SassStylesheetLanguage = Object.freeze<StylesheetLanguage>({
5053
fileFilter: /\.s[ac]ss$/,
5154
process(data, file, format, options, build) {
5255
const syntax = format === 'sass' ? 'indented' : 'scss';
53-
const resolveUrl = async (url: string, options: CanonicalizeContext) => {
54-
let resolveDir = build.initialOptions.absWorkingDir;
55-
if (options.containingUrl) {
56-
resolveDir = dirname(fileURLToPath(options.containingUrl));
57-
}
58-
56+
const resolveUrl = async (url: string, resolveDir: string | undefined) => {
5957
const path = url.startsWith('pkg:') ? url.slice(4) : url;
6058
const result = await build.resolve(path, {
6159
kind: 'import-rule',
@@ -65,7 +63,14 @@ export const SassStylesheetLanguage = Object.freeze<StylesheetLanguage>({
6563
return result;
6664
};
6765

68-
return compileString(data, file, syntax, options, resolveUrl);
66+
return compileString(
67+
data,
68+
file,
69+
syntax,
70+
options,
71+
resolveUrl,
72+
build.initialOptions.absWorkingDir,
73+
);
6974
},
7075
});
7176

@@ -97,12 +102,39 @@ function parsePackageName(url: string): { packageName: string; readonly pathSegm
97102
};
98103
}
99104

105+
/**
106+
* Builds a key describing the `node_modules` directories visible from a directory, in the order
107+
* that package resolution searches them. Two directories with an identical key resolve every
108+
* package specifier to the same location; two with differing keys may not.
109+
*
110+
* The result is cached for the directory and for each of its parents.
111+
*
112+
* @param directory An absolute path of the directory to analyze.
113+
* @returns A key representing the `node_modules` directories visible from the directory.
114+
*/
115+
function nodeModulesChainKey(directory: string): string {
116+
nodeModulesChainCache ??= new Map<string, string>();
117+
const cached = nodeModulesChainCache.get(directory);
118+
if (cached !== undefined) {
119+
return cached;
120+
}
121+
122+
const parent = dirname(directory);
123+
const parentKey = parent === directory ? '' : nodeModulesChainKey(parent);
124+
const candidate = join(directory, 'node_modules');
125+
const key = existsSync(candidate) ? `${candidate}\n${parentKey}` : parentKey;
126+
nodeModulesChainCache.set(directory, key);
127+
128+
return key;
129+
}
130+
100131
async function compileString(
101132
data: string,
102133
filePath: string,
103134
syntax: Syntax,
104135
options: StylesheetPluginOptions,
105-
resolveUrl: (url: string, options: CanonicalizeContext) => Promise<ResolveResult>,
136+
resolveUrl: (url: string, resolveDir: string | undefined) => Promise<ResolveResult>,
137+
workingDirectory: string | undefined,
106138
): Promise<OnLoadResult> {
107139
// Lazily load Sass when a Sass file is found
108140
if (sassService === undefined) {
@@ -119,7 +151,9 @@ async function compileString(
119151
}
120152

121153
// Caching follows Sass behavior where a given package url will always resolve to the same value
122-
// regardless of its importer's path. Relative paths are qualified with the containing URL.
154+
// for importers that search the same `node_modules` directories. Package urls are therefore
155+
// qualified with the visible `node_modules` directories of the importer instead of its path,
156+
// while relative paths are qualified with the containing URL.
123157
// A null value indicates that the cached resolution attempt failed to find a location and
124158
// later stage resolution should be attempted. This avoids potentially expensive repeat
125159
// failing resolution attempts.
@@ -145,11 +179,18 @@ async function compileString(
145179
importers: [
146180
{
147181
findFileUrl: (url, options) => {
182+
const resolveDir = options.containingUrl
183+
? dirname(fileURLToPath(options.containingUrl))
184+
: workingDirectory;
148185
const isPackage = isPackageUrl(url);
149-
const cacheKey = isPackage ? url : `${options.containingUrl?.href ?? ''}:${url}`;
186+
const chainKey =
187+
isPackage && resolveDir !== undefined ? nodeModulesChainKey(resolveDir) : '';
188+
const cacheKey = isPackage
189+
? `${chainKey}:${url}`
190+
: `${options.containingUrl?.href ?? ''}:${url}`;
150191

151192
return currentResolutionCache.getOrCreate(cacheKey, async () => {
152-
const result = await resolveUrl(url, options);
193+
const result = await resolveUrl(url, resolveDir);
153194
if (result.path) {
154195
return pathToFileURL(result.path);
155196
}
@@ -164,10 +205,10 @@ async function compileString(
164205
// Caching package root locations is particularly beneficial for `@material/*` packages
165206
// which extensively use deep imports.
166207
const packageRoot = await currentPackageRootCache.getOrCreate(
167-
packageName,
208+
`${chainKey}:${packageName}`,
168209
async () => {
169210
// Use the required presence of a package root `package.json` file to resolve the location
170-
const packageResult = await resolveUrl(packageName + '/package.json', options);
211+
const packageResult = await resolveUrl(packageName + '/package.json', resolveDir);
171212

172213
return packageResult.path ? dirname(packageResult.path) : null;
173214
},

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

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

9-
import { isPackageUrl } from './sass-language';
9+
import type { PluginBuild } from 'esbuild';
10+
import { existsSync } from 'node:fs';
11+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
12+
import { tmpdir } from 'node:os';
13+
import { dirname, join } from 'node:path';
14+
import {
15+
SassStylesheetLanguage,
16+
isPackageUrl,
17+
resetSassWorkerPoolCaches,
18+
shutdownSassWorkerPool,
19+
} from './sass-language';
1020

1121
describe('sass-language', () => {
1222
describe('isPackageUrl', () => {
@@ -46,4 +56,118 @@ describe('sass-language', () => {
4656
expect(isPackageUrl('')).toBeFalse();
4757
});
4858
});
59+
60+
describe('package resolution caching', () => {
61+
let temporaryRoot: string;
62+
let projectRoot: string;
63+
let resolveRequests: string[];
64+
65+
/**
66+
* Creates a build stub that resolves a package specifier by searching the `node_modules`
67+
* directories visible from the resolve directory, which is how esbuild resolves the
68+
* package specifiers of a stylesheet.
69+
*/
70+
function createBuildStub(): PluginBuild {
71+
return {
72+
initialOptions: { absWorkingDir: projectRoot },
73+
resolve: async (path: string, options: { resolveDir: string }) => {
74+
resolveRequests.push(`${options.resolveDir}:${path}`);
75+
76+
for (let directory = options.resolveDir; ; directory = dirname(directory)) {
77+
const candidate = join(directory, 'node_modules', path, '_index.scss');
78+
if (existsSync(candidate)) {
79+
return { path: candidate, errors: [], warnings: [] };
80+
}
81+
82+
if (dirname(directory) === directory) {
83+
return { path: undefined, errors: [], warnings: [] };
84+
}
85+
}
86+
},
87+
} as unknown as PluginBuild;
88+
}
89+
90+
async function compile(stylesheet: string): Promise<string> {
91+
const result = await SassStylesheetLanguage.process?.(
92+
"@use 'theme';",
93+
stylesheet,
94+
'scss',
95+
{ sourcemap: false },
96+
createBuildStub(),
97+
);
98+
if (!result) {
99+
throw new Error('The Sass stylesheet language has no process function.');
100+
}
101+
102+
if (result.errors?.length) {
103+
return `error: ${result.errors[0].text}`;
104+
}
105+
106+
return (result.contents as string).trim();
107+
}
108+
109+
beforeAll(async () => {
110+
temporaryRoot = await mkdtemp(join(tmpdir(), 'angular-cli-sass-language-'));
111+
projectRoot = join(temporaryRoot, 'project');
112+
113+
// A project with a `theme` package, plus a component directory with its own copy of it.
114+
for (const [directory, marker] of [
115+
[join(projectRoot, 'node_modules', 'theme'), 'project'],
116+
[join(projectRoot, 'nested', 'node_modules', 'theme'), 'nested'],
117+
]) {
118+
await mkdir(directory, { recursive: true });
119+
await writeFile(join(directory, '_index.scss'), `.marker { content: "${marker}"; }`);
120+
}
121+
for (const directory of ['component', 'sibling', 'nested']) {
122+
await mkdir(join(projectRoot, directory), { recursive: true });
123+
}
124+
// A directory outside the project with no `theme` package visible to it.
125+
await mkdir(join(temporaryRoot, 'isolated'), { recursive: true });
126+
});
127+
128+
afterAll(async () => {
129+
shutdownSassWorkerPool();
130+
await rm(temporaryRoot, { force: true, recursive: true });
131+
});
132+
133+
beforeEach(() => {
134+
resetSassWorkerPoolCaches();
135+
resolveRequests = [];
136+
});
137+
138+
it('should not use a nested package resolution for a stylesheet that cannot see it', async () => {
139+
const nested = await compile(join(projectRoot, 'nested', 'styles.scss'));
140+
const component = await compile(join(projectRoot, 'component', 'styles.scss'));
141+
142+
expect(nested).toContain('content: "nested";');
143+
expect(component).toContain('content: "project";');
144+
});
145+
146+
it('should not use a package resolution from a stylesheet with additional nested packages', async () => {
147+
// The reverse order of the above. The resolution of the first stylesheet must not be
148+
// reused for the second, which has an additional `node_modules` directory to search.
149+
const component = await compile(join(projectRoot, 'component', 'styles.scss'));
150+
const nested = await compile(join(projectRoot, 'nested', 'styles.scss'));
151+
152+
expect(component).toContain('content: "project";');
153+
expect(nested).toContain('content: "nested";');
154+
});
155+
156+
it('should reuse a package resolution for stylesheets that search the same directories', async () => {
157+
const component = await compile(join(projectRoot, 'component', 'styles.scss'));
158+
const sibling = await compile(join(projectRoot, 'sibling', 'styles.scss'));
159+
160+
expect(component).toContain('content: "project";');
161+
expect(sibling).toContain('content: "project";');
162+
expect(resolveRequests.length).toBe(1);
163+
});
164+
165+
it('should not reuse a failed package resolution for a stylesheet that can resolve it', async () => {
166+
const isolated = await compile(join(temporaryRoot, 'isolated', 'styles.scss'));
167+
const component = await compile(join(projectRoot, 'component', 'styles.scss'));
168+
169+
expect(isolated).toContain("Can't find stylesheet to import.");
170+
expect(component).toContain('content: "project";');
171+
});
172+
});
49173
});

0 commit comments

Comments
 (0)