Skip to content

Commit 93f238c

Browse files
committed
fix(@angular/build): rebuild library when transitive stylesheet dependencies change in watch mode
Track transitive stylesheet resource references per containing component and stylesheet file in `BuilderState.addFileReferences`, and resolve affected files via `BuilderState.getAffectedFiles` during watch mode rebuilds. Previously, modifying a Sass partial imported by an inline component stylesheet did not invalidate the containing component file or reset the Sass worker pool cache.
1 parent d542827 commit 93f238c

5 files changed

Lines changed: 213 additions & 32 deletions

File tree

‎packages/angular/build/src/builders/library/builder-state.ts‎

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

9+
import { toPosixPath } from '../../utils/path';
910
import { ExecutionResult } from './execution-result';
1011
import type {
1112
BundleResult,
@@ -15,10 +16,12 @@ import type {
1516
} from './types';
1617

1718
/**
18-
* Encapsulates incremental compilation state, watched compilation file tracking,
19-
* and execution results across watch-mode rebuilds.
19+
* Encapsulates incremental compilation state, transitive resource reference tracking,
20+
* watched compilation file tracking, and execution results across watch-mode rebuilds.
2021
*/
2122
export class BuilderState {
23+
readonly #referencingFiles = new Map<string, Set<string>>();
24+
readonly #referencedByFile = new Map<string, Set<string>>();
2225
readonly #watchedCompilationFiles = new Set<string>();
2326
readonly #pendingCompilationFiles = new Set<string>();
2427

@@ -116,6 +119,94 @@ export class BuilderState {
116119
return this.#executionResult;
117120
}
118121

122+
/**
123+
* Records transitive resource dependencies (such as Sass partials) for a containing
124+
* component or stylesheet file and registers them in `watchedCompilationFiles`.
125+
* Multiple calls for the same containing file accumulate references additively.
126+
*
127+
* @param containingFile Path of the referencing component or stylesheet file.
128+
* @param referencedFiles Paths of the referenced files.
129+
*/
130+
addFileReferences(containingFile: string, referencedFiles: Iterable<string>): void {
131+
const posixContainingFile = toPosixPath(containingFile);
132+
let forwardReferences = this.#referencedByFile.get(posixContainingFile);
133+
134+
for (const file of referencedFiles) {
135+
const posixFile = toPosixPath(file);
136+
this.#watchedCompilationFiles.add(posixFile);
137+
if (posixFile === posixContainingFile) {
138+
continue;
139+
}
140+
141+
if (!forwardReferences) {
142+
forwardReferences = new Set();
143+
this.#referencedByFile.set(posixContainingFile, forwardReferences);
144+
}
145+
forwardReferences.add(posixFile);
146+
147+
let referencing = this.#referencingFiles.get(posixFile);
148+
if (!referencing) {
149+
referencing = new Set();
150+
this.#referencingFiles.set(posixFile, referencing);
151+
}
152+
referencing.add(posixContainingFile);
153+
}
154+
}
155+
156+
/**
157+
* Clears previously recorded outgoing resource references for the given containing files
158+
* before they are recompiled.
159+
*
160+
* @param containingFiles Paths of the containing or stylesheet files being recompiled.
161+
*/
162+
clearFileReferences(containingFiles: Iterable<string>): void {
163+
for (const containingFile of containingFiles) {
164+
const posixContainingFile = toPosixPath(containingFile);
165+
const forwardReferences = this.#referencedByFile.get(posixContainingFile);
166+
if (!forwardReferences) {
167+
continue;
168+
}
169+
170+
for (const prevRef of forwardReferences) {
171+
const referencing = this.#referencingFiles.get(prevRef);
172+
if (referencing) {
173+
referencing.delete(posixContainingFile);
174+
if (referencing.size === 0) {
175+
this.#referencingFiles.delete(prevRef);
176+
}
177+
}
178+
}
179+
180+
this.#referencedByFile.delete(posixContainingFile);
181+
}
182+
}
183+
184+
/**
185+
* Returns the set of all files affected by the given changed files, including the
186+
* changed files themselves and all transitively referencing files
187+
* (e.g., partial `.scss` -> component `.scss` -> component `.ts`).
188+
*
189+
* @param files Initial changed file paths.
190+
* @returns Set of POSIX paths including all transitively referencing files.
191+
*/
192+
getAffectedFiles(files: Iterable<string>): Set<string> {
193+
const affected = new Set<string>();
194+
for (const file of files) {
195+
affected.add(toPosixPath(file));
196+
}
197+
198+
for (const file of affected) {
199+
const referencing = this.#referencingFiles.get(file);
200+
if (referencing) {
201+
for (const refFile of referencing) {
202+
affected.add(refFile);
203+
}
204+
}
205+
}
206+
207+
return affected;
208+
}
209+
119210
/**
120211
* Determines whether compilation and bundling need to run for the current build iteration.
121212
* Also records incoming modified files so they are retained if the iteration aborts early.
@@ -235,6 +326,8 @@ export class BuilderState {
235326
this.pendingChangedEsmFiles.clear();
236327
this.pendingChangedDtsFiles.clear();
237328
this.#pendingCompilationFiles.clear();
329+
this.#referencingFiles.clear();
330+
this.#referencedByFile.clear();
238331
this.#watchedCompilationFiles.clear();
239332
}
240333
}

‎packages/angular/build/src/builders/library/builder-state_spec.ts‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,58 @@ describe('BuilderState', () => {
7070
});
7171
});
7272

73+
describe('file reference tracking', () => {
74+
it('should expand transitive multi-hop references and ignore self-references', () => {
75+
const state = new BuilderState();
76+
const compTs = '/workspace/projects/my-lib/src/lib/comp.ts';
77+
const compScss = '/workspace/projects/my-lib/src/lib/comp.scss';
78+
const varsScss = '/workspace/projects/my-lib/src/lib/_vars.scss';
79+
80+
state.addFileReferences(compTs, [compTs, compScss, varsScss]);
81+
state.addFileReferences(compScss, [compScss, varsScss]);
82+
83+
expect(state.watchedCompilationFiles.has(compTs)).toBeTrue();
84+
expect(state.watchedCompilationFiles.has(compScss)).toBeTrue();
85+
expect(state.watchedCompilationFiles.has(varsScss)).toBeTrue();
86+
87+
const affected = state.getAffectedFiles([varsScss]);
88+
expect(affected).toEqual(new Set([varsScss, compTs, compScss]));
89+
90+
// Subsequent getAffectedFiles calls preserve mappings when not yet overwritten
91+
const affectedAgain = state.getAffectedFiles([varsScss]);
92+
expect(affectedAgain).toEqual(new Set([varsScss, compTs, compScss]));
93+
});
94+
95+
it('should accumulate references across multiple stylesheets for the same containing file', () => {
96+
const state = new BuilderState();
97+
const compTs = '/workspace/projects/my-lib/src/lib/comp.ts';
98+
const firstPartial = '/workspace/projects/my-lib/src/lib/_first.scss';
99+
const secondPartial = '/workspace/projects/my-lib/src/lib/_second.scss';
100+
101+
state.addFileReferences(compTs, [compTs, firstPartial]);
102+
state.addFileReferences(compTs, [compTs, secondPartial]);
103+
104+
expect(state.getAffectedFiles([firstPartial])).toEqual(new Set([firstPartial, compTs]));
105+
expect(state.getAffectedFiles([secondPartial])).toEqual(new Set([secondPartial, compTs]));
106+
});
107+
108+
it('should prune stale references when a containing file is cleared and re-recorded', () => {
109+
const state = new BuilderState();
110+
const compTs = '/workspace/projects/my-lib/src/lib/comp.ts';
111+
const oldPartial = '/workspace/projects/my-lib/src/lib/_old.scss';
112+
const newPartial = '/workspace/projects/my-lib/src/lib/_new.scss';
113+
114+
state.addFileReferences(compTs, [compTs, oldPartial]);
115+
expect(state.getAffectedFiles([oldPartial])).toEqual(new Set([oldPartial, compTs]));
116+
117+
// Clear and re-record references for compTs without oldPartial
118+
state.clearFileReferences([compTs]);
119+
state.addFileReferences(compTs, [compTs, newPartial]);
120+
expect(state.getAffectedFiles([oldPartial])).toEqual(new Set([oldPartial]));
121+
expect(state.getAffectedFiles([newPartial])).toEqual(new Set([newPartial, compTs]));
122+
});
123+
});
124+
73125
describe('shouldCompileEntryPoints & getCompilationModifiedFiles', () => {
74126
it('should preserve pending compilation files across aborted iterations', () => {
75127
const state = new BuilderState({

‎packages/angular/build/src/builders/library/builder.ts‎

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -261,29 +261,14 @@ async function* runWatchLoop(
261261
console.clear();
262262
}
263263

264-
const changedFiles = new Set<string>();
265-
let hasStyleChanges = false;
266-
let hasSassChanges = false;
267-
for (const file of changes.all) {
268-
const posixFile = toPosixPath(file);
269-
changedFiles.add(posixFile);
270-
if (/\.(?:scss|sass)$/i.test(posixFile)) {
271-
hasStyleChanges = true;
272-
hasSassChanges = true;
273-
} else if (/\.(?:less|css)$/i.test(posixFile)) {
274-
hasStyleChanges = true;
275-
}
276-
}
264+
const rawChangedFiles = changes.all.map(toPosixPath);
265+
stylesheetBundler.invalidate(rawChangedFiles);
266+
const changedFiles = buildState.getAffectedFiles(rawChangedFiles);
277267

278-
if (hasStyleChanges) {
279-
if (hasSassChanges) {
268+
for (const posixFile of changedFiles) {
269+
if (/\.(?:scss|sass)$/i.test(posixFile)) {
280270
resetSassWorkerPoolCaches();
281-
}
282-
const invalidatedStyles = stylesheetBundler.invalidate(changedFiles);
283-
if (invalidatedStyles) {
284-
for (const styleFile of invalidatedStyles) {
285-
changedFiles.add(toPosixPath(styleFile));
286-
}
271+
break;
287272
}
288273
}
289274

‎packages/angular/build/src/builders/library/pipeline/compilation.ts‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const EMITTED_EXTENSIONS = ['.js', '.mjs', '.cjs', '.d.ts', '.d.mts', '.d.cts'];
2626
*
2727
* @param options The normalized library builder options with POSIX paths.
2828
* @param stylesheetBundler The component stylesheet bundler instance.
29-
* @param buildState The build state tracking compilation caches and diagnostics.
29+
* @param buildState The build state tracking compilation caches, file references, and diagnostics.
3030
* @param modifiedFiles Optional set of modified POSIX file paths for incremental compilation.
3131
* @returns A promise resolving to the compilation output containing emitted files and cache state.
3232
*/
@@ -70,9 +70,9 @@ export async function compileLibrary(
7070

7171
if (effectiveModifiedFiles && effectiveModifiedFiles.size > 0) {
7272
await compilationInstance.update?.(effectiveModifiedFiles);
73+
buildState.clearFileReferences(effectiveModifiedFiles);
7374
}
7475

75-
const allReferencedFiles = new Set<string>();
7676
const stylesheetWarnings: PartialMessage[] = [];
7777
const stylesheetErrors: PartialMessage[] = [];
7878
const failedFiles = new Set<string>();
@@ -88,7 +88,13 @@ export async function compileLibrary(
8888
? await stylesheetBundler.bundleFile(stylesheetFile)
8989
: await stylesheetBundler.bundleInline(data, containingFile);
9090

91-
result.referencedFiles?.forEach((f) => allReferencedFiles.add(toPosixPath(f)));
91+
if (result.referencedFiles) {
92+
buildState.addFileReferences(containingFile, result.referencedFiles);
93+
if (stylesheetFile) {
94+
buildState.addFileReferences(stylesheetFile, result.referencedFiles);
95+
}
96+
}
97+
9298
if (result.warnings.length > 0) {
9399
stylesheetWarnings.push(...result.warnings);
94100
}
@@ -137,14 +143,11 @@ export async function compileLibrary(
137143
const dtsFiles = cached?.dtsFiles ?? new Map<string, string>();
138144
const changedEsmFiles = new Set<string>();
139145
const changedDtsFiles = new Set<string>();
140-
141-
for (const ref of referencedFiles) {
142-
allReferencedFiles.add(toPosixPath(ref));
143-
}
146+
const posixReferencedFiles = new Set(referencedFiles.map(toPosixPath));
144147

145148
if (cached && effectiveModifiedFiles) {
146149
for (const modifiedFile of effectiveModifiedFiles) {
147-
if (allReferencedFiles.has(modifiedFile)) {
150+
if (posixReferencedFiles.has(modifiedFile)) {
148151
continue;
149152
}
150153

@@ -187,7 +190,7 @@ export async function compileLibrary(
187190
dtsFiles,
188191
changedEsmFiles,
189192
changedDtsFiles,
190-
referencedFiles: allReferencedFiles,
193+
referencedFiles: posixReferencedFiles,
191194
cache: {
192195
compilationInstance,
193196
esmFiles,

‎packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import { rm } from 'node:fs/promises';
1010
import { executeLibraryBuilder } from '../../builder';
11+
import { InlineStyleLanguage } from '../../schema';
1112
import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup';
1213

1314
describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => {
@@ -601,5 +602,52 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) =>
601602
},
602603
]);
603604
});
605+
606+
it('should rebuild when a Sass partial imported by an inline component stylesheet is modified in watch mode', async () => {
607+
await harness.writeFiles({
608+
'projects/lib/src/lib/_vars.scss': '$primary-color: rgb(10, 20, 30);',
609+
'projects/lib/src/lib/_spacing.scss': '$pad: 12px;',
610+
'projects/lib/src/lib/lib.component.ts': `
611+
import { Component } from '@angular/core';
612+
613+
@Component({
614+
selector: 'lib-inline-sass',
615+
template: '<span>Inline Sass</span>',
616+
styles: [
617+
'@use "./vars"; :host { color: vars.$primary-color; }',
618+
'@use "./spacing"; :host { padding: spacing.$pad; }',
619+
],
620+
})
621+
export class LibComponent {}
622+
`,
623+
});
624+
625+
harness.useTarget('build', {
626+
...BASE_OPTIONS,
627+
inlineStyleLanguage: InlineStyleLanguage.Scss,
628+
watch: true,
629+
});
630+
631+
await harness.executeWithCases([
632+
async ({ result }) => {
633+
expect(result?.success).toBeTrue();
634+
const content = harness.readFile('dist/lib/fesm2022/lib.mjs');
635+
expect(content).toMatch(/color:\s*(?:#0a141e|rgb\(10,\s*20,\s*30\))/);
636+
expect(content).toMatch(/padding:\s*12px/);
637+
638+
// Modify the Sass partial referenced by the first inline component style
639+
await harness.writeFile(
640+
'projects/lib/src/lib/_vars.scss',
641+
'$primary-color: rgb(200, 100, 50);',
642+
);
643+
},
644+
({ result }) => {
645+
expect(result?.success).toBeTrue();
646+
const content = harness.readFile('dist/lib/fesm2022/lib.mjs');
647+
expect(content).toMatch(/color:\s*(?:#c86432|rgb\(200,\s*100,\s*50\))/);
648+
expect(content).toMatch(/padding:\s*12px/);
649+
},
650+
]);
651+
});
604652
});
605653
});

0 commit comments

Comments
 (0)