Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 95 additions & 2 deletions packages/angular/build/src/builders/library/builder-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, Set<string>>();
readonly #referencedByFile = new Map<string, Set<string>>();
readonly #watchedCompilationFiles = new Set<string>();
readonly #pendingCompilationFiles = new Set<string>();

Expand Down Expand Up @@ -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<string>): 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<string>): 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<string>): Set<string> {
const affected = new Set<string>();
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.
Expand Down Expand Up @@ -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();
}
}
52 changes: 52 additions & 0 deletions packages/angular/build/src/builders/library/builder-state_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
27 changes: 6 additions & 21 deletions packages/angular/build/src/builders/library/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,29 +261,14 @@ async function* runWatchLoop(
console.clear();
}

const changedFiles = new Set<string>();
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;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -70,9 +70,9 @@ export async function compileLibrary(

if (effectiveModifiedFiles && effectiveModifiedFiles.size > 0) {
await compilationInstance.update?.(effectiveModifiedFiles);
buildState.clearFileReferences(effectiveModifiedFiles);
}

const allReferencedFiles = new Set<string>();
const stylesheetWarnings: PartialMessage[] = [];
const stylesheetErrors: PartialMessage[] = [];
const failedFiles = new Set<string>();
Expand All @@ -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);
Comment thread
alan-agius4 marked this conversation as resolved.
if (stylesheetFile) {
buildState.addFileReferences(stylesheetFile, result.referencedFiles);
}
}

if (result.warnings.length > 0) {
stylesheetWarnings.push(...result.warnings);
}
Expand Down Expand Up @@ -137,14 +143,11 @@ export async function compileLibrary(
const dtsFiles = cached?.dtsFiles ?? new Map<string, string>();
const changedEsmFiles = new Set<string>();
const changedDtsFiles = new Set<string>();

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;
}

Expand Down Expand Up @@ -187,7 +190,7 @@ export async function compileLibrary(
dtsFiles,
changedEsmFiles,
changedDtsFiles,
referencedFiles: allReferencedFiles,
referencedFiles: posixReferencedFiles,
cache: {
compilationInstance,
esmFiles,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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: '<span>Inline Sass</span>',
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/);
},
]);
});
});
});
Loading