Skip to content

Commit c6b5165

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 f186fbb commit c6b5165

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.
@@ -233,6 +312,8 @@ export class BuilderState {
233312
this.pendingChangedEsmFiles.clear();
234313
this.pendingChangedDtsFiles.clear();
235314
this.#pendingCompilationFiles.clear();
315+
this.#referencingFiles.clear();
316+
this.#referencedByFile.clear();
236317
this.#watchedCompilationFiles.clear();
237318
}
238319
}

‎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
*/
@@ -68,7 +68,6 @@ export async function compileLibrary(
6868
await compilationInstance.update?.(effectiveModifiedFiles);
6969
}
7070

71-
const allReferencedFiles = new Set<string>();
7271
const stylesheetWarnings: PartialMessage[] = [];
7372
const stylesheetErrors: PartialMessage[] = [];
7473
const failedFiles = new Set<string>();
@@ -84,7 +83,13 @@ export async function compileLibrary(
8483
? await stylesheetBundler.bundleFile(stylesheetFile)
8584
: await stylesheetBundler.bundleInline(data, containingFile);
8685

87-
result.referencedFiles?.forEach((f) => allReferencedFiles.add(toPosixPath(f)));
86+
if (result.referencedFiles) {
87+
buildState.addFileReferences(containingFile, result.referencedFiles);
88+
if (stylesheetFile) {
89+
buildState.addFileReferences(stylesheetFile, result.referencedFiles);
90+
}
91+
}
92+
8893
if (result.warnings.length > 0) {
8994
stylesheetWarnings.push(...result.warnings);
9095
}
@@ -133,14 +138,11 @@ export async function compileLibrary(
133138
const dtsFiles = cached?.dtsFiles ?? new Map<string, string>();
134139
const changedEsmFiles = new Set<string>();
135140
const changedDtsFiles = new Set<string>();
136-
137-
for (const ref of referencedFiles) {
138-
allReferencedFiles.add(toPosixPath(ref));
139-
}
141+
const posixReferencedFiles = new Set(referencedFiles.map(toPosixPath));
140142

141143
if (cached && effectiveModifiedFiles) {
142144
for (const modifiedFile of effectiveModifiedFiles) {
143-
if (allReferencedFiles.has(modifiedFile)) {
145+
if (posixReferencedFiles.has(modifiedFile)) {
144146
continue;
145147
}
146148

@@ -183,7 +185,7 @@ export async function compileLibrary(
183185
dtsFiles,
184186
changedEsmFiles,
185187
changedDtsFiles,
186-
referencedFiles: allReferencedFiles,
188+
referencedFiles: posixReferencedFiles,
187189
cache: {
188190
compilationInstance,
189191
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)