Skip to content

Commit f6d4a1c

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 f6d4a1c

2 files changed

Lines changed: 207 additions & 15 deletions

File tree

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

Lines changed: 58 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,42 @@ 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. Directories with differing keys search different
108+
* locations and must not share a cached package resolution. An identical key does not by itself
109+
* guarantee an identical resolution, since esbuild also applies the path mappings of the nearest
110+
* `tsconfig.json` and the browser field of the nearest `package.json`, neither of which is
111+
* modeled here.
112+
*
113+
* The result is cached for the directory and for each of its parents.
114+
*
115+
* @param directory An absolute path of the directory to analyze.
116+
* @returns A key representing the `node_modules` directories visible from the directory.
117+
*/
118+
function nodeModulesChainKey(directory: string): string {
119+
nodeModulesChainCache ??= new Map<string, string>();
120+
const cached = nodeModulesChainCache.get(directory);
121+
if (cached !== undefined) {
122+
return cached;
123+
}
124+
125+
const parent = dirname(directory);
126+
const parentKey = parent === directory ? '' : nodeModulesChainKey(parent);
127+
const candidate = join(directory, 'node_modules');
128+
const key = existsSync(candidate) ? `${candidate}\n${parentKey}` : parentKey;
129+
nodeModulesChainCache.set(directory, key);
130+
131+
return key;
132+
}
133+
100134
async function compileString(
101135
data: string,
102136
filePath: string,
103137
syntax: Syntax,
104138
options: StylesheetPluginOptions,
105-
resolveUrl: (url: string, options: CanonicalizeContext) => Promise<ResolveResult>,
139+
resolveUrl: (url: string, resolveDir: string | undefined) => Promise<ResolveResult>,
140+
workingDirectory: string | undefined,
106141
): Promise<OnLoadResult> {
107142
// Lazily load Sass when a Sass file is found
108143
if (sassService === undefined) {
@@ -119,7 +154,9 @@ async function compileString(
119154
}
120155

121156
// 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.
157+
// for importers that search the same `node_modules` directories. Package urls are therefore
158+
// qualified with the visible `node_modules` directories of the importer instead of its path,
159+
// while relative paths are qualified with the containing URL.
123160
// A null value indicates that the cached resolution attempt failed to find a location and
124161
// later stage resolution should be attempted. This avoids potentially expensive repeat
125162
// failing resolution attempts.
@@ -145,11 +182,18 @@ async function compileString(
145182
importers: [
146183
{
147184
findFileUrl: (url, options) => {
185+
const resolveDir = options.containingUrl
186+
? dirname(fileURLToPath(options.containingUrl))
187+
: workingDirectory;
148188
const isPackage = isPackageUrl(url);
149-
const cacheKey = isPackage ? url : `${options.containingUrl?.href ?? ''}:${url}`;
189+
const chainKey =
190+
isPackage && resolveDir !== undefined ? nodeModulesChainKey(resolveDir) : '';
191+
const cacheKey = isPackage
192+
? `${chainKey}:${url}`
193+
: `${options.containingUrl?.href ?? ''}:${url}`;
150194

151195
return currentResolutionCache.getOrCreate(cacheKey, async () => {
152-
const result = await resolveUrl(url, options);
196+
const result = await resolveUrl(url, resolveDir);
153197
if (result.path) {
154198
return pathToFileURL(result.path);
155199
}
@@ -164,10 +208,10 @@ async function compileString(
164208
// Caching package root locations is particularly beneficial for `@material/*` packages
165209
// which extensively use deep imports.
166210
const packageRoot = await currentPackageRootCache.getOrCreate(
167-
packageName,
211+
`${chainKey}:${packageName}`,
168212
async () => {
169213
// Use the required presence of a package root `package.json` file to resolve the location
170-
const packageResult = await resolveUrl(packageName + '/package.json', options);
214+
const packageResult = await resolveUrl(packageName + '/package.json', resolveDir);
171215

172216
return packageResult.path ? dirname(packageResult.path) : null;
173217
},

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

Lines changed: 149 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 { statSync } 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,142 @@ 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+
// A package specifier resolves to the index file of the package, and an explicit file
78+
// within it to that file. A deeper subpath is left unresolved, as esbuild leaves one
79+
// that the `exports` of the package does not name; the Sass importer then resolves it
80+
// against the package root instead.
81+
for (const candidate of [
82+
join(directory, 'node_modules', path, '_index.scss'),
83+
join(directory, 'node_modules', path),
84+
]) {
85+
if (statSync(candidate, { throwIfNoEntry: false })?.isFile()) {
86+
return { path: candidate, errors: [], warnings: [] };
87+
}
88+
}
89+
90+
if (dirname(directory) === directory) {
91+
return { path: undefined, errors: [], warnings: [] };
92+
}
93+
}
94+
},
95+
} as unknown as PluginBuild;
96+
}
97+
98+
async function compile(stylesheet: string, source = "@use 'theme';"): Promise<string> {
99+
const result = await SassStylesheetLanguage.process?.(
100+
source,
101+
stylesheet,
102+
'scss',
103+
{ sourcemap: false },
104+
createBuildStub(),
105+
);
106+
if (!result) {
107+
throw new Error('The Sass stylesheet language has no process function.');
108+
}
109+
110+
if (result.errors?.length) {
111+
return `error: ${result.errors[0].text}`;
112+
}
113+
114+
return (result.contents as string).trim();
115+
}
116+
117+
beforeAll(async () => {
118+
temporaryRoot = await mkdtemp(join(tmpdir(), 'angular-cli-sass-language-'));
119+
projectRoot = join(temporaryRoot, 'project');
120+
121+
// A project with a `theme` package, plus a component directory with its own copy of it.
122+
for (const [directory, marker] of [
123+
[join(projectRoot, 'node_modules', 'theme'), 'project'],
124+
[join(projectRoot, 'nested', 'node_modules', 'theme'), 'nested'],
125+
]) {
126+
await mkdir(join(directory, 'sub'), { recursive: true });
127+
await writeFile(join(directory, 'package.json'), '{ "name": "theme" }');
128+
await writeFile(join(directory, '_index.scss'), `.marker { content: "${marker}"; }`);
129+
await writeFile(
130+
join(directory, 'sub', '_other.scss'),
131+
`.deep { content: "${marker} deep"; }`,
132+
);
133+
}
134+
for (const directory of ['component', 'sibling', 'nested']) {
135+
await mkdir(join(projectRoot, directory), { recursive: true });
136+
}
137+
// A directory outside the project with no `theme` package visible to it.
138+
await mkdir(join(temporaryRoot, 'isolated'), { recursive: true });
139+
});
140+
141+
afterAll(async () => {
142+
shutdownSassWorkerPool();
143+
await rm(temporaryRoot, { force: true, recursive: true });
144+
});
145+
146+
beforeEach(() => {
147+
resetSassWorkerPoolCaches();
148+
resolveRequests = [];
149+
});
150+
151+
it('should not use a nested package resolution for a stylesheet that cannot see it', async () => {
152+
const nested = await compile(join(projectRoot, 'nested', 'styles.scss'));
153+
const component = await compile(join(projectRoot, 'component', 'styles.scss'));
154+
155+
expect(nested).toContain('content: "nested";');
156+
expect(component).toContain('content: "project";');
157+
});
158+
159+
it('should not use a package resolution from a stylesheet with additional nested packages', async () => {
160+
// The reverse order of the above. The resolution of the first stylesheet must not be
161+
// reused for the second, which has an additional `node_modules` directory to search.
162+
const component = await compile(join(projectRoot, 'component', 'styles.scss'));
163+
const nested = await compile(join(projectRoot, 'nested', 'styles.scss'));
164+
165+
expect(component).toContain('content: "project";');
166+
expect(nested).toContain('content: "nested";');
167+
});
168+
169+
it('should reuse a package resolution for stylesheets that search the same directories', async () => {
170+
const component = await compile(join(projectRoot, 'component', 'styles.scss'));
171+
const sibling = await compile(join(projectRoot, 'sibling', 'styles.scss'));
172+
173+
expect(component).toContain('content: "project";');
174+
expect(sibling).toContain('content: "project";');
175+
expect(resolveRequests.length).toBe(1);
176+
});
177+
178+
it('should not use a nested package root for a deep import that cannot see it', async () => {
179+
// A subpath that resolves to no file of its own is located through the root of the package,
180+
// which is cached separately from the resolution of the specifier.
181+
const deep = "@use 'theme/sub/other';";
182+
const nested = await compile(join(projectRoot, 'nested', 'styles.scss'), deep);
183+
const component = await compile(join(projectRoot, 'component', 'styles.scss'), deep);
184+
185+
expect(nested).toContain('content: "nested deep";');
186+
expect(component).toContain('content: "project deep";');
187+
});
188+
189+
it('should not reuse a failed package resolution for a stylesheet that can resolve it', async () => {
190+
const isolated = await compile(join(temporaryRoot, 'isolated', 'styles.scss'));
191+
const component = await compile(join(projectRoot, 'component', 'styles.scss'));
192+
193+
expect(isolated).toContain("Can't find stylesheet to import.");
194+
expect(component).toContain('content: "project";');
195+
});
196+
});
49197
});

0 commit comments

Comments
 (0)