From 365b6518e22afe333c387ef98f5d827a2972f73f Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:03:30 +0000 Subject: [PATCH] fix(@angular/build): support incremental asset updates and output cleanup in library watch mode Ensure the library builder properly handles created, modified, and deleted assets during watch mode rebuilds, and removes stale bundle, sourcemap, and manifest output files from disk when entry points or exports are removed. --- .../src/builders/library/builder-state.ts | 176 +++++++++++++- .../builders/library/builder-state_spec.ts | 146 +++++++++++- .../build/src/builders/library/builder.ts | 25 +- .../src/builders/library/pipeline/assets.ts | 105 +++++++-- .../builders/library/pipeline/build-action.ts | 50 ++-- .../src/builders/library/pipeline/bundler.ts | 94 +++++--- .../library/pipeline/entry-points_spec.ts | 2 + .../tests/behavior/watch-cleanup_spec.ts | 216 ++++++++++++++++++ .../build/src/builders/library/types.ts | 10 +- 9 files changed, 713 insertions(+), 111 deletions(-) create mode 100644 packages/angular/build/src/builders/library/tests/behavior/watch-cleanup_spec.ts diff --git a/packages/angular/build/src/builders/library/builder-state.ts b/packages/angular/build/src/builders/library/builder-state.ts index 81b4e21e778d..8d060befb0ec 100644 --- a/packages/angular/build/src/builders/library/builder-state.ts +++ b/packages/angular/build/src/builders/library/builder-state.ts @@ -8,6 +8,13 @@ import { toPosixPath } from '../../utils/path'; import { ExecutionResult } from './execution-result'; +import { + type AssetMatcher, + type CollectedAssets, + collectAssetsToEmit, + createAssetMatchers, +} from './pipeline/assets'; +import type { OutputFile } from './pipeline/utils'; import type { BundleResult, LibraryCompilationOutput, @@ -17,17 +24,29 @@ import type { /** * Encapsulates incremental compilation state, transitive resource reference tracking, - * watched compilation file tracking, and execution results across watch-mode rebuilds. + * asset matchers, and emitted output file tracking across watch-mode rebuilds. */ export class BuilderState { readonly #referencingFiles = new Map>(); readonly #referencedByFile = new Map>(); readonly #watchedCompilationFiles = new Set(); readonly #pendingCompilationFiles = new Set(); + readonly #pendingAssetFiles = new Set(); + readonly #assetMatchers?: readonly AssetMatcher[]; + + #emittedBundleFiles = new Set(); + #emittedManifestFiles = new Set(); + readonly #emittedAssets = new Map>(); + #hasEmittedAssets = false; #executionResult = new ExecutionResult(); + #nextBundleFiles?: Set; + #nextManifestFiles?: Set; + readonly #filesToDelete = new Set(); + #singleProgramCache?: SingleProgramCache; #compilationMode?: 'partial' | 'full'; + #hasEmittedTslib = false; /** Map of entry point names to their previous bundle results. */ readonly previousBundleResults = new Map(); @@ -44,18 +63,12 @@ export class BuilderState { /** 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 + Pick >, ) { if (options?.tsConfigPath) { @@ -69,6 +82,8 @@ export class BuilderState { this.#watchedCompilationFiles.add(entryPoint.entryFilePath); } } + + this.#assetMatchers = options?.assets ? createAssetMatchers(options.assets) : undefined; } /** Cached unified TypeScript and Angular compilation state. */ @@ -81,6 +96,11 @@ export class BuilderState { return this.#compilationMode; } + /** Whether the last emitted root package.json included a tslib dependency. */ + get hasEmittedTslib(): boolean { + return this.#hasEmittedTslib; + } + /** Whether any active entry point bundle imports `tslib`. */ get hasTslibImport(): boolean { for (const result of this.previousBundleResults.values()) { @@ -109,12 +129,15 @@ export class BuilderState { } /** - * Resets the execution result for a new build iteration. + * Resets the execution result and staged output state for a new build iteration. * * @returns The fresh `ExecutionResult` instance for the current build iteration. */ resetExecutionResult(): ExecutionResult { this.#executionResult = new ExecutionResult(); + this.#nextBundleFiles = undefined; + this.#nextManifestFiles = undefined; + this.#filesToDelete.clear(); return this.#executionResult; } @@ -221,6 +244,9 @@ export class BuilderState { if (file !== packageJsonPath) { this.#pendingCompilationFiles.add(file); } + if (this.#hasEmittedAssets) { + this.#pendingAssetFiles.add(file); + } } } @@ -295,11 +321,13 @@ export class BuilderState { /** * Records active bundle results across entry points after compilation and bundling succeed, - * clearing pending compilation/bundle flags. + * clearing pending compilation/bundle flags and queuing any previously emitted bundle files + * that are no longer present for deletion. * * @param bundleResults Newly bundled results keyed by entry point name. */ recordBundleOutput(bundleResults: ReadonlyMap): void { + const hadEntryPointsChanges = this.hasEntryPointsChanges; this.hasCompilationError = false; this.hasEntryPointsChanges = false; this.pendingChangedEsmFiles.clear(); @@ -308,6 +336,123 @@ export class BuilderState { for (const [name, bundleResult] of bundleResults) { this.previousBundleResults.set(name, bundleResult); } + + if (bundleResults.size === 0 && !hadEntryPointsChanges && this.#emittedBundleFiles.size > 0) { + return; + } + + const nextBundleFiles = new Set(); + for (const result of this.previousBundleResults.values()) { + for (const file of result.esmOutputFiles) { + nextBundleFiles.add(file); + } + for (const file of result.dtsOutputFiles) { + nextBundleFiles.add(file); + } + } + + for (const prevFile of this.#emittedBundleFiles) { + if (!nextBundleFiles.has(prevFile)) { + this.#filesToDelete.add(prevFile); + } + } + + this.#nextBundleFiles = nextBundleFiles; + } + + /** + * Computes the active manifest output file set and queues any previously emitted + * manifest files that are no longer present for deletion. + * + * @param manifestFiles Manifest output files generated in this iteration. + */ + recordManifestOutput(manifestFiles: readonly OutputFile[]): void { + const nextManifestFiles = new Set(manifestFiles.map((file) => file.path)); + for (const prevFile of this.#emittedManifestFiles) { + if (!nextManifestFiles.has(prevFile)) { + this.#filesToDelete.add(prevFile); + } + } + + this.#nextManifestFiles = nextManifestFiles; + } + + /** + * Resolves and collects configured library assets to be emitted to or deleted from disk. + * + * @param options Normalized library options containing `assets` and `workspaceRoot`. + * @param modifiedFiles Optional set of modified POSIX file paths in watch mode. + * @returns The collected asset files to emit and delete. + */ + collectAssets( + options: Pick, + modifiedFiles?: ReadonlySet, + ): Promise { + let effectiveModifiedFiles: ReadonlySet | undefined; + if (this.#hasEmittedAssets && modifiedFiles) { + for (const file of modifiedFiles) { + this.#pendingAssetFiles.add(file); + } + effectiveModifiedFiles = this.#pendingAssetFiles; + } + + return collectAssetsToEmit( + options.assets, + options.workspaceRoot, + this.#emittedAssets, + effectiveModifiedFiles, + this.#assetMatchers, + ); + } + + /** + * Records emitted bundle, manifest, and asset output files after disk emission, + * queuing any stale or deleted output paths for removal from disk. + * + * @param collectedAssets Collected asset files emitted and deleted in this iteration. + * @returns Read-only set of relative POSIX output paths queued for deletion from disk. + */ + recordEmittedOutputs(collectedAssets: CollectedAssets): ReadonlySet { + const { filesToDelete: toDelete, filesToEmit: toEmit } = collectedAssets; + + for (const deletedAsset of toDelete) { + this.#emittedAssets.delete(deletedAsset.source); + } + + for (const emittedAsset of toEmit) { + let outputs = this.#emittedAssets.get(emittedAsset.source); + if (!outputs) { + outputs = new Set(); + this.#emittedAssets.set(emittedAsset.source, outputs); + } + outputs.add(emittedAsset.path); + } + + if (toDelete.length) { + const activeAssetOutputs = new Set(); + for (const outputs of this.#emittedAssets.values()) { + for (const output of outputs) { + activeAssetOutputs.add(output); + } + } + + for (const deletedAsset of toDelete) { + if (!activeAssetOutputs.has(deletedAsset.path)) { + this.#filesToDelete.add(deletedAsset.path); + } + } + } + + this.#emittedBundleFiles = this.#nextBundleFiles ?? this.#emittedBundleFiles; + this.#emittedManifestFiles = this.#nextManifestFiles ?? this.#emittedManifestFiles; + this.#nextBundleFiles = undefined; + this.#nextManifestFiles = undefined; + this.hasEmittedManifests = true; + this.#hasEmittedTslib = this.hasTslibImport; + this.#hasEmittedAssets = true; + this.#pendingAssetFiles.clear(); + + return this.#filesToDelete; } /** @@ -317,8 +462,8 @@ export class BuilderState { await this.#singleProgramCache?.compilationInstance.close?.(); this.#singleProgramCache = undefined; this.#compilationMode = undefined; - this.hasEmittedTslib = false; - this.hasEmittedAssets = false; + this.#hasEmittedTslib = false; + this.#hasEmittedAssets = false; this.hasCompilationError = false; this.hasEmittedManifests = false; this.hasEntryPointsChanges = false; @@ -326,8 +471,15 @@ export class BuilderState { this.pendingChangedEsmFiles.clear(); this.pendingChangedDtsFiles.clear(); this.#pendingCompilationFiles.clear(); + this.#pendingAssetFiles.clear(); this.#referencingFiles.clear(); this.#referencedByFile.clear(); this.#watchedCompilationFiles.clear(); + this.#emittedBundleFiles.clear(); + this.#emittedManifestFiles.clear(); + this.#emittedAssets.clear(); + this.#nextBundleFiles = undefined; + this.#nextManifestFiles = undefined; + this.#filesToDelete.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 ae84bbaa7331..95ffa1b2ea4f 100644 --- a/packages/angular/build/src/builders/library/builder-state_spec.ts +++ b/packages/angular/build/src/builders/library/builder-state_spec.ts @@ -6,7 +6,11 @@ * found in the LICENSE file at https://angular.dev/license */ +import assert from 'node:assert'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { BuilderState } from './builder-state'; +import { createDiskOutputFile, createMemoryOutputFile } from './pipeline/utils'; import type { LibraryCompilationOutput, SingleProgramCache } from './types'; describe('BuilderState', () => { @@ -183,7 +187,147 @@ describe('BuilderState', () => { }); }); - describe('dispose', () => { + describe('output tracking and cleanup', () => { + it('should track bundle and manifest outputs and queue stale files for deletion', () => { + const state = new BuilderState(); + + const initialBundles = new Map([ + [ + '.', + { + esmModuleIds: new Set(['/src/index.js']), + dtsModuleIds: new Set(['/src/index.d.ts']), + esmOutputFiles: new Set(['fesm2022/my-lib.mjs']), + dtsOutputFiles: new Set(['types/my-lib.d.ts']), + hasTslibImport: false, + }, + ], + [ + 'testing', + { + esmModuleIds: new Set(['/testing/index.js']), + dtsModuleIds: new Set(['/testing/index.d.ts']), + esmOutputFiles: new Set(['fesm2022/my-lib-testing.mjs']), + dtsOutputFiles: new Set(['types/my-lib-testing.d.ts']), + hasTslibImport: true, + }, + ], + ]); + + state.recordBundleOutput(initialBundles); + expect(state.hasTslibImport).toBeTrue(); + state.recordManifestOutput([ + createMemoryOutputFile('package.json', '{}'), + createMemoryOutputFile('testing/package.json', '{}'), + ]); + + const initialFilesToDelete = state.recordEmittedOutputs({ + filesToEmit: [], + filesToDelete: [], + }); + expect(initialFilesToDelete).toHaveSize(0); + expect(state.hasEmittedTslib).toBeTrue(); + + // No-op bundle pass when nothing changed + state.resetExecutionResult(); + state.recordBundleOutput(new Map()); + expect(state.recordEmittedOutputs({ filesToEmit: [], filesToDelete: [] })).toHaveSize(0); + + // Remove secondary entry point 'testing' + state.resetExecutionResult(); + state.previousBundleResults.delete('testing'); + state.hasEntryPointsChanges = true; + expect(state.hasTslibImport).toBeFalse(); + + state.recordBundleOutput(new Map()); + state.recordManifestOutput([createMemoryOutputFile('package.json', '{}')]); + const filesToDelete = state.recordEmittedOutputs({ + filesToEmit: [], + filesToDelete: [], + }); + + expect(state.hasEntryPointsChanges).toBeFalse(); + expect(state.hasEmittedTslib).toBeFalse(); + expect(filesToDelete).toEqual( + new Set([ + 'fesm2022/my-lib-testing.mjs', + 'types/my-lib-testing.d.ts', + 'testing/package.json', + ]), + ); + }); + + it('should not delete an asset output path when another source still emits to it', () => { + const state = new BuilderState(); + + const sourceA = '/workspace/projects/my-lib/README.md'; + const sourceB = '/workspace/docs/README.md'; + + state.recordEmittedOutputs({ + filesToEmit: [ + createDiskOutputFile(sourceA, 'README.md'), + createDiskOutputFile(sourceB, 'README.md'), + ], + filesToDelete: [], + }); + + // Delete sourceB while sourceA remains active + state.resetExecutionResult(); + const firstDeletes = state.recordEmittedOutputs({ + filesToEmit: [], + filesToDelete: [createDiskOutputFile(sourceB, 'README.md')], + }); + expect(firstDeletes.has('README.md')).toBeFalse(); + + // Delete sourceA as well; now README.md should be queued for deletion + state.resetExecutionResult(); + const secondDeletes = state.recordEmittedOutputs({ + filesToEmit: [], + filesToDelete: [createDiskOutputFile(sourceA, 'README.md')], + }); + expect(secondDeletes.has('README.md')).toBeTrue(); + }); + + it('should preserve pending asset modifications across failed iterations', async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + const tempRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-builder-state-')); + + try { + const readmePath = join(tempRoot, 'README.md'); + await writeFile(readmePath, '# Initial'); + + const options = { + workspaceRoot: tempRoot, + assets: [{ glob: 'README.md', input: tempRoot, output: '.' }], + }; + const state = new BuilderState(options); + + // Initial build emits README.md + const initialAssets = await state.collectAssets(options); + expect(initialAssets.filesToEmit).toHaveSize(1); + state.recordEmittedOutputs(initialAssets); + + // Watch iteration 1: README.md is modified, but build fails before recordEmittedOutputs + await writeFile(readmePath, '# Updated'); + const failedIterAssets = await state.collectAssets(options, new Set([readmePath])); + expect(failedIterAssets.filesToEmit).toHaveSize(1); + + // Watch iteration 2: only an unrelated file is in modifiedFiles; README.md is still collected + const unrelatedFile = join(tempRoot, 'src/index.ts'); + const recoveryAssets = await state.collectAssets(options, new Set([unrelatedFile])); + expect(recoveryAssets.filesToEmit).toHaveSize(1); + expect(recoveryAssets.filesToEmit[0]?.source).toBe(readmePath); + + // Once recovery outputs are recorded, pending asset modifications are cleared + state.recordEmittedOutputs(recoveryAssets); + const subsequentAssets = await state.collectAssets(options, new Set([unrelatedFile])); + expect(subsequentAssets.filesToEmit).toHaveSize(0); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + it('should close compilationInstance and clear cached state on dispose', async () => { const closeSpy = jasmine.createSpy('close').and.resolveTo(); const state = new BuilderState({ packageJsonPath }); diff --git a/packages/angular/build/src/builders/library/builder.ts b/packages/angular/build/src/builders/library/builder.ts index 42be7f99b211..ccead3cd594e 100644 --- a/packages/angular/build/src/builders/library/builder.ts +++ b/packages/angular/build/src/builders/library/builder.ts @@ -216,9 +216,9 @@ async function executeBuild( if (watcher) { watcher.add(Array.from(buildState.watchedCompilationFiles)); - const { assetsToEmit } = actionContext; - if (assetsToEmit?.length) { - watcher.add(assetsToEmit.map((asset) => asset.source)); + const { collectedAssets } = actionContext; + if (collectedAssets?.filesToEmit.length) { + watcher.add(collectedAssets.filesToEmit.map((asset) => asset.source)); } } } @@ -247,9 +247,7 @@ async function* runWatchLoop( buildAction: typeof import('./pipeline/build-action').buildAction, signal: AbortSignal, ): AsyncIterableIterator { - const { collectAssetsToEmit } = await import('./pipeline/assets'); - - const { workspaceRoot, packageJsonPath, assets, clearScreen } = options; + const { packageJsonPath, clearScreen } = options; for await (const changes of watcher) { if (signal.aborted) { @@ -298,13 +296,14 @@ async function* runWatchLoop( await buildState.singleProgramCache?.compilationInstance.update?.(changedFiles); } - const assetsToEmit = await collectAssetsToEmit( - assets, - workspaceRoot, - buildState.hasEmittedAssets ? changedFiles : undefined, - ); + const collectedAssets = await buildState.collectAssets(options, changedFiles); - if (!hasSourceChanges && buildState.hasEmittedManifests && assetsToEmit.length === 0) { + if ( + !hasSourceChanges && + buildState.hasEmittedManifests && + collectedAssets.filesToEmit.length === 0 && + collectedAssets.filesToDelete.length === 0 + ) { continue; } @@ -316,7 +315,7 @@ async function* runWatchLoop( isWatchMode: true, buildState, modifiedFiles: changedFiles, - assetsToEmit, + collectedAssets, }, logger, withProgress, diff --git a/packages/angular/build/src/builders/library/pipeline/assets.ts b/packages/angular/build/src/builders/library/pipeline/assets.ts index 7b8329a7865a..7a650398c38d 100644 --- a/packages/angular/build/src/builders/library/pipeline/assets.ts +++ b/packages/angular/build/src/builders/library/pipeline/assets.ts @@ -14,27 +14,90 @@ import type { NormalizedLibraryOptions } from '../types'; import { type DiskOutputFile, createDiskOutputFile } from './utils'; /** - * Resolves and collects configured library assets to be emitted to disk. + * Result of resolving and collecting library assets for a build iteration. + */ +export interface CollectedAssets { + /** Asset files to emit to disk, with POSIX source and destination paths. */ + filesToEmit: DiskOutputFile[]; + + /** Previously emitted asset files whose source files were deleted and should be removed from output. */ + filesToDelete: DiskOutputFile[]; +} + +/** + * Compiled asset matcher for incremental watch-mode matching. + */ +export interface AssetMatcher { + /** Normalized asset pattern configuration. */ + asset: NormalizedLibraryOptions['assets'][number]; + + /** Normalized POSIX input directory path with a trailing slash. */ + prefix: string; + + /** Compiled picomatch matcher function for the asset glob. */ + isMatch: picomatch.Matcher; +} + +/** + * Compiles picomatch matchers and POSIX input prefixes for each asset configuration. * - * @param assets The normalized asset patterns with POSIX paths. - * @param workspaceRoot The normalized POSIX workspace root directory path. + * @param assets The normalized asset patterns with POSIX input and output paths. + * @returns An array of compiled asset matcher descriptors. + */ +export function createAssetMatchers( + assets: NormalizedLibraryOptions['assets'], +): readonly AssetMatcher[] { + return assets.map((asset) => { + const isMatch = picomatch(asset.glob, { + dot: true, + ignore: [...DEFAULT_ASSET_IGNORE, ...(asset.ignore ?? [])], + }); + const prefix = asset.input.endsWith('/') ? asset.input : `${asset.input}/`; + + return { asset, prefix, isMatch }; + }); +} + +/** + * Resolves and collects configured library assets to be emitted to or deleted from disk. + * + * @param assets The normalized asset patterns with POSIX input and output paths. + * @param workspaceRoot The workspace root POSIX directory path. + * @param emittedAssets Map of previously emitted POSIX source file paths to sets of relative POSIX output paths. * @param modifiedFiles Optional set of modified POSIX file paths for incremental copying in watch mode. - * @returns An array of disk file emission descriptors. + * @param assetMatchers Optional pre-compiled asset matchers for incremental matching. + * @returns The collected asset files to emit and delete. */ export async function collectAssetsToEmit( assets: NormalizedLibraryOptions['assets'], workspaceRoot: string, + emittedAssets: ReadonlyMap>, modifiedFiles?: ReadonlySet, -): Promise { + assetMatchers?: readonly AssetMatcher[], +): Promise { if (assets.length === 0 || modifiedFiles?.size === 0) { - return []; + return { filesToEmit: [], filesToDelete: [] }; } if (modifiedFiles) { - const matchers = createAssetMatchers(assets); + const matchers = assetMatchers ?? createAssetMatchers(assets); const filesToEmit: DiskOutputFile[] = []; + const filesToDelete: DiskOutputFile[] = []; for (const file of modifiedFiles) { + let isFile: boolean | undefined; + + const previousOutputs = emittedAssets.get(file); + if (previousOutputs) { + isFile = statSync(file, { throwIfNoEntry: false })?.isFile() ?? false; + if (!isFile) { + for (const output of previousOutputs) { + filesToDelete.push(createDiskOutputFile(file, output)); + } + continue; + } + } + for (const { asset, prefix, isMatch } of matchers) { if (!file.startsWith(prefix)) { continue; @@ -45,28 +108,24 @@ export async function collectAssetsToEmit( continue; } - if (statSync(file, { throwIfNoEntry: false })?.isFile()) { - filesToEmit.push(createDiskOutputFile(file, path.posix.join(asset.output, relative))); + isFile ??= statSync(file, { throwIfNoEntry: false })?.isFile() ?? false; + if (!isFile) { + break; } + + filesToEmit.push(createDiskOutputFile(file, path.posix.join(asset.output, relative))); } } - return filesToEmit; + return { filesToEmit, filesToDelete }; } const resolvedAssets = await resolveAssets(assets, workspaceRoot); - return resolvedAssets.map(({ source, destination }) => createDiskOutputFile(source, destination)); -} - -function createAssetMatchers(assets: NormalizedLibraryOptions['assets']) { - return assets.map((asset) => { - const isMatch = picomatch(asset.glob, { - dot: true, - ignore: [...DEFAULT_ASSET_IGNORE, ...(asset.ignore ?? [])], - }); - const prefix = asset.input.endsWith('/') ? asset.input : `${asset.input}/`; - - return { asset, prefix, isMatch }; - }); + return { + filesToEmit: resolvedAssets.map(({ source, destination }) => + createDiskOutputFile(source, destination), + ), + filesToDelete: [], + }; } diff --git a/packages/angular/build/src/builders/library/pipeline/build-action.ts b/packages/angular/build/src/builders/library/pipeline/build-action.ts index 734ea23c544e..2452f0e94a42 100644 --- a/packages/angular/build/src/builders/library/pipeline/build-action.ts +++ b/packages/angular/build/src/builders/library/pipeline/build-action.ts @@ -8,17 +8,17 @@ import path from 'node:path'; import type { ComponentStylesheetBundler } from '../../../tools/esbuild/angular/component-stylesheets'; -import { emitFilesToDisk } from '../../../utils/disk-files'; +import { deleteFilesFromDisk, emitFilesToDisk } from '../../../utils/disk-files'; import { assertIsError } from '../../../utils/error'; import type { BuilderState } from '../builder-state'; import type { ExecutionResult } from '../execution-result'; import type { NormalizedLibraryOptions } from '../types'; -import { collectAssetsToEmit } from './assets'; +import type { CollectedAssets } from './assets'; import { type BundleEntryPointsOutput, bundleEntryPoints } from './bundler'; import { compileLibrary } from './compilation'; import { collectEntryPointsToBundle } from './entry-points'; import { generatePackageManifests } from './package-manifests'; -import type { DiskOutputFile, OutputFile } from './utils'; +import type { OutputFile } from './utils'; /** * Context required to execute a single library build iteration. @@ -33,19 +33,19 @@ export interface BuildActionContext { /** Whether the builder is running in watch mode. */ isWatchMode: boolean; - /** Build state tracking compilation caches and emitted outputs. */ + /** Build state tracking compilation caches, file references, and emitted outputs. */ buildState: BuilderState; /** Set of POSIX file paths modified since the last build iteration. */ modifiedFiles?: Set; /** - * Asset files to emit for the current build iteration. + * Collected asset changes for the current build iteration. * Pre-collected in the watch loop to avoid redundant asset matching, or resolved and * populated by `buildAction` when omitted (such as during the initial build) so the * caller can register their POSIX source paths with the file watcher. */ - assetsToEmit?: DiskOutputFile[]; + collectedAssets?: CollectedAssets; } /** @@ -133,30 +133,34 @@ export async function buildAction(actionContext: BuildActionContext): Promise path.posix.join(options.outputPath, file.path)); - buildState.hasEmittedManifests = true; - buildState.hasEmittedTslib = hasTslibImport; - buildState.hasEmittedAssets = true; + const filesToDelete = buildState.recordEmittedOutputs(collectedAssets); + + if (filesToDelete.size) { + await deleteFilesFromDisk( + Array.from(filesToDelete, (file) => path.posix.join(options.outputPath, file)), + ); + } } catch (error) { assertIsError(error); result.addError(error.message); diff --git a/packages/angular/build/src/builders/library/pipeline/bundler.ts b/packages/angular/build/src/builders/library/pipeline/bundler.ts index c48942885083..1fe20e342d63 100644 --- a/packages/angular/build/src/builders/library/pipeline/bundler.ts +++ b/packages/angular/build/src/builders/library/pipeline/bundler.ts @@ -26,7 +26,7 @@ export interface BundleEntryPointsOutput { /** In-memory files produced by bundling to be written to disk. */ filesToEmit: MemoryOutputFile[]; - /** Map of entry point names to their bundle results containing bundled module IDs. */ + /** Map of entry point names to their bundle results. */ bundleResults: Map; /** Warning messages emitted during bundling. */ @@ -63,6 +63,9 @@ interface MultiBundleOutput { /** Map of bundle entry names to the set of virtual POSIX module IDs included in the bundle. */ moduleIdsByBundle: Map>; + /** Map of bundle entry names to the set of relative POSIX output paths generated for the bundle. */ + outputFilesByBundle: Map>; + /** Set of bundle entry names that import 'tslib'. */ tslibBundles: Set; @@ -111,12 +114,21 @@ export async function bundleEntryPoints( for (const { entryPoint, previousBundleResult } of items) { const { bundleName, name } = entryPoint; const esmModuleIds = esmOutput.moduleIdsByBundle.get(bundleName); + bundleResults.set(name, { - esmModuleIds: esmModuleIds ?? previousBundleResult?.esmModuleIds ?? new Set(), - dtsModuleIds: - dtsOutput.moduleIdsByBundle.get(bundleName) ?? - previousBundleResult?.dtsModuleIds ?? - new Set(), + esmModuleIds: getUpdatedOrPreviousSet(esmModuleIds, previousBundleResult?.esmModuleIds), + dtsModuleIds: getUpdatedOrPreviousSet( + dtsOutput.moduleIdsByBundle.get(bundleName), + previousBundleResult?.dtsModuleIds, + ), + esmOutputFiles: getUpdatedOrPreviousSet( + esmOutput.outputFilesByBundle.get(bundleName), + previousBundleResult?.esmOutputFiles, + ), + dtsOutputFiles: getUpdatedOrPreviousSet( + dtsOutput.outputFilesByBundle.get(bundleName), + previousBundleResult?.dtsOutputFiles, + ), hasTslibImport: esmModuleIds ? esmOutput.tslibBundles.has(bundleName) : (previousBundleResult?.hasTslibImport ?? false), @@ -177,7 +189,7 @@ function createMemoryFileLoaderPlugin( return files.has(id) ? { id, external: false } : undefined; } - if (id[0] !== '.' && !path.isAbsolute(id)) { + if (id[0] !== '.' && !path.posix.isAbsolute(id)) { return { id, external: true }; } @@ -220,7 +232,8 @@ function createMemoryFileLoaderPlugin( /** * Processes Rolldown output assets and chunks, extracting emitted files and computing - * the transitive set of module IDs belonging to each entry point bundle. + * the transitive sets of module IDs, output file paths, and tslib imports belonging to + * each entry point bundle. * * @param output The array of output chunks and assets from Rolldown. * @param dir The destination POSIX output directory prefix. @@ -232,11 +245,14 @@ function processRolldownOutput( ): Omit { const filesToEmit: MemoryOutputFile[] = []; const moduleIdsByBundle = new Map>(); + const outputFilesByBundle = new Map>(); const tslibBundles = new Set(); const chunksByFileName = new Map(); + const outputFileNames = new Set(); const entryChunks: OutputChunk[] = []; for (const item of output) { + outputFileNames.add(item.fileName); filesToEmit.push( createMemoryOutputFile( path.posix.join(dir, item.fileName), @@ -254,23 +270,18 @@ function processRolldownOutput( for (const entryChunk of entryChunks) { const modSet = new Set(); + const outSet = new Set(); moduleIdsByBundle.set(entryChunk.name, modSet); - - const visited = new Set(); - const queue: OutputChunk[] = [entryChunk]; - - while (queue.length) { - const chunk = queue.pop(); - if (!chunk) { - break; - } - - if (visited.has(chunk)) { - continue; + outputFilesByBundle.set(entryChunk.name, outSet); + + const visited = new Set([entryChunk]); + for (const chunk of visited) { + outSet.add(path.posix.join(dir, chunk.fileName)); + const mapFileName = `${chunk.fileName}.map`; + if (outputFileNames.has(mapFileName)) { + outSet.add(path.posix.join(dir, mapFileName)); } - visited.add(chunk); - for (const modId of chunk.moduleIds) { if (modId[0] !== '\0') { modSet.add(toPosixPath(modId)); @@ -283,14 +294,14 @@ function processRolldownOutput( } const depChunk = chunksByFileName.get(depFile); - if (depChunk && !visited.has(depChunk)) { - queue.push(depChunk); + if (depChunk) { + visited.add(depChunk); } } } } - return { filesToEmit, moduleIdsByBundle, tslibBundles }; + return { filesToEmit, moduleIdsByBundle, outputFilesByBundle, tslibBundles }; } /** @@ -319,18 +330,9 @@ async function executeMultiBundle( ): Promise { const { workspaceRoot, preserveSymlinks, declarationMap, outputPath } = options; const isDts = extension === 'd.ts'; - - let sourcemap: boolean; - let dir: string; - - if (isDts) { - dir = TYPES_OUTPUT_DIR; - sourcemap = !!declarationMap; - } else { - dir = FESM_OUTPUT_DIR; - // FESM (.mjs) sourcemaps are always enabled. - sourcemap = true; - } + const dir = isDts ? TYPES_OUTPUT_DIR : FESM_OUTPUT_DIR; + // FESM (.mjs) sourcemaps are always enabled. + const sourcemap = isDts ? !!declarationMap : true; const warnings: string[] = []; const { rolldown } = await loadRolldown(); @@ -394,6 +396,7 @@ async function bundleAllEsm( return { filesToEmit: [], moduleIdsByBundle: new Map(), + outputFilesByBundle: new Map(), tslibBundles: new Set(), warnings: [], }; @@ -424,6 +427,7 @@ async function bundleAllDts( return { filesToEmit: [], moduleIdsByBundle: new Map(), + outputFilesByBundle: new Map(), tslibBundles: new Set(), warnings: [], }; @@ -466,3 +470,19 @@ async function bundleAllDts( options, ); } + +/** + * Returns the newly generated set from the current bundle pass if present, + * falling back to the cached set from the previous build (when that bundle + * pass was skipped in incremental watch mode) or an empty set. + * + * @param current The set produced by the current bundle pass, if executed. + * @param previous The cached set from the previous build iteration, if available. + * @returns The active set to store in the bundle result. + */ +function getUpdatedOrPreviousSet( + current: ReadonlySet | undefined, + previous: ReadonlySet | undefined, +): ReadonlySet { + return current ?? previous ?? new Set(); +} diff --git a/packages/angular/build/src/builders/library/pipeline/entry-points_spec.ts b/packages/angular/build/src/builders/library/pipeline/entry-points_spec.ts index e4653ef56e2b..b5b4aac20059 100644 --- a/packages/angular/build/src/builders/library/pipeline/entry-points_spec.ts +++ b/packages/angular/build/src/builders/library/pipeline/entry-points_spec.ts @@ -31,6 +31,8 @@ describe('entry-points pipeline', () => { return { esmModuleIds: new Set(), dtsModuleIds: new Set(), + esmOutputFiles: new Set(), + dtsOutputFiles: new Set(), hasTslibImport: false, ...overrides, }; diff --git a/packages/angular/build/src/builders/library/tests/behavior/watch-cleanup_spec.ts b/packages/angular/build/src/builders/library/tests/behavior/watch-cleanup_spec.ts new file mode 100644 index 000000000000..6518b459b023 --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/behavior/watch-cleanup_spec.ts @@ -0,0 +1,216 @@ +/** + * @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 { executeLibraryBuilder } from '../../builder'; +import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; + +describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { + describe('Behavior: "Watch Mode Asset and Output Cleanup"', () => { + it('should emit newly created assets, respect ignore patterns, and clean up deleted assets in watch mode', async () => { + await harness.writeFiles({ + 'projects/lib/assets-dir/keep.txt': 'keep', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [ + { + glob: '**/*.txt', + input: 'projects/lib/assets-dir', + output: 'assets', + ignore: ['**/ignored.txt'], + }, + ], + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/assets/keep.txt')).toBe('keep'); + + // Create an ignored file and a matching nested asset file + await harness.writeFiles({ + 'projects/lib/assets-dir/sub/ignored.txt': 'ignored', + 'projects/lib/assets-dir/sub/added.txt': 'added', + }); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/assets/sub/added.txt')).toBe('added'); + expect(harness.hasFile('dist/lib/assets/sub/ignored.txt')).toBeFalse(); + + // Remove the added asset file + await harness.removeFile('projects/lib/assets-dir/sub/added.txt'); + }, + ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/assets/keep.txt')).toBe('keep'); + expect(harness.hasFile('dist/lib/assets/sub/added.txt')).toBeFalse(); + }, + ]); + }); + + it('should clean up output files when a secondary entry point is removed in watch mode', async () => { + await harness.writeFile( + 'projects/lib/secondary/src/public-api.ts', + `export const secondary = 'secondary-value';`, + ); + + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + './secondary': './secondary/src/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + declarationMap: true, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.hasFile('dist/lib/fesm2022/lib.mjs')).toBeTrue(); + expect(harness.hasFile('dist/lib/fesm2022/lib-secondary.mjs')).toBeTrue(); + expect(harness.hasFile('dist/lib/fesm2022/lib-secondary.mjs.map')).toBeTrue(); + expect(harness.hasFile('dist/lib/types/lib-secondary.d.ts')).toBeTrue(); + expect(harness.hasFile('dist/lib/types/lib-secondary.d.ts.map')).toBeTrue(); + expect(harness.hasFile('dist/lib/secondary/package.json')).toBeTrue(); + expect(harness.hasFile('dist/lib/.npmignore')).toBeTrue(); + + // Remove the secondary entry point from package.json exports + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + }, + ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.hasFile('dist/lib/fesm2022/lib.mjs')).toBeTrue(); + expect(harness.hasFile('dist/lib/fesm2022/lib-secondary.mjs')).toBeFalse(); + expect(harness.hasFile('dist/lib/fesm2022/lib-secondary.mjs.map')).toBeFalse(); + expect(harness.hasFile('dist/lib/types/lib-secondary.d.ts')).toBeFalse(); + expect(harness.hasFile('dist/lib/types/lib-secondary.d.ts.map')).toBeFalse(); + expect(harness.hasFile('dist/lib/secondary/package.json')).toBeFalse(); + expect(harness.hasFile('dist/lib/.npmignore')).toBeFalse(); + + const pkg = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(pkg.exports['./secondary']).toBeUndefined(); + }, + ]); + }); + + it('should prune removed nested secondary entry point files and update .npmignore in watch mode', async () => { + await harness.writeFiles({ + 'projects/lib/shared/src/public-api.ts': `export const SHARED = 'shared';\n`, + 'projects/lib/testing/sub/src/public-api.ts': `export const SUB = 'sub';\n`, + }); + + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + './shared': './shared/src/public-api.ts', + './testing/sub': './testing/sub/src/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + harness.expectFile('dist/lib/fesm2022/lib-shared.mjs').toExist(); + harness.expectFile('dist/lib/fesm2022/lib-testing-sub.mjs').toExist(); + harness.expectFile('dist/lib/types/lib-testing-sub.d.ts').toExist(); + harness.expectFile('dist/lib/testing/sub/package.json').toExist(); + expect(harness.readFile('dist/lib/.npmignore')).toContain('/testing/sub/package.json'); + + // Remove './testing/sub' while keeping './shared' + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + './shared': './shared/src/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + }, + ({ result }) => { + expect(result?.success).toBeTrue(); + harness.expectFile('dist/lib/fesm2022/lib-shared.mjs').toExist(); + harness.expectFile('dist/lib/fesm2022/lib-testing-sub.mjs').toNotExist(); + harness.expectFile('dist/lib/fesm2022/lib-testing-sub.mjs.map').toNotExist(); + harness.expectFile('dist/lib/types/lib-testing-sub.d.ts').toNotExist(); + harness.expectFile('dist/lib/testing/sub/package.json').toNotExist(); + + const npmignore = harness.readFile('dist/lib/.npmignore'); + expect(npmignore).toContain('/shared/package.json'); + expect(npmignore).not.toContain('/testing/sub/package.json'); + }, + ]); + }); + + it('should clean up all output destinations when a single asset source matches multiple patterns and is deleted in watch mode', async () => { + await harness.writeFiles({ + 'projects/lib/assets-dir/shared.txt': 'shared-content', + 'projects/lib/assets-dir/keep.txt': 'keep-content', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [ + { + glob: '**/*.txt', + input: 'projects/lib/assets-dir', + output: 'assets-a', + }, + { + glob: '**/*.txt', + input: 'projects/lib/assets-dir', + output: 'assets-b', + }, + ], + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/assets-a/shared.txt')).toBe('shared-content'); + expect(harness.readFile('dist/lib/assets-b/shared.txt')).toBe('shared-content'); + + await harness.removeFile('projects/lib/assets-dir/shared.txt'); + }, + ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.hasFile('dist/lib/assets-a/shared.txt')).toBeFalse(); + expect(harness.hasFile('dist/lib/assets-b/shared.txt')).toBeFalse(); + expect(harness.readFile('dist/lib/assets-a/keep.txt')).toBe('keep-content'); + expect(harness.readFile('dist/lib/assets-b/keep.txt')).toBe('keep-content'); + }, + ]); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/types.ts b/packages/angular/build/src/builders/library/types.ts index 2d2d9f61891d..e37d06a82aa0 100644 --- a/packages/angular/build/src/builders/library/types.ts +++ b/packages/angular/build/src/builders/library/types.ts @@ -142,7 +142,7 @@ export interface NormalizedLibraryOptions { } /** - * Cached module ID sets for a bundled entry point. + * Cached module ID and output file sets for a bundled entry point. */ export interface BundleResult { /** Exact set of virtual POSIX ESM module IDs bundled into this entry point. */ @@ -151,8 +151,14 @@ export interface BundleResult { /** Exact set of virtual POSIX DTS module IDs bundled into this entry point. */ dtsModuleIds: ReadonlySet; + /** Relative POSIX output paths of ESM bundle files and sourcemaps emitted for this entry point. */ + esmOutputFiles: ReadonlySet; + + /** Relative POSIX output paths of DTS bundle files and sourcemaps emitted for this entry point. */ + dtsOutputFiles: ReadonlySet; + /** Whether this entry point bundle imports 'tslib'. */ - hasTslibImport?: boolean; + hasTslibImport: boolean; } /**