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