Skip to content

Commit 63b6471

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 expand modified files via `BuilderState.expandChangedFiles` 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 594c500 commit 63b6471

5 files changed

Lines changed: 184 additions & 32 deletions

File tree

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

Lines changed: 83 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,82 @@ 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, pruning any previously recorded references for that
125+
* containing file and registering the current references in `watchedCompilationFiles`.
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+
133+
let forwardReferences = this.#referencedByFile.get(posixContainingFile);
134+
if (forwardReferences) {
135+
for (const prevRef of forwardReferences) {
136+
const referencing = this.#referencingFiles.get(prevRef);
137+
if (referencing) {
138+
referencing.delete(posixContainingFile);
139+
if (referencing.size === 0) {
140+
this.#referencingFiles.delete(prevRef);
141+
}
142+
}
143+
}
144+
forwardReferences.clear();
145+
}
146+
147+
for (const file of referencedFiles) {
148+
const posixFile = toPosixPath(file);
149+
this.#watchedCompilationFiles.add(posixFile);
150+
if (posixFile === posixContainingFile) {
151+
continue;
152+
}
153+
154+
if (!forwardReferences) {
155+
forwardReferences = new Set();
156+
this.#referencedByFile.set(posixContainingFile, forwardReferences);
157+
}
158+
forwardReferences.add(posixFile);
159+
160+
let referencing = this.#referencingFiles.get(posixFile);
161+
if (!referencing) {
162+
referencing = new Set();
163+
this.#referencingFiles.set(posixFile, referencing);
164+
}
165+
referencing.add(posixContainingFile);
166+
}
167+
168+
if (forwardReferences?.size === 0) {
169+
this.#referencedByFile.delete(posixContainingFile);
170+
}
171+
}
172+
173+
/**
174+
* Expands a set of changed files with all transitively referencing files
175+
* (e.g., partial `.scss` -> component `.scss` -> component `.ts`).
176+
*
177+
* @param files Initial changed file paths.
178+
* @returns Set of POSIX paths including all transitively referencing files.
179+
*/
180+
expandChangedFiles(files: Iterable<string>): Set<string> {
181+
const expanded = new Set<string>();
182+
for (const file of files) {
183+
expanded.add(toPosixPath(file));
184+
}
185+
186+
for (const file of expanded) {
187+
const referencing = this.#referencingFiles.get(file);
188+
if (referencing) {
189+
for (const refFile of referencing) {
190+
expanded.add(refFile);
191+
}
192+
}
193+
}
194+
195+
return expanded;
196+
}
197+
119198
/**
120199
* Determines whether compilation and bundling need to run for the current build iteration.
121200
* Also records incoming modified files so they are retained if the iteration aborts early.
@@ -235,6 +314,8 @@ export class BuilderState {
235314
this.pendingChangedEsmFiles.clear();
236315
this.pendingChangedDtsFiles.clear();
237316
this.#pendingCompilationFiles.clear();
317+
this.#referencingFiles.clear();
318+
this.#referencedByFile.clear();
238319
this.#watchedCompilationFiles.clear();
239320
}
240321
}

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,44 @@ 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 expanded = state.expandChangedFiles([varsScss]);
88+
expect(expanded).toEqual(new Set([varsScss, compTs, compScss]));
89+
90+
// Subsequent expandChangedFiles calls preserve mappings when not yet overwritten
91+
const expandedAgain = state.expandChangedFiles([varsScss]);
92+
expect(expandedAgain).toEqual(new Set([varsScss, compTs, compScss]));
93+
});
94+
95+
it('should prune stale references when a containing file removes an import', () => {
96+
const state = new BuilderState();
97+
const compTs = '/workspace/projects/my-lib/src/lib/comp.ts';
98+
const oldPartial = '/workspace/projects/my-lib/src/lib/_old.scss';
99+
const newPartial = '/workspace/projects/my-lib/src/lib/_new.scss';
100+
101+
state.addFileReferences(compTs, [compTs, oldPartial]);
102+
expect(state.expandChangedFiles([oldPartial])).toEqual(new Set([oldPartial, compTs]));
103+
104+
// Re-record references for compTs without oldPartial
105+
state.addFileReferences(compTs, [compTs, newPartial]);
106+
expect(state.expandChangedFiles([oldPartial])).toEqual(new Set([oldPartial]));
107+
expect(state.expandChangedFiles([newPartial])).toEqual(new Set([newPartial, compTs]));
108+
});
109+
});
110+
73111
describe('shouldCompileEntryPoints & getCompilationModifiedFiles', () => {
74112
it('should preserve pending compilation files across aborted iterations', () => {
75113
const state = new BuilderState({

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

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -261,29 +261,18 @@ 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+
const invalidatedStyles = stylesheetBundler.invalidate(rawChangedFiles);
266+
const changedFiles = buildState.expandChangedFiles(
267+
invalidatedStyles
268+
? [...rawChangedFiles, ...invalidatedStyles.map(toPosixPath)]
269+
: rawChangedFiles,
270+
);
277271

278-
if (hasStyleChanges) {
279-
if (hasSassChanges) {
272+
for (const posixFile of changedFiles) {
273+
if (/\.(?:scss|sass)$/i.test(posixFile)) {
280274
resetSassWorkerPoolCaches();
281-
}
282-
const invalidatedStyles = stylesheetBundler.invalidate(changedFiles);
283-
if (invalidatedStyles) {
284-
for (const styleFile of invalidatedStyles) {
285-
changedFiles.add(toPosixPath(styleFile));
286-
}
275+
break;
287276
}
288277
}
289278

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

Lines changed: 11 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
*/
@@ -72,7 +72,6 @@ export async function compileLibrary(
7272
await compilationInstance.update?.(effectiveModifiedFiles);
7373
}
7474

75-
const allReferencedFiles = new Set<string>();
7675
const stylesheetWarnings: PartialMessage[] = [];
7776
const stylesheetErrors: PartialMessage[] = [];
7877
const failedFiles = new Set<string>();
@@ -88,7 +87,13 @@ export async function compileLibrary(
8887
? await stylesheetBundler.bundleFile(stylesheetFile)
8988
: await stylesheetBundler.bundleInline(data, containingFile);
9089

91-
result.referencedFiles?.forEach((f) => allReferencedFiles.add(toPosixPath(f)));
90+
if (result.referencedFiles) {
91+
buildState.addFileReferences(containingFile, result.referencedFiles);
92+
if (stylesheetFile) {
93+
buildState.addFileReferences(stylesheetFile, result.referencedFiles);
94+
}
95+
}
96+
9297
if (result.warnings.length > 0) {
9398
stylesheetWarnings.push(...result.warnings);
9499
}
@@ -137,14 +142,11 @@ export async function compileLibrary(
137142
const dtsFiles = cached?.dtsFiles ?? new Map<string, string>();
138143
const changedEsmFiles = new Set<string>();
139144
const changedDtsFiles = new Set<string>();
140-
141-
for (const ref of referencedFiles) {
142-
allReferencedFiles.add(toPosixPath(ref));
143-
}
145+
const posixReferencedFiles = new Set(referencedFiles.map(toPosixPath));
144146

145147
if (cached && effectiveModifiedFiles) {
146148
for (const modifiedFile of effectiveModifiedFiles) {
147-
if (allReferencedFiles.has(modifiedFile)) {
149+
if (posixReferencedFiles.has(modifiedFile)) {
148150
continue;
149151
}
150152

@@ -187,7 +189,7 @@ export async function compileLibrary(
187189
dtsFiles,
188190
changedEsmFiles,
189191
changedDtsFiles,
190-
referencedFiles: allReferencedFiles,
192+
referencedFiles: posixReferencedFiles,
191193
cache: {
192194
compilationInstance,
193195
esmFiles,

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

Lines changed: 42 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,46 @@ 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/lib.component.ts': `
610+
import { Component } from '@angular/core';
611+
612+
@Component({
613+
selector: 'lib-inline-sass',
614+
template: '<span>Inline Sass</span>',
615+
styles: ['@use "./vars"; :host { color: vars.$primary-color; }'],
616+
})
617+
export class LibComponent {}
618+
`,
619+
});
620+
621+
harness.useTarget('build', {
622+
...BASE_OPTIONS,
623+
inlineStyleLanguage: InlineStyleLanguage.Scss,
624+
watch: true,
625+
});
626+
627+
await harness.executeWithCases([
628+
async ({ result }) => {
629+
expect(result?.success).toBeTrue();
630+
const content = harness.readFile('dist/lib/fesm2022/lib.mjs');
631+
expect(content).toMatch(/color:\s*(?:#0a141e|rgb\(10,\s*20,\s*30\))/);
632+
633+
// Modify the Sass partial referenced by the inline component style
634+
await harness.writeFile(
635+
'projects/lib/src/lib/_vars.scss',
636+
'$primary-color: rgb(200, 100, 50);',
637+
);
638+
},
639+
({ result }) => {
640+
expect(result?.success).toBeTrue();
641+
const content = harness.readFile('dist/lib/fesm2022/lib.mjs');
642+
expect(content).toMatch(/color:\s*(?:#c86432|rgb\(200,\s*100,\s*50\))/);
643+
},
644+
]);
645+
});
604646
});
605647
});

0 commit comments

Comments
 (0)