Skip to content

Commit ed52db2

Browse files
thekhegaydgp1130
authored andcommitted
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 the root of its enclosing package as the scope, so every file of a package shares one resolution while nested dependency versions stay 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 f52fb53 commit ed52db2

2 files changed

Lines changed: 290 additions & 15 deletions

File tree

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

Lines changed: 48 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 { MemoryCache } from '../../../utils/cache';
1414
import type { SassCompiler } from '../../sass/sass-service';
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

@@ -97,12 +99,35 @@ function parsePackageName(url: string): { packageName: string; readonly pathSegm
9799
};
98100
}
99101

102+
/**
103+
* Returns the scope that qualifies the cached package resolutions of a stylesheet. A stylesheet
104+
* within `node_modules` uses the root of its enclosing package, since every file of a package
105+
* resolves its dependencies against the same `node_modules` directories. All other stylesheets use
106+
* the working directory, allowing component stylesheets to share package resolutions.
107+
*/
108+
export function getPackageScope(
109+
containingPath: string | undefined,
110+
workingDirectory: string | undefined,
111+
): string {
112+
// The directory segments of the stylesheet, excluding the file name
113+
const segments = containingPath?.split(/[\\/]/).slice(0, -1) ?? [];
114+
const index = segments.lastIndexOf('node_modules');
115+
if (index === -1) {
116+
return workingDirectory ?? '';
117+
}
118+
119+
const packageNameLength = segments[index + 1]?.[0] === '@' ? 2 : 1;
120+
121+
return segments.slice(0, index + 1 + packageNameLength).join('/');
122+
}
123+
100124
async function compileString(
101125
data: string,
102126
filePath: string,
103127
syntax: Syntax,
104128
options: StylesheetPluginOptions,
105-
resolveUrl: (url: string, options: CanonicalizeContext) => Promise<ResolveResult>,
129+
resolveUrl: (url: string, resolveDir: string | undefined) => Promise<ResolveResult>,
130+
workingDirectory: string | undefined,
106131
): Promise<OnLoadResult> {
107132
// Lazily load Sass when a Sass file is found
108133
if (sassService === undefined) {
@@ -119,7 +144,8 @@ async function compileString(
119144
}
120145

121146
// 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.
147+
// regardless of its importer's path, except for importers within `node_modules`, which are
148+
// scoped to their enclosing package. Relative paths are qualified with the containing URL.
123149
// A null value indicates that the cached resolution attempt failed to find a location and
124150
// later stage resolution should be attempted. This avoids potentially expensive repeat
125151
// failing resolution attempts.
@@ -145,11 +171,19 @@ async function compileString(
145171
importers: [
146172
{
147173
findFileUrl: (url, options) => {
174+
const containingPath =
175+
options.containingUrl?.protocol === 'file:'
176+
? fileURLToPath(options.containingUrl)
177+
: undefined;
178+
const resolveDir = containingPath ? dirname(containingPath) : workingDirectory;
148179
const isPackage = isPackageUrl(url);
149-
const cacheKey = isPackage ? url : `${options.containingUrl?.href ?? ''}:${url}`;
180+
const scope = getPackageScope(containingPath, workingDirectory);
181+
const cacheKey = isPackage
182+
? `${scope}:${url}`
183+
: `${options.containingUrl?.href ?? ''}:${url}`;
150184

151185
return currentResolutionCache.getOrCreate(cacheKey, async () => {
152-
const result = await resolveUrl(url, options);
186+
const result = await resolveUrl(url, resolveDir);
153187
if (result.path) {
154188
return pathToFileURL(result.path);
155189
}
@@ -164,10 +198,10 @@ async function compileString(
164198
// Caching package root locations is particularly beneficial for `@material/*` packages
165199
// which extensively use deep imports.
166200
const packageRoot = await currentPackageRootCache.getOrCreate(
167-
packageName,
201+
`${scope}:${packageName}`,
168202
async () => {
169203
// Use the required presence of a package root `package.json` file to resolve the location
170-
const packageResult = await resolveUrl(packageName + '/package.json', options);
204+
const packageResult = await resolveUrl(packageName + '/package.json', resolveDir);
171205

172206
return packageResult.path ? dirname(packageResult.path) : null;
173207
},

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

Lines changed: 242 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 assert from 'node:assert';
11+
import { statSync } from 'node:fs';
12+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
13+
import { dirname, join } from 'node:path';
14+
import { pathToFileURL } from 'node:url';
15+
import { SassCompiler } from '../../sass/sass-service';
16+
import {
17+
SassStylesheetLanguage,
18+
getPackageScope,
19+
isPackageUrl,
20+
resetSassWorkerPoolCaches,
21+
shutdownSassWorkerPool,
22+
} from './sass-language';
1023

1124
describe('sass-language', () => {
1225
describe('isPackageUrl', () => {
@@ -46,4 +59,232 @@ describe('sass-language', () => {
4659
expect(isPackageUrl('')).toBeFalse();
4760
});
4861
});
62+
63+
describe('getPackageScope', () => {
64+
const scope = (containingPath?: string) => getPackageScope(containingPath, '/app');
65+
66+
it('should use the working directory for a stylesheet outside node_modules', () => {
67+
expect(scope('/app/src/app.scss')).toBe('/app');
68+
expect(scope('/app/src/foo-node_modules/a.scss')).toBe('/app');
69+
expect(scope('/app/src/node_modules.scss')).toBe('/app');
70+
expect(scope(undefined)).toBe('/app');
71+
});
72+
73+
it('should use the enclosing package root for a stylesheet within node_modules', () => {
74+
expect(scope('/app/node_modules/pkg/a.scss')).toBe('/app/node_modules/pkg');
75+
expect(scope('/app/node_modules/pkg/sub/a.scss')).toBe('/app/node_modules/pkg');
76+
expect(scope('/app/node_modules/@scope/pkg/sub/a.scss')).toBe('/app/node_modules/@scope/pkg');
77+
expect(scope('/app/node_modules/foo-node_modules/a.scss')).toBe(
78+
'/app/node_modules/foo-node_modules',
79+
);
80+
expect(scope('/app/node_modules/a.scss')).toBe('/app/node_modules');
81+
});
82+
83+
it('should use the innermost package root for a nested dependency', () => {
84+
expect(scope('/app/node_modules/dep/node_modules/pkg/a.scss')).toBe(
85+
'/app/node_modules/dep/node_modules/pkg',
86+
);
87+
expect(scope('/app/node_modules/.pnpm/pkg@1.0.0/node_modules/pkg/a.scss')).toBe(
88+
'/app/node_modules/.pnpm/pkg@1.0.0/node_modules/pkg',
89+
);
90+
});
91+
92+
it('should support Windows path separators', () => {
93+
expect(getPackageScope('C:\\app\\node_modules\\pkg\\sub\\a.scss', 'C:\\app')).toBe(
94+
'C:/app/node_modules/pkg',
95+
);
96+
expect(getPackageScope('C:\\app\\src\\app.scss', 'C:\\app')).toBe('C:\\app');
97+
});
98+
});
99+
100+
describe('package resolution caching', () => {
101+
let temporaryRoot: string;
102+
let projectRoot: string;
103+
let buttonStylesheet: string;
104+
let cardStylesheet: string;
105+
let dependencyStylesheet: string;
106+
let resolveRequests: string[];
107+
108+
/**
109+
* Creates a build stub that resolves a package specifier by searching the `node_modules`
110+
* directories visible from the resolve directory, which is how esbuild resolves the
111+
* package specifiers of a stylesheet.
112+
*/
113+
function createBuildStub(): PluginBuild {
114+
return {
115+
initialOptions: { absWorkingDir: projectRoot },
116+
resolve: async (path: string, options: { resolveDir: string }) => {
117+
resolveRequests.push(`${options.resolveDir}:${path}`);
118+
119+
for (let directory = options.resolveDir; ; directory = dirname(directory)) {
120+
// A package specifier resolves to the index file of the package, and an explicit file
121+
// within it to that file. A deeper subpath is left unresolved, as esbuild leaves one
122+
// that the `exports` of the package does not name; the Sass importer then resolves it
123+
// against the package root instead.
124+
for (const candidate of [
125+
join(directory, 'node_modules', path, '_index.scss'),
126+
join(directory, 'node_modules', path),
127+
]) {
128+
if (statSync(candidate, { throwIfNoEntry: false })?.isFile()) {
129+
return { path: candidate, errors: [], warnings: [] };
130+
}
131+
}
132+
133+
if (dirname(directory) === directory) {
134+
return { path: undefined, errors: [], warnings: [] };
135+
}
136+
}
137+
},
138+
} as unknown as PluginBuild;
139+
}
140+
141+
async function compile(stylesheet: string, source = "@use 'theme';"): Promise<string> {
142+
const result = await SassStylesheetLanguage.process?.(
143+
source,
144+
stylesheet,
145+
'scss',
146+
{ sourcemap: false },
147+
createBuildStub(),
148+
);
149+
if (!result) {
150+
throw new Error('The Sass stylesheet language has no process function.');
151+
}
152+
153+
if (result.errors?.length) {
154+
return `error: ${result.errors[0].text}`;
155+
}
156+
157+
return (result.contents as string).trim();
158+
}
159+
160+
async function writePackage(directory: string, marker: string): Promise<void> {
161+
await mkdir(join(directory, 'sub'), { recursive: true });
162+
await writeFile(join(directory, 'package.json'), '{}');
163+
await writeFile(join(directory, '_index.scss'), `.marker { content: "${marker}"; }`);
164+
await writeFile(
165+
join(directory, 'sub', '_other.scss'),
166+
`.deep { content: "${marker} deep"; }`,
167+
);
168+
}
169+
170+
beforeAll(async () => {
171+
const baseTmpDir = process.env['TEST_TMPDIR'];
172+
assert(baseTmpDir, 'TEST_TMPDIR is not set');
173+
temporaryRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-sass-language-'));
174+
projectRoot = join(temporaryRoot, 'project');
175+
const dependencyRoot = join(projectRoot, 'node_modules', 'dependency');
176+
177+
// An application using a `theme` package, and a dependency with its own nested version of
178+
// `theme` plus an `extra` package that only the dependency can see.
179+
await writePackage(join(projectRoot, 'node_modules', 'theme'), 'project');
180+
await writePackage(join(dependencyRoot, 'node_modules', 'theme'), 'dependency');
181+
await writePackage(join(dependencyRoot, 'node_modules', 'extra'), 'extra');
182+
183+
buttonStylesheet = join(projectRoot, 'src', 'app', 'button', 'button.scss');
184+
cardStylesheet = join(projectRoot, 'src', 'app', 'card', 'card.scss');
185+
dependencyStylesheet = join(dependencyRoot, 'styles.scss');
186+
for (const stylesheet of [buttonStylesheet, cardStylesheet]) {
187+
await mkdir(dirname(stylesheet), { recursive: true });
188+
}
189+
});
190+
191+
afterAll(async () => {
192+
shutdownSassWorkerPool();
193+
await rm(temporaryRoot, { force: true, recursive: true });
194+
});
195+
196+
beforeEach(() => {
197+
resetSassWorkerPoolCaches();
198+
resolveRequests = [];
199+
});
200+
201+
it('should not use the package resolution of a dependency for the application', async () => {
202+
const dependency = await compile(dependencyStylesheet);
203+
const application = await compile(buttonStylesheet);
204+
205+
expect(dependency).toContain('content: "dependency";');
206+
expect(application).toContain('content: "project";');
207+
});
208+
209+
it('should not use the package resolution of the application for a dependency', async () => {
210+
const application = await compile(buttonStylesheet);
211+
const dependency = await compile(dependencyStylesheet);
212+
213+
expect(application).toContain('content: "project";');
214+
expect(dependency).toContain('content: "dependency";');
215+
});
216+
217+
it('should not reuse a failed package resolution of the application for a dependency', async () => {
218+
const source = "@use 'extra';";
219+
const application = await compile(buttonStylesheet, source);
220+
const dependency = await compile(dependencyStylesheet, source);
221+
222+
expect(application).toContain("Can't find stylesheet to import.");
223+
expect(dependency).toContain('content: "extra";');
224+
});
225+
226+
it('should not use the package root of a dependency for a deep import of the application', async () => {
227+
// A subpath that resolves to no file of its own is located through the root of the package,
228+
// which is cached separately from the resolution of the specifier.
229+
const source = "@use 'theme/sub/other';";
230+
const dependency = await compile(dependencyStylesheet, source);
231+
const application = await compile(buttonStylesheet, source);
232+
233+
expect(dependency).toContain('content: "dependency deep";');
234+
expect(application).toContain('content: "project deep";');
235+
});
236+
237+
it('should share a package resolution between the stylesheets of different components', async () => {
238+
const button = await compile(buttonStylesheet);
239+
const card = await compile(cardStylesheet);
240+
241+
expect(button).toContain('content: "project";');
242+
expect(card).toContain('content: "project";');
243+
expect(resolveRequests.length).toBe(1);
244+
});
245+
246+
it('should share a package resolution between the stylesheets of different folders of a dependency', async () => {
247+
const dependencyRoot = join(projectRoot, 'node_modules', 'dependency');
248+
const first = await compile(join(dependencyRoot, 'sub1', 'styles.scss'));
249+
const second = await compile(join(dependencyRoot, 'sub2', 'styles.scss'));
250+
251+
expect(first).toContain('content: "dependency";');
252+
expect(second).toContain('content: "dependency";');
253+
expect(resolveRequests.length).toBe(1);
254+
});
255+
256+
it('should share a package resolution between the stylesheets of different folders of a scoped package', async () => {
257+
const packageRoot = join(projectRoot, 'node_modules', '@scope', 'pkg');
258+
const first = await compile(join(packageRoot, 'sub1', 'styles.scss'));
259+
const second = await compile(join(packageRoot, 'sub2', 'styles.scss'));
260+
261+
expect(first).toContain('content: "project";');
262+
expect(second).toContain('content: "project";');
263+
expect(resolveRequests.length).toBe(1);
264+
});
265+
266+
it('should resolve a package url of a non-file containing URL from the working directory', async () => {
267+
// The stylesheets of a build have file URLs, but Sass does not limit a containing URL to them.
268+
spyOn(SassCompiler.prototype, 'compileStringAsync').and.callFake(async (_, options) => {
269+
const importer = options.importers?.[0] as {
270+
findFileUrl(
271+
url: string,
272+
context: { containingUrl: URL; fromImport: boolean },
273+
): Promise<URL | null>;
274+
};
275+
const url = await importer.findFileUrl('theme', {
276+
containingUrl: new URL('custom:styles.scss'),
277+
fromImport: false,
278+
});
279+
280+
return { css: url?.href ?? '', loadedUrls: [] };
281+
});
282+
283+
const result = await compile(buttonStylesheet);
284+
285+
expect(result).toBe(
286+
pathToFileURL(join(projectRoot, 'node_modules', 'theme', '_index.scss')).href,
287+
);
288+
});
289+
});
49290
});

0 commit comments

Comments
 (0)