From 45cd57c8f6f5db60b03d91ebfd1d3d1be01484b1 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:58:58 +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 | 174 ++++++++++++-- .../builders/library/builder-state_spec.ts | 150 +++++++++++- .../build/src/builders/library/builder.ts | 24 +- .../src/builders/library/pipeline/assets.ts | 104 +++++++-- .../builders/library/pipeline/build-action.ts | 55 ++--- .../src/builders/library/pipeline/bundler.ts | 109 +++++---- .../library/pipeline/entry-points_spec.ts | 2 + .../tests/behavior/watch-cleanup_spec.ts | 216 ++++++++++++++++++ .../build/src/builders/library/types.ts | 14 +- 9 files changed, 707 insertions(+), 141 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..9a468c2fb543 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,27 @@ 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(); + #hasUpdatedBundles = false; + #singleProgramCache?: SingleProgramCache; #compilationMode?: 'partial' | 'full'; + #hasEmittedTslib = false; /** Map of entry point names to their previous bundle results. */ readonly previousBundleResults = new Map(); @@ -44,18 +61,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 +80,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 +94,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 +127,13 @@ 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.#hasUpdatedBundles = false; return this.#executionResult; } @@ -221,6 +240,9 @@ export class BuilderState { if (file !== packageJsonPath) { this.#pendingCompilationFiles.add(file); } + if (this.#hasEmittedAssets) { + this.#pendingAssetFiles.add(file); + } } } @@ -294,12 +316,13 @@ export class BuilderState { } /** - * Records active bundle results across entry points after compilation and bundling succeed, - * clearing pending compilation/bundle flags. + * Records active bundle results across entry points after compilation and bundling succeed + * and clears pending compilation/bundle flags. * * @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 +331,126 @@ export class BuilderState { for (const [name, bundleResult] of bundleResults) { this.previousBundleResults.set(name, bundleResult); } + + if (bundleResults.size > 0 || hadEntryPointsChanges || this.#emittedBundleFiles.size === 0) { + this.#hasUpdatedBundles = true; + } + } + + /** + * 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, + * returning any stale or deleted output paths to remove from disk. + * + * @param collectedAssets Collected asset files emitted and deleted in this iteration. + * @param manifestFiles Optional manifest output files generated in this iteration. + * @returns Read-only set of relative POSIX output paths to delete from disk. + */ + recordEmittedOutputs( + collectedAssets: CollectedAssets, + manifestFiles?: readonly OutputFile[], + ): ReadonlySet { + const filesToDelete = new Set(); + + if (this.#hasUpdatedBundles) { + this.#hasUpdatedBundles = false; + 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)) { + filesToDelete.add(prevFile); + } + } + + this.#emittedBundleFiles = nextBundleFiles; + } + + if (manifestFiles) { + const nextManifestFiles = new Set(manifestFiles.map((file) => file.path)); + for (const prevFile of this.#emittedManifestFiles) { + if (!nextManifestFiles.has(prevFile)) { + filesToDelete.add(prevFile); + } + } + + this.#emittedManifestFiles = nextManifestFiles; + } + + const { filesToDelete: toDelete, filesToEmit: toEmit } = collectedAssets; + + 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 candidateDeletes = new Set(); + for (const source of toDelete) { + const outputs = this.#emittedAssets.get(source); + if (outputs) { + for (const output of outputs) { + candidateDeletes.add(output); + } + this.#emittedAssets.delete(source); + } + } + + for (const outputs of this.#emittedAssets.values()) { + for (const output of outputs) { + candidateDeletes.delete(output); + } + } + + for (const output of candidateDeletes) { + filesToDelete.add(output); + } + } + + this.hasEmittedManifests = true; + this.#hasEmittedTslib = this.hasTslibImport; + this.#hasEmittedAssets = true; + this.#pendingAssetFiles.clear(); + + return filesToDelete; } /** @@ -317,8 +460,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 +469,13 @@ 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.#hasUpdatedBundles = false; } } 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..da7420afc3e5 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,151 @@ 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(); + + const initialFilesToDelete = state.recordEmittedOutputs( + { + filesToEmit: [], + filesToDelete: [], + }, + [ + createMemoryOutputFile('package.json', '{}'), + createMemoryOutputFile('testing/package.json', '{}'), + ], + ); + 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()); + const filesToDelete = state.recordEmittedOutputs( + { + filesToEmit: [], + filesToDelete: [], + }, + [createMemoryOutputFile('package.json', '{}')], + ); + + 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: [sourceB], + }); + 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: [sourceA], + }); + 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..1e5d08b55b5f 100644 --- a/packages/angular/build/src/builders/library/builder.ts +++ b/packages/angular/build/src/builders/library/builder.ts @@ -215,11 +215,6 @@ async function executeBuild( } finally { if (watcher) { watcher.add(Array.from(buildState.watchedCompilationFiles)); - - const { assetsToEmit } = actionContext; - if (assetsToEmit?.length) { - watcher.add(assetsToEmit.map((asset) => asset.source)); - } } } } @@ -247,9 +242,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 +291,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 +310,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..90c6d5c10fa4 100644 --- a/packages/angular/build/src/builders/library/pipeline/assets.ts +++ b/packages/angular/build/src/builders/library/pipeline/assets.ts @@ -9,32 +9,90 @@ import { statSync } from 'node:fs'; import path from 'node:path'; import picomatch from 'picomatch'; +import type { NormalizedAssetsPattern } from '../../../utils'; import { DEFAULT_ASSET_IGNORE, resolveAssets } from '../../../utils/resolve-assets'; -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 POSIX asset source file paths that were deleted from disk. */ + filesToDelete: string[]; +} + +/** + * Compiled asset matcher for incremental watch-mode matching. + */ +export interface AssetMatcher { + /** Normalized asset pattern configuration. */ + asset: NormalizedAssetsPattern; + + /** 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 input and output paths. + * @returns An array of compiled asset matcher descriptors. + */ +export function createAssetMatchers(assets: NormalizedAssetsPattern[]): 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 paths. - * @param workspaceRoot The normalized POSIX workspace root directory path. + * @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. * @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'], + assets: NormalizedAssetsPattern[], 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: string[] = []; for (const file of modifiedFiles) { + let isFile: boolean | undefined; + + if (emittedAssets.has(file)) { + isFile = statSync(file, { throwIfNoEntry: false })?.isFile() ?? false; + if (!isFile) { + filesToDelete.push(file); + continue; + } + } + for (const { asset, prefix, isMatch } of matchers) { if (!file.startsWith(prefix)) { continue; @@ -45,28 +103,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..9d579e58a76a 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,18 @@ 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. - * 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. + * Collected asset changes for the current build iteration. + * Pre-collected in the watch loop to avoid redundant asset matching, or resolved by + * `buildAction` when omitted (such as during the initial build). */ - assetsToEmit?: DiskOutputFile[]; + collectedAssets?: CollectedAssets; } /** @@ -131,32 +130,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, manifestFiles); + + 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..eccd4cd04e12 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. */ @@ -53,6 +53,20 @@ export interface BundleEntryPointInput { const ESM_EXTENSIONS = ['.js', '.mjs', '/index.js', '/index.mjs'] as const; const DTS_EXTENSIONS = ['.d.ts', '.d.mts', '/index.d.ts', '/index.d.mts'] as const; +/** + * Bundled module IDs, output files, and metadata for a single entry point bundle. + */ +interface EntryBundleOutput { + /** Set of virtual POSIX module IDs included in the bundle. */ + moduleIds: Set; + + /** Set of relative POSIX output paths generated for the bundle. */ + outputFiles: Set; + + /** Whether the bundle imports 'tslib'. */ + hasTslibImport: boolean; +} + /** * Output of a multi-entry-point Rolldown bundling invocation. */ @@ -60,11 +74,8 @@ interface MultiBundleOutput { /** In-memory files generated by the bundle invocation. */ filesToEmit: MemoryOutputFile[]; - /** Map of bundle entry names to the set of virtual POSIX module IDs included in the bundle. */ - moduleIdsByBundle: Map>; - - /** Set of bundle entry names that import 'tslib'. */ - tslibBundles: Set; + /** Map of bundle entry names to their bundled module IDs, output files, and metadata. */ + bundles: Map; /** Warning messages emitted during the bundle invocation. */ warnings: string[]; @@ -110,16 +121,15 @@ export async function bundleEntryPoints( for (const { entryPoint, previousBundleResult } of items) { const { bundleName, name } = entryPoint; - const esmModuleIds = esmOutput.moduleIdsByBundle.get(bundleName); + const esmBundle = esmOutput.bundles.get(bundleName); + const dtsBundle = dtsOutput.bundles.get(bundleName); + bundleResults.set(name, { - esmModuleIds: esmModuleIds ?? previousBundleResult?.esmModuleIds ?? new Set(), - dtsModuleIds: - dtsOutput.moduleIdsByBundle.get(bundleName) ?? - previousBundleResult?.dtsModuleIds ?? - new Set(), - hasTslibImport: esmModuleIds - ? esmOutput.tslibBundles.has(bundleName) - : (previousBundleResult?.hasTslibImport ?? false), + esmModuleIds: esmBundle?.moduleIds ?? previousBundleResult?.esmModuleIds ?? new Set(), + dtsModuleIds: dtsBundle?.moduleIds ?? previousBundleResult?.dtsModuleIds ?? new Set(), + esmOutputFiles: esmBundle?.outputFiles ?? previousBundleResult?.esmOutputFiles ?? new Set(), + dtsOutputFiles: dtsBundle?.outputFiles ?? previousBundleResult?.dtsOutputFiles ?? new Set(), + hasTslibImport: esmBundle?.hasTslibImport ?? previousBundleResult?.hasTslibImport ?? false, }); } @@ -177,7 +187,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 +230,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. @@ -231,12 +242,13 @@ function processRolldownOutput( dir: string, ): Omit { const filesToEmit: MemoryOutputFile[] = []; - const moduleIdsByBundle = new Map>(); - const tslibBundles = new Set(); + const bundles = new Map(); 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), @@ -253,44 +265,40 @@ function processRolldownOutput( } for (const entryChunk of entryChunks) { - const modSet = 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; + const moduleIds = new Set(); + const outputFiles = new Set(); + let hasTslibImport = false; + + const visited = new Set([entryChunk]); + for (const chunk of visited) { + outputFiles.add(path.posix.join(dir, chunk.fileName)); + const mapFileName = `${chunk.fileName}.map`; + if (outputFileNames.has(mapFileName)) { + outputFiles.add(path.posix.join(dir, mapFileName)); } - visited.add(chunk); - for (const modId of chunk.moduleIds) { if (modId[0] !== '\0') { - modSet.add(toPosixPath(modId)); + moduleIds.add(toPosixPath(modId)); } } for (const depFile of [...chunk.imports, ...chunk.dynamicImports]) { if (depFile === 'tslib') { - tslibBundles.add(entryChunk.name); + hasTslibImport = true; } const depChunk = chunksByFileName.get(depFile); - if (depChunk && !visited.has(depChunk)) { - queue.push(depChunk); + if (depChunk) { + visited.add(depChunk); } } } + + bundles.set(entryChunk.name, { moduleIds, outputFiles, hasTslibImport }); } - return { filesToEmit, moduleIdsByBundle, tslibBundles }; + return { filesToEmit, bundles }; } /** @@ -319,18 +327,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(); @@ -393,8 +392,7 @@ async function bundleAllEsm( if (entryPoints.length === 0) { return { filesToEmit: [], - moduleIdsByBundle: new Map(), - tslibBundles: new Set(), + bundles: new Map(), warnings: [], }; } @@ -423,8 +421,7 @@ async function bundleAllDts( if (entryPoints.length === 0) { return { filesToEmit: [], - moduleIdsByBundle: new Map(), - tslibBundles: new Set(), + bundles: new Map(), warnings: [], }; } 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..273f7066cb99 100644 --- a/packages/angular/build/src/builders/library/types.ts +++ b/packages/angular/build/src/builders/library/types.ts @@ -8,7 +8,7 @@ import type { AngularCompilation } from '../../tools/angular/compilation'; import type { StylesheetPluginsass } from '../../tools/esbuild/stylesheets/stylesheet-plugin-factory'; -import type { normalizeAssetPatterns } from '../../utils'; +import type { NormalizedAssetsPattern } from '../../utils'; import type { NormalizedCachedOptions } from '../../utils/normalize-cache'; import type { PostcssConfiguration } from '../../utils/postcss-configuration'; @@ -99,7 +99,7 @@ export interface NormalizedLibraryOptions { sass?: StylesheetPluginsass; /** Normalized asset patterns with POSIX `input` and `output` paths. */ - assets: ReturnType; + assets: NormalizedAssetsPattern[]; /** Angular compilation mode (`'partial'` or `'full'`). */ compilationMode?: 'partial' | 'full'; @@ -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; } /**