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
240 changes: 240 additions & 0 deletions packages/angular/build/src/builders/library/builder-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import { ExecutionResult } from './execution-result';
import type {
BundleResult,
LibraryCompilationOutput,
NormalizedLibraryOptions,
SingleProgramCache,
} from './types';

/**
* Encapsulates incremental compilation state, watched compilation file tracking,
* and execution results across watch-mode rebuilds.
*/
export class BuilderState {
readonly #watchedCompilationFiles = new Set<string>();
readonly #pendingCompilationFiles = new Set<string>();

#executionResult = new ExecutionResult();
#singleProgramCache?: SingleProgramCache;
#compilationMode?: 'partial' | 'full';

/** Map of entry point names to their previous bundle results. */
readonly previousBundleResults = new Map<string, BundleResult>();

/** Set of changed POSIX ESM file paths pending bundling. */
readonly pendingChangedEsmFiles = new Set<string>();

/** Set of changed POSIX TypeScript declaration file paths pending bundling. */
readonly pendingChangedDtsFiles = new Set<string>();

/** Whether the previous build iteration encountered a compilation or diagnostic error. */
hasCompilationError = false;

/** Whether package.json and secondary entry point manifests have been emitted. */
hasEmittedManifests = false;

/** Whether the last emitted root package.json included a tslib dependency. */
hasEmittedTslib = false;

/** Whether static assets have been emitted in a prior iteration. */
hasEmittedAssets = false;

/** Whether entry points in package.json changed since the last compilation. */
hasEntryPointsChanges = false;

constructor(
options?: Partial<
Pick<NormalizedLibraryOptions, 'tsConfigPath' | 'packageJsonPath' | 'entryPoints'>
>,
) {
if (options?.tsConfigPath) {
this.#watchedCompilationFiles.add(options.tsConfigPath);
}
if (options?.packageJsonPath) {
this.#watchedCompilationFiles.add(options.packageJsonPath);
}
if (options?.entryPoints) {
for (const entryPoint of options.entryPoints.values()) {
this.#watchedCompilationFiles.add(entryPoint.entryFilePath);
}
}
}

/** Cached unified TypeScript and Angular compilation state. */
get singleProgramCache(): SingleProgramCache | undefined {
return this.#singleProgramCache;
}

/** Effective compilation mode resolved from builder options and tsconfig. */
get compilationMode(): 'partial' | 'full' | undefined {
return this.#compilationMode;
}

/** Whether any active entry point bundle imports `tslib`. */
get hasTslibImport(): boolean {
for (const result of this.previousBundleResults.values()) {
if (result.hasTslibImport) {
return true;
}
}

return false;
}

/**
* Set of POSIX file paths tracked for compilation and configuration
* (including `tsconfig.json`, `package.json`, entry points, and referenced source,
* template, and stylesheet files).
*/
get watchedCompilationFiles(): Set<string> {
return this.#watchedCompilationFiles;
}

/**
* Execution result holding errors, warnings, and logs for the current build iteration.
*/
get executionResult(): ExecutionResult {
return this.#executionResult;
}

/**
* Resets the execution result for a new build iteration.
*
* @returns The fresh `ExecutionResult` instance for the current build iteration.
*/
resetExecutionResult(): ExecutionResult {
this.#executionResult = new ExecutionResult();

return this.#executionResult;
}

/**
* 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.
*
* @param packageJsonPath Normalized POSIX path to the library's `package.json`.
* @param modifiedFiles Optional set of modified POSIX file paths in watch mode.
* @returns `true` if entry points should be compiled; otherwise `false`.
*/
shouldCompileEntryPoints(packageJsonPath: string, modifiedFiles?: ReadonlySet<string>): boolean {
if (modifiedFiles) {
for (const file of modifiedFiles) {
if (file !== packageJsonPath) {
this.#pendingCompilationFiles.add(file);
}
}
}

if (
!modifiedFiles ||
!this.#singleProgramCache ||
this.hasCompilationError ||
this.hasEntryPointsChanges ||
this.pendingChangedEsmFiles.size > 0 ||
this.pendingChangedDtsFiles.size > 0
) {
return true;
}

for (const file of this.#pendingCompilationFiles) {
if (this.#watchedCompilationFiles.has(file)) {
return true;
}
}

this.#pendingCompilationFiles.clear();

return false;
}
Comment thread
alan-agius4 marked this conversation as resolved.

/**
* Returns the effective set of modified POSIX file paths for compilation,
* merging any pending compilation files from earlier aborted iterations.
*
* @param modifiedFiles Optional set of modified POSIX file paths from the current iteration.
* @returns Merged set of modified POSIX file paths, or `undefined` for a full compilation.
*/
getCompilationModifiedFiles(modifiedFiles?: ReadonlySet<string>): Set<string> | undefined {
if (!modifiedFiles) {
this.#pendingCompilationFiles.clear();

return undefined;
}

const files = new Set(modifiedFiles);
for (const file of this.#pendingCompilationFiles) {
files.add(file);
}

return files;
}

/**
* Records compilation output into the build state, updating the program cache,
* watched compilation files, and pending changed ESM/DTS file sets.
*
* @param output The compilation output produced by `compileLibrary`.
*/
recordCompilationOutput(output: LibraryCompilationOutput): void {
this.#pendingCompilationFiles.clear();
this.#singleProgramCache = output.cache;
if (this.#compilationMode !== output.compilationMode) {
this.#compilationMode = output.compilationMode;
this.hasEmittedManifests = false;
}

for (const file of output.referencedFiles) {
this.#watchedCompilationFiles.add(file);
}
for (const file of output.changedEsmFiles) {
this.pendingChangedEsmFiles.add(file);
}
for (const file of output.changedDtsFiles) {
this.pendingChangedDtsFiles.add(file);
}
}

/**
* Records active bundle results across entry points after compilation and bundling succeed,
* clearing pending compilation/bundle flags.
*
* @param bundleResults Newly bundled results keyed by entry point name.
*/
recordBundleOutput(bundleResults: ReadonlyMap<string, BundleResult>): void {
this.hasCompilationError = false;
this.hasEntryPointsChanges = false;
this.pendingChangedEsmFiles.clear();
this.pendingChangedDtsFiles.clear();

for (const [name, bundleResult] of bundleResults) {
this.previousBundleResults.set(name, bundleResult);
}
}

/**
* Disposes active compilation resources and clears cached state.
*/
async dispose(): Promise<void> {
await this.#singleProgramCache?.compilationInstance.close?.();
this.#singleProgramCache = undefined;
this.#compilationMode = undefined;
this.hasEmittedTslib = false;
this.hasEmittedAssets = false;
this.hasCompilationError = false;
this.hasEmittedManifests = false;
this.hasEntryPointsChanges = false;
this.previousBundleResults.clear();
this.pendingChangedEsmFiles.clear();
this.pendingChangedDtsFiles.clear();
this.#pendingCompilationFiles.clear();
this.#watchedCompilationFiles.clear();
}
}
155 changes: 155 additions & 0 deletions packages/angular/build/src/builders/library/builder-state_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import { BuilderState } from './builder-state';
import type { LibraryCompilationOutput, SingleProgramCache } from './types';

describe('BuilderState', () => {
const packageJsonPath = '/workspace/projects/my-lib/package.json';
const tsConfigPath = '/workspace/projects/my-lib/tsconfig.lib.json';
const entryFilePath = '/workspace/projects/my-lib/src/public-api.ts';

function createMockCache(closeSpy?: jasmine.Spy): SingleProgramCache {
return {
compilationInstance: {
close: closeSpy,
} as unknown as SingleProgramCache['compilationInstance'],
esmFiles: new Map(),
dtsFiles: new Map(),
failedFiles: new Set(),
};
}

function createMockCompilationOutput(
overrides: Partial<LibraryCompilationOutput> = {},
): LibraryCompilationOutput {
return {
esmFiles: new Map(),
dtsFiles: new Map(),
changedEsmFiles: new Set(),
changedDtsFiles: new Set(),
referencedFiles: new Set(),
cache: createMockCache(),
hasStylesheetErrors: false,
diagnosePromise: Promise.resolve(),
declarationMap: false,
compilationMode: 'partial',
...overrides,
};
}

describe('ExecutionResult', () => {
it('should track logs, warnings, and errors and reset per iteration', () => {
const state = new BuilderState();
expect(state.executionResult.hasErrors).toBeFalse();
expect(state.executionResult.output).toEqual({ success: true });

state.executionResult.addLog('Bundling my-lib');
state.executionResult.addWarning('Unused import');
state.executionResult.addWarnings(['Deprecated API']);
state.executionResult.addError('Type error');
state.executionResult.addErrors(['Syntax error']);

expect(state.executionResult.logs).toEqual(['Bundling my-lib']);
expect(state.executionResult.warnings).toEqual(['Unused import', 'Deprecated API']);
expect(state.executionResult.errors).toEqual(['Type error', 'Syntax error']);
expect(state.executionResult.hasErrors).toBeTrue();
expect(state.executionResult.output).toEqual({ success: false });

const freshResult = state.resetExecutionResult();
expect(freshResult).toBe(state.executionResult);
expect(freshResult.hasErrors).toBeFalse();
expect(freshResult.logs).toEqual([]);
expect(freshResult.warnings).toEqual([]);
expect(freshResult.errors).toEqual([]);
});
});

describe('shouldCompileEntryPoints & getCompilationModifiedFiles', () => {
it('should preserve pending compilation files across aborted iterations', () => {
const state = new BuilderState({
packageJsonPath,
tsConfigPath,
entryPoints: new Map([
[
'.',
{
subpath: '.',
name: '.',
displayName: 'my-lib',
bundleName: 'my-lib',
entryFilePath,
isPrimary: true,
},
],
]),
});

state.recordCompilationOutput(
createMockCompilationOutput({
referencedFiles: new Set([entryFilePath]),
}),
);

// Iteration 1: entryFilePath changes, but build aborts before recordCompilationOutput
expect(
state.shouldCompileEntryPoints(packageJsonPath, new Set([entryFilePath, packageJsonPath])),
).toBeTrue();

// Iteration 2: only package.json changes on recovery
expect(
state.shouldCompileEntryPoints(packageJsonPath, new Set([packageJsonPath])),
).toBeTrue();
expect(state.getCompilationModifiedFiles(new Set([packageJsonPath]))).toEqual(
new Set([packageJsonPath, entryFilePath]),
);

// Once compilation output is recorded, pending compilation files are cleared
state.recordCompilationOutput(createMockCompilationOutput());
expect(
state.shouldCompileEntryPoints(packageJsonPath, new Set([packageJsonPath])),
).toBeFalse();
});

it('should invalidate hasEmittedManifests when compilationMode changes', () => {
const state = new BuilderState();
state.hasEmittedManifests = true;
state.recordCompilationOutput(createMockCompilationOutput({ compilationMode: 'partial' }));
expect(state.hasEmittedManifests).toBeFalse();

state.hasEmittedManifests = true;
state.recordCompilationOutput(createMockCompilationOutput({ compilationMode: 'partial' }));
expect(state.hasEmittedManifests).toBeTrue();

state.recordCompilationOutput(createMockCompilationOutput({ compilationMode: 'full' }));
expect(state.hasEmittedManifests).toBeFalse();
});
});

describe('dispose', () => {
it('should close compilationInstance and clear cached state on dispose', async () => {
const closeSpy = jasmine.createSpy('close').and.resolveTo();
const state = new BuilderState({ packageJsonPath });
state.recordCompilationOutput(
createMockCompilationOutput({
cache: createMockCache(closeSpy),
changedEsmFiles: new Set(['/a.js']),
changedDtsFiles: new Set(['/a.d.ts']),
}),
);

await state.dispose();

expect(closeSpy).toHaveBeenCalledTimes(1);
expect(state.singleProgramCache).toBeUndefined();
expect(state.pendingChangedEsmFiles).toHaveSize(0);
expect(state.pendingChangedDtsFiles).toHaveSize(0);
expect(state.watchedCompilationFiles).toHaveSize(0);
});
});
});
Loading
Loading