Skip to content

Commit 585bb25

Browse files
committed
fix(@angular/build): scope Sass package resolution caching for stylesheets in 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 dependency within `node_modules` that has its own nested version of a package received the version resolved for the application, the application received the nested version when the dependency was compiled first, and a failed resolution was reused for a dependency 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 qualified with a scope. A stylesheet within `node_modules` uses its own directory as the scope, which keeps nested dependency versions isolated. All other stylesheets use the working directory of the build, so the component stylesheets of an application continue to share a single resolution. The scope is derived from the path of the stylesheet alone and requires no file system access. A containing URL that does not use the `file:` scheme is resolved from the working directory instead of causing an error.
1 parent 8c43889 commit 585bb25

2 files changed

Lines changed: 209 additions & 15 deletions

File tree

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

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import type { OnLoadResult, PartialMessage, PartialNote, ResolveResult } from 'esbuild';
1010
import { dirname, join } from 'node:path';
1111
import { fileURLToPath, pathToFileURL } from 'node:url';
12-
import type { CanonicalizeContext, CompileResult, Exception, Syntax } from 'sass-embedded';
12+
import type { CompileResult, Exception, Syntax } from 'sass-embedded';
1313
import type { SassCompiler } from '../../sass/sass-service';
1414
import { MemoryCache } from '../cache';
1515
import { StylesheetLanguage, StylesheetPluginOptions } from './stylesheet-plugin-factory';
@@ -50,12 +50,7 @@ export const SassStylesheetLanguage = Object.freeze<StylesheetLanguage>({
5050
fileFilter: /\.s[ac]ss$/,
5151
process(data, file, format, options, build) {
5252
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-
53+
const resolveUrl = async (url: string, resolveDir: string | undefined) => {
5954
const path = url.startsWith('pkg:') ? url.slice(4) : url;
6055
const result = await build.resolve(path, {
6156
kind: 'import-rule',
@@ -65,7 +60,14 @@ export const SassStylesheetLanguage = Object.freeze<StylesheetLanguage>({
6560
return result;
6661
};
6762

68-
return compileString(data, file, syntax, options, resolveUrl);
63+
return compileString(
64+
data,
65+
file,
66+
syntax,
67+
options,
68+
resolveUrl,
69+
build.initialOptions.absWorkingDir,
70+
);
6971
},
7072
});
7173

@@ -102,7 +104,8 @@ async function compileString(
102104
filePath: string,
103105
syntax: Syntax,
104106
options: StylesheetPluginOptions,
105-
resolveUrl: (url: string, options: CanonicalizeContext) => Promise<ResolveResult>,
107+
resolveUrl: (url: string, resolveDir: string | undefined) => Promise<ResolveResult>,
108+
workingDirectory: string | undefined,
106109
): Promise<OnLoadResult> {
107110
// Lazily load Sass when a Sass file is found
108111
if (sassService === undefined) {
@@ -119,7 +122,8 @@ async function compileString(
119122
}
120123

121124
// 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.
125+
// regardless of its importer's path, except for importers within `node_modules`, which are
126+
// scoped to their own directory. Relative paths are qualified with the containing URL.
123127
// A null value indicates that the cached resolution attempt failed to find a location and
124128
// later stage resolution should be attempted. This avoids potentially expensive repeat
125129
// failing resolution attempts.
@@ -145,11 +149,24 @@ async function compileString(
145149
importers: [
146150
{
147151
findFileUrl: (url, options) => {
152+
const containingPath =
153+
options.containingUrl?.protocol === 'file:'
154+
? fileURLToPath(options.containingUrl)
155+
: undefined;
156+
const resolveDir = containingPath ? dirname(containingPath) : workingDirectory;
148157
const isPackage = isPackageUrl(url);
149-
const cacheKey = isPackage ? url : `${options.containingUrl?.href ?? ''}:${url}`;
158+
159+
// Package urls from files within `node_modules` are scoped to the directory of the
160+
// importer to isolate nested dependency versions. All other files share the working
161+
// directory, allowing component stylesheets to share package resolutions.
162+
const isNodeModules = /[\\/]node_modules[\\/]/.test(containingPath ?? '');
163+
const scope = isNodeModules ? (resolveDir ?? '') : (workingDirectory ?? '');
164+
const cacheKey = isPackage
165+
? `${scope}:${url}`
166+
: `${options.containingUrl?.href ?? ''}:${url}`;
150167

151168
return currentResolutionCache.getOrCreate(cacheKey, async () => {
152-
const result = await resolveUrl(url, options);
169+
const result = await resolveUrl(url, resolveDir);
153170
if (result.path) {
154171
return pathToFileURL(result.path);
155172
}
@@ -164,10 +181,10 @@ async function compileString(
164181
// Caching package root locations is particularly beneficial for `@material/*` packages
165182
// which extensively use deep imports.
166183
const packageRoot = await currentPackageRootCache.getOrCreate(
167-
packageName,
184+
`${scope}:${packageName}`,
168185
async () => {
169186
// Use the required presence of a package root `package.json` file to resolve the location
170-
const packageResult = await resolveUrl(packageName + '/package.json', options);
187+
const packageResult = await resolveUrl(packageName + '/package.json', resolveDir);
171188

172189
return packageResult.path ? dirname(packageResult.path) : null;
173190
},

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

Lines changed: 178 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,20 @@
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 { pathToFileURL } from 'node:url';
15+
import type { FileImporter } from 'sass-embedded';
16+
import { SassCompiler } from '../../sass/sass-service';
17+
import {
18+
SassStylesheetLanguage,
19+
isPackageUrl,
20+
resetSassWorkerPoolCaches,
21+
shutdownSassWorkerPool,
22+
} from './sass-language';
1023

1124
describe('sass-language', () => {
1225
describe('isPackageUrl', () => {
@@ -46,4 +59,168 @@ describe('sass-language', () => {
4659
expect(isPackageUrl('')).toBeFalse();
4760
});
4861
});
62+
63+
describe('package resolution caching', () => {
64+
let temporaryRoot: string;
65+
let projectRoot: string;
66+
let buttonStylesheet: string;
67+
let cardStylesheet: string;
68+
let dependencyStylesheet: string;
69+
let resolveRequests: string[];
70+
71+
/**
72+
* Creates a build stub that resolves a package specifier by searching the `node_modules`
73+
* directories visible from the resolve directory, which is how esbuild resolves the
74+
* package specifiers of a stylesheet.
75+
*/
76+
function createBuildStub(): PluginBuild {
77+
return {
78+
initialOptions: { absWorkingDir: projectRoot },
79+
resolve: async (path: string, options: { resolveDir: string }) => {
80+
resolveRequests.push(`${options.resolveDir}:${path}`);
81+
82+
for (let directory = options.resolveDir; ; directory = dirname(directory)) {
83+
// A package specifier resolves to the index file of the package, and an explicit file
84+
// within it to that file. A deeper subpath is left unresolved, as esbuild leaves one
85+
// that the `exports` of the package does not name; the Sass importer then resolves it
86+
// against the package root instead.
87+
for (const candidate of [
88+
join(directory, 'node_modules', path, '_index.scss'),
89+
join(directory, 'node_modules', path),
90+
]) {
91+
if (statSync(candidate, { throwIfNoEntry: false })?.isFile()) {
92+
return { path: candidate, errors: [], warnings: [] };
93+
}
94+
}
95+
96+
if (dirname(directory) === directory) {
97+
return { path: undefined, errors: [], warnings: [] };
98+
}
99+
}
100+
},
101+
} as unknown as PluginBuild;
102+
}
103+
104+
async function compile(stylesheet: string, source = "@use 'theme';"): Promise<string> {
105+
const result = await SassStylesheetLanguage.process?.(
106+
source,
107+
stylesheet,
108+
'scss',
109+
{ sourcemap: false },
110+
createBuildStub(),
111+
);
112+
if (!result) {
113+
throw new Error('The Sass stylesheet language has no process function.');
114+
}
115+
116+
if (result.errors?.length) {
117+
return `error: ${result.errors[0].text}`;
118+
}
119+
120+
return (result.contents as string).trim();
121+
}
122+
123+
async function writePackage(directory: string, marker: string): Promise<void> {
124+
await mkdir(join(directory, 'sub'), { recursive: true });
125+
await writeFile(join(directory, 'package.json'), '{}');
126+
await writeFile(join(directory, '_index.scss'), `.marker { content: "${marker}"; }`);
127+
await writeFile(
128+
join(directory, 'sub', '_other.scss'),
129+
`.deep { content: "${marker} deep"; }`,
130+
);
131+
}
132+
133+
beforeAll(async () => {
134+
temporaryRoot = await mkdtemp(join(tmpdir(), 'angular-cli-sass-language-'));
135+
projectRoot = join(temporaryRoot, 'project');
136+
const dependencyRoot = join(projectRoot, 'node_modules', 'dependency');
137+
138+
// An application using a `theme` package, and a dependency with its own nested version of
139+
// `theme` plus an `extra` package that only the dependency can see.
140+
await writePackage(join(projectRoot, 'node_modules', 'theme'), 'project');
141+
await writePackage(join(dependencyRoot, 'node_modules', 'theme'), 'dependency');
142+
await writePackage(join(dependencyRoot, 'node_modules', 'extra'), 'extra');
143+
144+
buttonStylesheet = join(projectRoot, 'src', 'app', 'button', 'button.scss');
145+
cardStylesheet = join(projectRoot, 'src', 'app', 'card', 'card.scss');
146+
dependencyStylesheet = join(dependencyRoot, 'styles.scss');
147+
for (const stylesheet of [buttonStylesheet, cardStylesheet]) {
148+
await mkdir(dirname(stylesheet), { recursive: true });
149+
}
150+
});
151+
152+
afterAll(async () => {
153+
shutdownSassWorkerPool();
154+
await rm(temporaryRoot, { force: true, recursive: true });
155+
});
156+
157+
beforeEach(() => {
158+
resetSassWorkerPoolCaches();
159+
resolveRequests = [];
160+
});
161+
162+
it('should not use the package resolution of a dependency for the application', async () => {
163+
const dependency = await compile(dependencyStylesheet);
164+
const application = await compile(buttonStylesheet);
165+
166+
expect(dependency).toContain('content: "dependency";');
167+
expect(application).toContain('content: "project";');
168+
});
169+
170+
it('should not use the package resolution of the application for a dependency', async () => {
171+
const application = await compile(buttonStylesheet);
172+
const dependency = await compile(dependencyStylesheet);
173+
174+
expect(application).toContain('content: "project";');
175+
expect(dependency).toContain('content: "dependency";');
176+
});
177+
178+
it('should not reuse a failed package resolution of the application for a dependency', async () => {
179+
const source = "@use 'extra';";
180+
const application = await compile(buttonStylesheet, source);
181+
const dependency = await compile(dependencyStylesheet, source);
182+
183+
expect(application).toContain("Can't find stylesheet to import.");
184+
expect(dependency).toContain('content: "extra";');
185+
});
186+
187+
it('should not use the package root of a dependency for a deep import of the application', async () => {
188+
// A subpath that resolves to no file of its own is located through the root of the package,
189+
// which is cached separately from the resolution of the specifier.
190+
const source = "@use 'theme/sub/other';";
191+
const dependency = await compile(dependencyStylesheet, source);
192+
const application = await compile(buttonStylesheet, source);
193+
194+
expect(dependency).toContain('content: "dependency deep";');
195+
expect(application).toContain('content: "project deep";');
196+
});
197+
198+
it('should share a package resolution between the stylesheets of different components', async () => {
199+
const button = await compile(buttonStylesheet);
200+
const card = await compile(cardStylesheet);
201+
202+
expect(button).toContain('content: "project";');
203+
expect(card).toContain('content: "project";');
204+
expect(resolveRequests.length).toBe(1);
205+
});
206+
207+
it('should resolve a package url of a non-file containing URL from the working directory', async () => {
208+
// The stylesheets of a build have file URLs, but Sass does not limit a containing URL to them.
209+
spyOn(SassCompiler.prototype, 'compileStringAsync').and.callFake(async (_, options) => {
210+
const importer = options.importers?.[0] as FileImporter<'async'>;
211+
const url = await importer.findFileUrl('theme', {
212+
containingUrl: new URL('custom:styles.scss'),
213+
fromImport: false,
214+
});
215+
216+
return { css: url?.href ?? '', loadedUrls: [] };
217+
});
218+
219+
const result = await compile(buttonStylesheet);
220+
221+
expect(result).toBe(
222+
pathToFileURL(join(projectRoot, 'node_modules', 'theme', '_index.scss')).href,
223+
);
224+
});
225+
});
49226
});

0 commit comments

Comments
 (0)