From 202d2f08eaa888dc645577dbdb607f7ac5e73add Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:41:18 +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 and manifest output files when entry points or exports change. --- .../build/src/builders/library/builder.ts | 23 ++- .../src/builders/library/pipeline/assets.ts | 87 +++++++-- .../builders/library/pipeline/build-action.ts | 146 +++++++++++++-- .../src/builders/library/pipeline/bundler.ts | 25 ++- .../builders/library/pipeline/entry-points.ts | 4 + .../library/pipeline/entry-points_spec.ts | 19 +- .../tests/behavior/watch-cleanup_spec.ts | 172 ++++++++++++++++++ .../library/tests/options/assets_spec.ts | 3 + .../build/src/builders/library/types.ts | 15 ++ 9 files changed, 440 insertions(+), 54 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.ts b/packages/angular/build/src/builders/library/builder.ts index c3c2149adf5b..8fb7e047f09f 100644 --- a/packages/angular/build/src/builders/library/builder.ts +++ b/packages/angular/build/src/builders/library/builder.ts @@ -127,7 +127,10 @@ export async function* executeLibraryBuilder( poll, preserveSymlinks, signal, - watchFiles: watchedCompilationFiles, + watchFiles: [ + ...watchedCompilationFiles, + ...normalizedOptions.assets.map((asset) => asset.input), + ], }); context.addTeardown?.(() => void watcher?.close()); @@ -224,9 +227,9 @@ async function executeBuild( if (watcher) { watcher.add(Array.from(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)); } } } @@ -337,13 +340,19 @@ async function* runWatchLoop( buildState.hasEntryPointsChanges || hasModifiedWatchedFile(changedFiles, watchedCompilationFiles, packageJsonPath); - const assetsToEmit = await collectAssetsToEmit( + const collectedAssets = await collectAssetsToEmit( assets, workspaceRoot, buildState.hasEmittedAssets ? changedFiles : undefined, + buildState.emittedAssets, ); - if (!hasSourceChanges && !hasPackageJsonChanges && assetsToEmit.length === 0) { + if ( + !hasSourceChanges && + !hasPackageJsonChanges && + collectedAssets.filesToEmit.length === 0 && + collectedAssets.filesToDelete.length === 0 + ) { continue; } @@ -357,7 +366,7 @@ async function* runWatchLoop( context, buildState, modifiedFiles: changedFiles, - assetsToEmit, + collectedAssets, }, withProgress, watcher, diff --git a/packages/angular/build/src/builders/library/pipeline/assets.ts b/packages/angular/build/src/builders/library/pipeline/assets.ts index 7b8329a7865a..33efe643eeda 100644 --- a/packages/angular/build/src/builders/library/pipeline/assets.ts +++ b/packages/angular/build/src/builders/library/pipeline/assets.ts @@ -14,27 +14,60 @@ 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[]; +} + +interface AssetMatcher { + asset: NormalizedLibraryOptions['assets'][number]; + prefix: string; + isMatch: picomatch.Matcher; +} + +const assetMatcherCache = new WeakMap(); + +/** + * 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 modifiedFiles Optional set of modified POSIX file paths for incremental copying in watch mode. - * @returns An array of disk file emission descriptors. + * @param emittedAssets Optional map of previously emitted POSIX source file paths to relative POSIX output paths. + * @returns The collected asset files to emit and delete. */ export async function collectAssetsToEmit( assets: NormalizedLibraryOptions['assets'], workspaceRoot: string, modifiedFiles?: ReadonlySet, -): Promise { + emittedAssets?: ReadonlyMap, +): Promise { if (assets.length === 0 || modifiedFiles?.size === 0) { - return []; + return { filesToEmit: [], filesToDelete: [] }; } if (modifiedFiles) { - const matchers = createAssetMatchers(assets); + const matchers = getAssetMatchers(assets); const filesToEmit: DiskOutputFile[] = []; + const filesToDelete: DiskOutputFile[] = []; for (const file of modifiedFiles) { + let isFile: boolean | undefined; + + const previousOutput = emittedAssets?.get(file); + if (previousOutput !== undefined) { + isFile = statSync(file, { throwIfNoEntry: false })?.isFile() ?? false; + if (!isFile) { + filesToDelete.push(createDiskOutputFile(file, previousOutput)); + continue; + } + } + for (const { asset, prefix, isMatch } of matchers) { if (!file.startsWith(prefix)) { continue; @@ -45,28 +78,46 @@ export async function collectAssetsToEmit( continue; } - if (statSync(file, { throwIfNoEntry: false })?.isFile()) { + isFile ??= statSync(file, { throwIfNoEntry: false })?.isFile() ?? false; + if (isFile) { 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)); + return { + filesToEmit: resolvedAssets.map(({ source, destination }) => + createDiskOutputFile(source, destination), + ), + filesToDelete: [], + }; } -function createAssetMatchers(assets: NormalizedLibraryOptions['assets']) { - return assets.map((asset) => { - const isMatch = picomatch(asset.glob, { - dot: true, - ignore: [...DEFAULT_ASSET_IGNORE, ...(asset.ignore ?? [])], +/** + * Returns cached or newly compiled 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 asset matcher descriptors. + */ +function getAssetMatchers(assets: NormalizedLibraryOptions['assets']): AssetMatcher[] { + let matchers = assetMatcherCache.get(assets); + if (!matchers) { + matchers = 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 }; }); - const prefix = asset.input.endsWith('/') ? asset.input : `${asset.input}/`; + assetMatcherCache.set(assets, matchers); + } - return { asset, prefix, isMatch }; - }); + return matchers; } 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 f121048b1fc7..46efd576fc81 100644 --- a/packages/angular/build/src/builders/library/pipeline/build-action.ts +++ b/packages/angular/build/src/builders/library/pipeline/build-action.ts @@ -9,15 +9,15 @@ import type { BuilderContext } from '@angular-devkit/architect'; 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 { NormalizedLibraryOptions, SingleBuildState } from '../types'; -import { collectAssetsToEmit } from './assets'; +import type { BundleResult, NormalizedLibraryOptions, SingleBuildState } from '../types'; +import { type CollectedAssets, collectAssetsToEmit } from './assets'; import { bundleEntryPoints } from './bundler'; import { type LibraryCompilationDiagnostics, compileLibrary } from './compilation'; import { collectEntryPointsToBundle } from './entry-points'; import { generatePackageManifests } from './package-manifests'; -import type { DiskOutputFile, OutputFile } from './utils'; +import type { OutputFile } from './utils'; /** * Result of executing a single library build iteration. @@ -41,6 +41,9 @@ export function createSingleBuildState(): SingleBuildState { previousBundleResults: new Map(), pendingChangedEsmFiles: new Set(), pendingChangedDtsFiles: new Set(), + emittedBundleFiles: new Set(), + emittedManifestFiles: new Set(), + emittedAssets: new Map(), }; } @@ -76,12 +79,12 @@ export interface BuildActionContext { modifiedFiles?: Set; /** - * Asset files to emit to disk 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 source paths with the file watcher. + * caller can register their POSIX source paths with the file watcher. */ - assetsToEmit?: DiskOutputFile[]; + collectedAssets?: CollectedAssets; } /** @@ -133,8 +136,12 @@ export async function buildAction(actionContext: BuildActionContext): Promise(); + let nextBundleFiles: Set | undefined; + let nextManifestFiles: Set | undefined; if (shouldCompileEntryPoints) { + const hadEntryPointsChanges = buildState.hasEntryPointsChanges; buildState.hasCompilationError = true; buildState.hasEntryPointsChanges = false; @@ -212,6 +219,18 @@ export async function buildAction(actionContext: BuildActionContext): Promise 0 || + hadEntryPointsChanges || + buildState.emittedBundleFiles.size === 0 + ) { + nextBundleFiles = collectBundleOutputFiles( + buildState.previousBundleResults.values(), + buildState.emittedBundleFiles, + filesToDelete, + ); + } } let hasTslibImport = false; @@ -223,27 +242,45 @@ export async function buildAction(actionContext: BuildActionContext): Promise file.path)); + for (const prevFile of buildState.emittedManifestFiles) { + if (!nextManifestFiles.has(prevFile)) { + filesToDelete.add(prevFile); + } + } } - const resolvedAssetsToEmit = (actionContext.assetsToEmit ??= await collectAssetsToEmit( + const collectedAssets = (actionContext.collectedAssets ??= await collectAssetsToEmit( options.assets, options.workspaceRoot, buildState.hasEmittedAssets ? modifiedFiles : undefined, + buildState.emittedAssets, )); - filesToEmit.push(...resolvedAssetsToEmit); + filesToEmit.push(...collectedAssets.filesToEmit); await emitFilesToDisk(filesToEmit, (file) => path.posix.join(options.outputPath, file.path)); + updateEmittedAssetState(buildState, collectedAssets, filesToDelete); + + if (filesToDelete.size) { + await deleteFilesFromDisk( + Array.from(filesToDelete, (file) => path.join(options.outputPath, file)), + ); + } + + buildState.emittedBundleFiles = nextBundleFiles ?? buildState.emittedBundleFiles; + buildState.emittedManifestFiles = nextManifestFiles ?? buildState.emittedManifestFiles; buildState.hasEmittedManifests = true; buildState.hasEmittedTslib = hasTslibImport; buildState.hasEmittedAssets = true; @@ -255,6 +292,79 @@ export async function buildAction(actionContext: BuildActionContext): Promise, + previousBundleFiles: ReadonlySet, + filesToDelete: Set, +): Set { + const nextBundleFiles = new Set(); + for (const result of bundleResults) { + if (result.esmOutputFiles) { + for (const file of result.esmOutputFiles) { + nextBundleFiles.add(file); + } + } + if (result.dtsOutputFiles) { + for (const file of result.dtsOutputFiles) { + nextBundleFiles.add(file); + } + } + } + + for (const prevFile of previousBundleFiles) { + if (!nextBundleFiles.has(prevFile)) { + filesToDelete.add(prevFile); + } + } + + return nextBundleFiles; +} + +/** + * Updates `buildState.emittedAssets` with newly emitted and deleted assets, queuing any + * deleted asset POSIX output paths that are not produced by another active asset for removal. + * + * @param buildState The state preserved across incremental builds. + * @param collectedAssets The collected asset files to emit and delete. + * @param filesToDelete Set of relative POSIX output paths queued for deletion. + */ +function updateEmittedAssetState( + buildState: SingleBuildState, + collectedAssets: CollectedAssets, + filesToDelete: Set, +): void { + const emittedAssets = buildState.emittedAssets; + const toDelete = collectedAssets.filesToDelete; + const toEmit = collectedAssets.filesToEmit; + + for (const deletedAsset of toDelete) { + emittedAssets.delete(deletedAsset.source); + } + + for (const emittedAsset of toEmit) { + emittedAssets.set(emittedAsset.source, emittedAsset.path); + } + + if (toDelete.length) { + const activeAssetOutputs = new Set(emittedAssets.values()); + + for (const deletedAsset of toDelete) { + if (!activeAssetOutputs.has(deletedAsset.path)) { + filesToDelete.add(deletedAsset.path); + } + } + } +} + /** * Checks whether any modified file (excluding `package.json`) is part of the * tracked compilation file set. diff --git a/packages/angular/build/src/builders/library/pipeline/bundler.ts b/packages/angular/build/src/builders/library/pipeline/bundler.ts index c48942885083..a8057a126a7d 100644 --- a/packages/angular/build/src/builders/library/pipeline/bundler.ts +++ b/packages/angular/build/src/builders/library/pipeline/bundler.ts @@ -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; @@ -117,6 +120,14 @@ export async function bundleEntryPoints( dtsOutput.moduleIdsByBundle.get(bundleName) ?? previousBundleResult?.dtsModuleIds ?? new Set(), + esmOutputFiles: + esmOutput.outputFilesByBundle.get(bundleName) ?? + previousBundleResult?.esmOutputFiles ?? + new Set(), + dtsOutputFiles: + dtsOutput.outputFilesByBundle.get(bundleName) ?? + previousBundleResult?.dtsOutputFiles ?? + new Set(), hasTslibImport: esmModuleIds ? esmOutput.tslibBundles.has(bundleName) : (previousBundleResult?.hasTslibImport ?? false), @@ -232,11 +243,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,7 +268,9 @@ function processRolldownOutput( for (const entryChunk of entryChunks) { const modSet = new Set(); + const outSet = new Set(); moduleIdsByBundle.set(entryChunk.name, modSet); + outputFilesByBundle.set(entryChunk.name, outSet); const visited = new Set(); const queue: OutputChunk[] = [entryChunk]; @@ -270,6 +286,11 @@ function processRolldownOutput( } visited.add(chunk); + outSet.add(path.posix.join(dir, chunk.fileName)); + const mapFileName = `${chunk.fileName}.map`; + if (outputFileNames.has(mapFileName)) { + outSet.add(path.posix.join(dir, mapFileName)); + } for (const modId of chunk.moduleIds) { if (modId[0] !== '\0') { @@ -290,7 +311,7 @@ function processRolldownOutput( } } - return { filesToEmit, moduleIdsByBundle, tslibBundles }; + return { filesToEmit, moduleIdsByBundle, outputFilesByBundle, tslibBundles }; } /** @@ -394,6 +415,7 @@ async function bundleAllEsm( return { filesToEmit: [], moduleIdsByBundle: new Map(), + outputFilesByBundle: new Map(), tslibBundles: new Set(), warnings: [], }; @@ -424,6 +446,7 @@ async function bundleAllDts( return { filesToEmit: [], moduleIdsByBundle: new Map(), + outputFilesByBundle: new Map(), tslibBundles: new Set(), warnings: [], }; diff --git a/packages/angular/build/src/builders/library/pipeline/entry-points.ts b/packages/angular/build/src/builders/library/pipeline/entry-points.ts index 5cff4ca82136..955ff3fa1904 100644 --- a/packages/angular/build/src/builders/library/pipeline/entry-points.ts +++ b/packages/angular/build/src/builders/library/pipeline/entry-points.ts @@ -260,6 +260,10 @@ export function updateWatchedEntryPoints( ); if (haveEntryPointsChanged(options.entryPoints, newEntryPoints)) { + for (const oldEntryPoint of options.entryPoints.values()) { + watchedCompilationFiles.delete(oldEntryPoint.entryFilePath); + } + for (const entryPoint of newEntryPoints.values()) { watchedCompilationFiles.add(entryPoint.entryFilePath); } 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 834b7aec6835..c79dc7c36c3e 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 @@ -7,7 +7,6 @@ */ import { join, resolve } from 'node:path'; -import { toPosixPath } from '../../../utils/path'; import type { BundleResult, NormalizedEntryPoint, @@ -530,7 +529,10 @@ describe('entry-points pipeline', () => { hasEntryPointsChanges: false, } as unknown as SingleBuildState; - const watchedCompilationFiles = new Set(); + const watchedCompilationFiles = new Set([ + join(projectRoot, 'src/public-api.ts'), + join(projectRoot, 'old-feature/src/public-api.ts'), + ]); const newPackageJson: PackageJsonData = { name: packageName, @@ -553,13 +555,12 @@ describe('entry-points pipeline', () => { expect(options.entryPoints.has('old-feature')).toBeFalse(); expect(buildState.previousBundleResults.has('old-feature')).toBeFalse(); expect(buildState.previousBundleResults.has('.')).toBeTrue(); + expect(watchedCompilationFiles.has(join(projectRoot, 'src/public-api.ts'))).toBeTrue(); expect( - watchedCompilationFiles.has(toPosixPath(join(projectRoot, 'src/public-api.ts'))), - ).toBeTrue(); + watchedCompilationFiles.has(join(projectRoot, 'old-feature/src/public-api.ts')), + ).toBeFalse(); expect( - watchedCompilationFiles.has( - toPosixPath(join(projectRoot, 'new-feature/src/public-api.ts')), - ), + watchedCompilationFiles.has(join(projectRoot, 'new-feature/src/public-api.ts')), ).toBeTrue(); }); @@ -657,9 +658,7 @@ describe('entry-points pipeline', () => { expect(options.entryPoints.get('.')?.entryFilePath).toBe( join(projectRoot, 'src/other-api.ts'), ); - expect( - watchedCompilationFiles.has(toPosixPath(join(projectRoot, 'src/other-api.ts'))), - ).toBeTrue(); + expect(watchedCompilationFiles.has(join(projectRoot, 'src/other-api.ts'))).toBeTrue(); }); it('should prune previousBundleResults when an entry point bundleName changes', () => { 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..b0096998496e --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/behavior/watch-cleanup_spec.ts @@ -0,0 +1,172 @@ +/** + * @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 + const pkg = JSON.parse(harness.readFile('projects/lib/package.json')); + pkg.exports = { + '.': './src/public-api.ts', + }; + await harness.writeFile('projects/lib/package.json', 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'); + }, + ]); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/tests/options/assets_spec.ts b/packages/angular/build/src/builders/library/tests/options/assets_spec.ts index ded7743675ff..a596cbfe4e62 100644 --- a/packages/angular/build/src/builders/library/tests/options/assets_spec.ts +++ b/packages/angular/build/src/builders/library/tests/options/assets_spec.ts @@ -15,6 +15,7 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => await harness.writeFiles({ 'projects/lib/assets-dir/file-a.png': 'PNG_A', 'projects/lib/assets-dir/file-b.png': 'PNG_B', + 'projects/lib/assets-dir/ignored.png': 'PNG_IGNORED', 'projects/lib/assets-dir/file-c.svg': 'SVG_C', }); @@ -25,6 +26,7 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => glob: '**/*.png', input: 'projects/lib/assets-dir', output: 'assets', + ignore: ['ignored.png'], }, ], }); @@ -34,6 +36,7 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => expect(harness.readFile('dist/lib/assets/file-a.png')).toBe('PNG_A'); expect(harness.readFile('dist/lib/assets/file-b.png')).toBe('PNG_B'); + expect(harness.hasFile('dist/lib/assets/ignored.png')).toBeFalse(); expect(harness.hasFile('dist/lib/assets/file-c.svg')).toBeFalse(); }); diff --git a/packages/angular/build/src/builders/library/types.ts b/packages/angular/build/src/builders/library/types.ts index 2deeddf589e5..b6df21d12610 100644 --- a/packages/angular/build/src/builders/library/types.ts +++ b/packages/angular/build/src/builders/library/types.ts @@ -118,6 +118,12 @@ 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; } @@ -158,6 +164,15 @@ export interface SingleBuildState { /** Set of changed POSIX TypeScript declaration file paths pending bundling. */ pendingChangedDtsFiles: Set; + /** Set of relative POSIX output paths for bundle files emitted in the previous build iteration. */ + emittedBundleFiles: Set; + + /** Set of relative POSIX output paths for package manifest files emitted in the previous build iteration. */ + emittedManifestFiles: Set; + + /** Map of normalized POSIX asset source paths to their relative POSIX output paths. */ + emittedAssets: Map; + /** Whether the previous build iteration encountered a compilation or diagnostic error. */ hasCompilationError?: boolean;