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;