diff --git a/packages/angular/build/src/builders/library/builder-state.ts b/packages/angular/build/src/builders/library/builder-state.ts index c20ef26ea7c5..81b4e21e778d 100644 --- a/packages/angular/build/src/builders/library/builder-state.ts +++ b/packages/angular/build/src/builders/library/builder-state.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import { toPosixPath } from '../../utils/path'; import { ExecutionResult } from './execution-result'; import type { BundleResult, @@ -15,10 +16,12 @@ import type { } from './types'; /** - * Encapsulates incremental compilation state, watched compilation file tracking, - * and execution results across watch-mode rebuilds. + * Encapsulates incremental compilation state, transitive resource reference tracking, + * watched compilation file tracking, and execution results across watch-mode rebuilds. */ export class BuilderState { + readonly #referencingFiles = new Map>(); + readonly #referencedByFile = new Map>(); readonly #watchedCompilationFiles = new Set(); readonly #pendingCompilationFiles = new Set(); @@ -116,6 +119,94 @@ export class BuilderState { return this.#executionResult; } + /** + * Records transitive resource dependencies (such as Sass partials) for a containing + * component or stylesheet file and registers them in `watchedCompilationFiles`. + * Multiple calls for the same containing file accumulate references additively. + * + * @param containingFile Path of the referencing component or stylesheet file. + * @param referencedFiles Paths of the referenced files. + */ + addFileReferences(containingFile: string, referencedFiles: Iterable): void { + const posixContainingFile = toPosixPath(containingFile); + let forwardReferences = this.#referencedByFile.get(posixContainingFile); + + for (const file of referencedFiles) { + const posixFile = toPosixPath(file); + this.#watchedCompilationFiles.add(posixFile); + if (posixFile === posixContainingFile) { + continue; + } + + if (!forwardReferences) { + forwardReferences = new Set(); + this.#referencedByFile.set(posixContainingFile, forwardReferences); + } + forwardReferences.add(posixFile); + + let referencing = this.#referencingFiles.get(posixFile); + if (!referencing) { + referencing = new Set(); + this.#referencingFiles.set(posixFile, referencing); + } + referencing.add(posixContainingFile); + } + } + + /** + * Clears previously recorded outgoing resource references for the given containing files + * before they are recompiled. + * + * @param containingFiles Paths of the containing or stylesheet files being recompiled. + */ + clearFileReferences(containingFiles: Iterable): void { + for (const containingFile of containingFiles) { + const posixContainingFile = toPosixPath(containingFile); + const forwardReferences = this.#referencedByFile.get(posixContainingFile); + if (!forwardReferences) { + continue; + } + + for (const prevRef of forwardReferences) { + const referencing = this.#referencingFiles.get(prevRef); + if (referencing) { + referencing.delete(posixContainingFile); + if (referencing.size === 0) { + this.#referencingFiles.delete(prevRef); + } + } + } + + this.#referencedByFile.delete(posixContainingFile); + } + } + + /** + * Returns the set of all files affected by the given changed files, including the + * changed files themselves and all transitively referencing files + * (e.g., partial `.scss` -> component `.scss` -> component `.ts`). + * + * @param files Initial changed file paths. + * @returns Set of POSIX paths including all transitively referencing files. + */ + getAffectedFiles(files: Iterable): Set { + const affected = new Set(); + for (const file of files) { + affected.add(toPosixPath(file)); + } + + for (const file of affected) { + const referencing = this.#referencingFiles.get(file); + if (referencing) { + for (const refFile of referencing) { + affected.add(refFile); + } + } + } + + return affected; + } + /** * Determines whether compilation and bundling need to run for the current build iteration. * Also records incoming modified files so they are retained if the iteration aborts early. @@ -235,6 +326,8 @@ export class BuilderState { this.pendingChangedEsmFiles.clear(); this.pendingChangedDtsFiles.clear(); this.#pendingCompilationFiles.clear(); + this.#referencingFiles.clear(); + this.#referencedByFile.clear(); this.#watchedCompilationFiles.clear(); } } diff --git a/packages/angular/build/src/builders/library/builder-state_spec.ts b/packages/angular/build/src/builders/library/builder-state_spec.ts index 9cf744d288bc..ae84bbaa7331 100644 --- a/packages/angular/build/src/builders/library/builder-state_spec.ts +++ b/packages/angular/build/src/builders/library/builder-state_spec.ts @@ -70,6 +70,58 @@ describe('BuilderState', () => { }); }); + describe('file reference tracking', () => { + it('should expand transitive multi-hop references and ignore self-references', () => { + const state = new BuilderState(); + const compTs = '/workspace/projects/my-lib/src/lib/comp.ts'; + const compScss = '/workspace/projects/my-lib/src/lib/comp.scss'; + const varsScss = '/workspace/projects/my-lib/src/lib/_vars.scss'; + + state.addFileReferences(compTs, [compTs, compScss, varsScss]); + state.addFileReferences(compScss, [compScss, varsScss]); + + expect(state.watchedCompilationFiles.has(compTs)).toBeTrue(); + expect(state.watchedCompilationFiles.has(compScss)).toBeTrue(); + expect(state.watchedCompilationFiles.has(varsScss)).toBeTrue(); + + const affected = state.getAffectedFiles([varsScss]); + expect(affected).toEqual(new Set([varsScss, compTs, compScss])); + + // Subsequent getAffectedFiles calls preserve mappings when not yet overwritten + const affectedAgain = state.getAffectedFiles([varsScss]); + expect(affectedAgain).toEqual(new Set([varsScss, compTs, compScss])); + }); + + it('should accumulate references across multiple stylesheets for the same containing file', () => { + const state = new BuilderState(); + const compTs = '/workspace/projects/my-lib/src/lib/comp.ts'; + const firstPartial = '/workspace/projects/my-lib/src/lib/_first.scss'; + const secondPartial = '/workspace/projects/my-lib/src/lib/_second.scss'; + + state.addFileReferences(compTs, [compTs, firstPartial]); + state.addFileReferences(compTs, [compTs, secondPartial]); + + expect(state.getAffectedFiles([firstPartial])).toEqual(new Set([firstPartial, compTs])); + expect(state.getAffectedFiles([secondPartial])).toEqual(new Set([secondPartial, compTs])); + }); + + it('should prune stale references when a containing file is cleared and re-recorded', () => { + const state = new BuilderState(); + const compTs = '/workspace/projects/my-lib/src/lib/comp.ts'; + const oldPartial = '/workspace/projects/my-lib/src/lib/_old.scss'; + const newPartial = '/workspace/projects/my-lib/src/lib/_new.scss'; + + state.addFileReferences(compTs, [compTs, oldPartial]); + expect(state.getAffectedFiles([oldPartial])).toEqual(new Set([oldPartial, compTs])); + + // Clear and re-record references for compTs without oldPartial + state.clearFileReferences([compTs]); + state.addFileReferences(compTs, [compTs, newPartial]); + expect(state.getAffectedFiles([oldPartial])).toEqual(new Set([oldPartial])); + expect(state.getAffectedFiles([newPartial])).toEqual(new Set([newPartial, compTs])); + }); + }); + describe('shouldCompileEntryPoints & getCompilationModifiedFiles', () => { it('should preserve pending compilation files across aborted iterations', () => { const state = new BuilderState({ diff --git a/packages/angular/build/src/builders/library/builder.ts b/packages/angular/build/src/builders/library/builder.ts index fa8e717c9776..42be7f99b211 100644 --- a/packages/angular/build/src/builders/library/builder.ts +++ b/packages/angular/build/src/builders/library/builder.ts @@ -261,29 +261,14 @@ async function* runWatchLoop( console.clear(); } - const changedFiles = new Set(); - let hasStyleChanges = false; - let hasSassChanges = false; - for (const file of changes.all) { - const posixFile = toPosixPath(file); - changedFiles.add(posixFile); - if (/\.(?:scss|sass)$/i.test(posixFile)) { - hasStyleChanges = true; - hasSassChanges = true; - } else if (/\.(?:less|css)$/i.test(posixFile)) { - hasStyleChanges = true; - } - } + const rawChangedFiles = changes.all.map(toPosixPath); + stylesheetBundler.invalidate(rawChangedFiles); + const changedFiles = buildState.getAffectedFiles(rawChangedFiles); - if (hasStyleChanges) { - if (hasSassChanges) { + for (const posixFile of changedFiles) { + if (/\.(?:scss|sass)$/i.test(posixFile)) { resetSassWorkerPoolCaches(); - } - const invalidatedStyles = stylesheetBundler.invalidate(changedFiles); - if (invalidatedStyles) { - for (const styleFile of invalidatedStyles) { - changedFiles.add(toPosixPath(styleFile)); - } + break; } } diff --git a/packages/angular/build/src/builders/library/pipeline/compilation.ts b/packages/angular/build/src/builders/library/pipeline/compilation.ts index 38b28f442df6..1c026c5e9fec 100644 --- a/packages/angular/build/src/builders/library/pipeline/compilation.ts +++ b/packages/angular/build/src/builders/library/pipeline/compilation.ts @@ -26,7 +26,7 @@ const EMITTED_EXTENSIONS = ['.js', '.mjs', '.cjs', '.d.ts', '.d.mts', '.d.cts']; * * @param options The normalized library builder options with POSIX paths. * @param stylesheetBundler The component stylesheet bundler instance. - * @param buildState The build state tracking compilation caches and diagnostics. + * @param buildState The build state tracking compilation caches, file references, and diagnostics. * @param modifiedFiles Optional set of modified POSIX file paths for incremental compilation. * @returns A promise resolving to the compilation output containing emitted files and cache state. */ @@ -70,9 +70,9 @@ export async function compileLibrary( if (effectiveModifiedFiles && effectiveModifiedFiles.size > 0) { await compilationInstance.update?.(effectiveModifiedFiles); + buildState.clearFileReferences(effectiveModifiedFiles); } - const allReferencedFiles = new Set(); const stylesheetWarnings: PartialMessage[] = []; const stylesheetErrors: PartialMessage[] = []; const failedFiles = new Set(); @@ -88,7 +88,13 @@ export async function compileLibrary( ? await stylesheetBundler.bundleFile(stylesheetFile) : await stylesheetBundler.bundleInline(data, containingFile); - result.referencedFiles?.forEach((f) => allReferencedFiles.add(toPosixPath(f))); + if (result.referencedFiles) { + buildState.addFileReferences(containingFile, result.referencedFiles); + if (stylesheetFile) { + buildState.addFileReferences(stylesheetFile, result.referencedFiles); + } + } + if (result.warnings.length > 0) { stylesheetWarnings.push(...result.warnings); } @@ -137,14 +143,11 @@ export async function compileLibrary( const dtsFiles = cached?.dtsFiles ?? new Map(); const changedEsmFiles = new Set(); const changedDtsFiles = new Set(); - - for (const ref of referencedFiles) { - allReferencedFiles.add(toPosixPath(ref)); - } + const posixReferencedFiles = new Set(referencedFiles.map(toPosixPath)); if (cached && effectiveModifiedFiles) { for (const modifiedFile of effectiveModifiedFiles) { - if (allReferencedFiles.has(modifiedFile)) { + if (posixReferencedFiles.has(modifiedFile)) { continue; } @@ -187,7 +190,7 @@ export async function compileLibrary( dtsFiles, changedEsmFiles, changedDtsFiles, - referencedFiles: allReferencedFiles, + referencedFiles: posixReferencedFiles, cache: { compilationInstance, esmFiles, diff --git a/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts b/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts index 415f24d269c0..e9b7e57ddd9f 100644 --- a/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts +++ b/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts @@ -8,6 +8,7 @@ import { rm } from 'node:fs/promises'; import { executeLibraryBuilder } from '../../builder'; +import { InlineStyleLanguage } from '../../schema'; import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { @@ -601,5 +602,52 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => }, ]); }); + + it('should rebuild when a Sass partial imported by an inline component stylesheet is modified in watch mode', async () => { + await harness.writeFiles({ + 'projects/lib/src/lib/_vars.scss': '$primary-color: rgb(10, 20, 30);', + 'projects/lib/src/lib/_spacing.scss': '$pad: 12px;', + 'projects/lib/src/lib/lib.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-inline-sass', + template: 'Inline Sass', + styles: [ + '@use "./vars"; :host { color: vars.$primary-color; }', + '@use "./spacing"; :host { padding: spacing.$pad; }', + ], + }) + export class LibComponent {} + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + inlineStyleLanguage: InlineStyleLanguage.Scss, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + const content = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(content).toMatch(/color:\s*(?:#0a141e|rgb\(10,\s*20,\s*30\))/); + expect(content).toMatch(/padding:\s*12px/); + + // Modify the Sass partial referenced by the first inline component style + await harness.writeFile( + 'projects/lib/src/lib/_vars.scss', + '$primary-color: rgb(200, 100, 50);', + ); + }, + ({ result }) => { + expect(result?.success).toBeTrue(); + const content = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(content).toMatch(/color:\s*(?:#c86432|rgb\(200,\s*100,\s*50\))/); + expect(content).toMatch(/padding:\s*12px/); + }, + ]); + }); }); });