Skip to content

Commit 202d2f0

Browse files
committed
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.
1 parent a3b6d48 commit 202d2f0

9 files changed

Lines changed: 440 additions & 54 deletions

File tree

‎packages/angular/build/src/builders/library/builder.ts‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,10 @@ export async function* executeLibraryBuilder(
127127
poll,
128128
preserveSymlinks,
129129
signal,
130-
watchFiles: watchedCompilationFiles,
130+
watchFiles: [
131+
...watchedCompilationFiles,
132+
...normalizedOptions.assets.map((asset) => asset.input),
133+
],
131134
});
132135

133136
context.addTeardown?.(() => void watcher?.close());
@@ -224,9 +227,9 @@ async function executeBuild(
224227
if (watcher) {
225228
watcher.add(Array.from(watchedCompilationFiles));
226229

227-
const { assetsToEmit } = actionContext;
228-
if (assetsToEmit?.length) {
229-
watcher.add(assetsToEmit.map((asset) => asset.source));
230+
const { collectedAssets } = actionContext;
231+
if (collectedAssets?.filesToEmit.length) {
232+
watcher.add(collectedAssets.filesToEmit.map((asset) => asset.source));
230233
}
231234
}
232235
}
@@ -337,13 +340,19 @@ async function* runWatchLoop(
337340
buildState.hasEntryPointsChanges ||
338341
hasModifiedWatchedFile(changedFiles, watchedCompilationFiles, packageJsonPath);
339342

340-
const assetsToEmit = await collectAssetsToEmit(
343+
const collectedAssets = await collectAssetsToEmit(
341344
assets,
342345
workspaceRoot,
343346
buildState.hasEmittedAssets ? changedFiles : undefined,
347+
buildState.emittedAssets,
344348
);
345349

346-
if (!hasSourceChanges && !hasPackageJsonChanges && assetsToEmit.length === 0) {
350+
if (
351+
!hasSourceChanges &&
352+
!hasPackageJsonChanges &&
353+
collectedAssets.filesToEmit.length === 0 &&
354+
collectedAssets.filesToDelete.length === 0
355+
) {
347356
continue;
348357
}
349358

@@ -357,7 +366,7 @@ async function* runWatchLoop(
357366
context,
358367
buildState,
359368
modifiedFiles: changedFiles,
360-
assetsToEmit,
369+
collectedAssets,
361370
},
362371
withProgress,
363372
watcher,

‎packages/angular/build/src/builders/library/pipeline/assets.ts‎

Lines changed: 69 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,27 +14,60 @@ import type { NormalizedLibraryOptions } from '../types';
1414
import { type DiskOutputFile, createDiskOutputFile } from './utils';
1515

1616
/**
17-
* Resolves and collects configured library assets to be emitted to disk.
17+
* Result of resolving and collecting library assets for a build iteration.
18+
*/
19+
export interface CollectedAssets {
20+
/** Asset files to emit to disk, with POSIX source and destination paths. */
21+
filesToEmit: DiskOutputFile[];
22+
23+
/** Previously emitted asset files whose source files were deleted and should be removed from output. */
24+
filesToDelete: DiskOutputFile[];
25+
}
26+
27+
interface AssetMatcher {
28+
asset: NormalizedLibraryOptions['assets'][number];
29+
prefix: string;
30+
isMatch: picomatch.Matcher;
31+
}
32+
33+
const assetMatcherCache = new WeakMap<NormalizedLibraryOptions['assets'], AssetMatcher[]>();
34+
35+
/**
36+
* Resolves and collects configured library assets to be emitted to or deleted from disk.
1837
*
19-
* @param assets The normalized asset patterns with POSIX paths.
20-
* @param workspaceRoot The normalized POSIX workspace root directory path.
38+
* @param assets The normalized asset patterns with POSIX input and output paths.
39+
* @param workspaceRoot The workspace root POSIX directory path.
2140
* @param modifiedFiles Optional set of modified POSIX file paths for incremental copying in watch mode.
22-
* @returns An array of disk file emission descriptors.
41+
* @param emittedAssets Optional map of previously emitted POSIX source file paths to relative POSIX output paths.
42+
* @returns The collected asset files to emit and delete.
2343
*/
2444
export async function collectAssetsToEmit(
2545
assets: NormalizedLibraryOptions['assets'],
2646
workspaceRoot: string,
2747
modifiedFiles?: ReadonlySet<string>,
28-
): Promise<DiskOutputFile[]> {
48+
emittedAssets?: ReadonlyMap<string, string>,
49+
): Promise<CollectedAssets> {
2950
if (assets.length === 0 || modifiedFiles?.size === 0) {
30-
return [];
51+
return { filesToEmit: [], filesToDelete: [] };
3152
}
3253

3354
if (modifiedFiles) {
34-
const matchers = createAssetMatchers(assets);
55+
const matchers = getAssetMatchers(assets);
3556
const filesToEmit: DiskOutputFile[] = [];
57+
const filesToDelete: DiskOutputFile[] = [];
3658

3759
for (const file of modifiedFiles) {
60+
let isFile: boolean | undefined;
61+
62+
const previousOutput = emittedAssets?.get(file);
63+
if (previousOutput !== undefined) {
64+
isFile = statSync(file, { throwIfNoEntry: false })?.isFile() ?? false;
65+
if (!isFile) {
66+
filesToDelete.push(createDiskOutputFile(file, previousOutput));
67+
continue;
68+
}
69+
}
70+
3871
for (const { asset, prefix, isMatch } of matchers) {
3972
if (!file.startsWith(prefix)) {
4073
continue;
@@ -45,28 +78,46 @@ export async function collectAssetsToEmit(
4578
continue;
4679
}
4780

48-
if (statSync(file, { throwIfNoEntry: false })?.isFile()) {
81+
isFile ??= statSync(file, { throwIfNoEntry: false })?.isFile() ?? false;
82+
if (isFile) {
4983
filesToEmit.push(createDiskOutputFile(file, path.posix.join(asset.output, relative)));
5084
}
5185
}
5286
}
5387

54-
return filesToEmit;
88+
return { filesToEmit, filesToDelete };
5589
}
5690

5791
const resolvedAssets = await resolveAssets(assets, workspaceRoot);
5892

59-
return resolvedAssets.map(({ source, destination }) => createDiskOutputFile(source, destination));
93+
return {
94+
filesToEmit: resolvedAssets.map(({ source, destination }) =>
95+
createDiskOutputFile(source, destination),
96+
),
97+
filesToDelete: [],
98+
};
6099
}
61100

62-
function createAssetMatchers(assets: NormalizedLibraryOptions['assets']) {
63-
return assets.map((asset) => {
64-
const isMatch = picomatch(asset.glob, {
65-
dot: true,
66-
ignore: [...DEFAULT_ASSET_IGNORE, ...(asset.ignore ?? [])],
101+
/**
102+
* Returns cached or newly compiled picomatch matchers and POSIX input prefixes for each asset configuration.
103+
*
104+
* @param assets The normalized asset patterns with POSIX input and output paths.
105+
* @returns An array of asset matcher descriptors.
106+
*/
107+
function getAssetMatchers(assets: NormalizedLibraryOptions['assets']): AssetMatcher[] {
108+
let matchers = assetMatcherCache.get(assets);
109+
if (!matchers) {
110+
matchers = assets.map((asset) => {
111+
const isMatch = picomatch(asset.glob, {
112+
dot: true,
113+
ignore: [...DEFAULT_ASSET_IGNORE, ...(asset.ignore ?? [])],
114+
});
115+
const prefix = asset.input.endsWith('/') ? asset.input : `${asset.input}/`;
116+
117+
return { asset, prefix, isMatch };
67118
});
68-
const prefix = asset.input.endsWith('/') ? asset.input : `${asset.input}/`;
119+
assetMatcherCache.set(assets, matchers);
120+
}
69121

70-
return { asset, prefix, isMatch };
71-
});
122+
return matchers;
72123
}

‎packages/angular/build/src/builders/library/pipeline/build-action.ts‎

Lines changed: 128 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,15 @@
99
import type { BuilderContext } from '@angular-devkit/architect';
1010
import path from 'node:path';
1111
import type { ComponentStylesheetBundler } from '../../../tools/esbuild/angular/component-stylesheets';
12-
import { emitFilesToDisk } from '../../../utils/disk-files';
12+
import { deleteFilesFromDisk, emitFilesToDisk } from '../../../utils/disk-files';
1313
import { assertIsError } from '../../../utils/error';
14-
import type { NormalizedLibraryOptions, SingleBuildState } from '../types';
15-
import { collectAssetsToEmit } from './assets';
14+
import type { BundleResult, NormalizedLibraryOptions, SingleBuildState } from '../types';
15+
import { type CollectedAssets, collectAssetsToEmit } from './assets';
1616
import { bundleEntryPoints } from './bundler';
1717
import { type LibraryCompilationDiagnostics, compileLibrary } from './compilation';
1818
import { collectEntryPointsToBundle } from './entry-points';
1919
import { generatePackageManifests } from './package-manifests';
20-
import type { DiskOutputFile, OutputFile } from './utils';
20+
import type { OutputFile } from './utils';
2121

2222
/**
2323
* Result of executing a single library build iteration.
@@ -41,6 +41,9 @@ export function createSingleBuildState(): SingleBuildState {
4141
previousBundleResults: new Map(),
4242
pendingChangedEsmFiles: new Set(),
4343
pendingChangedDtsFiles: new Set(),
44+
emittedBundleFiles: new Set(),
45+
emittedManifestFiles: new Set(),
46+
emittedAssets: new Map(),
4447
};
4548
}
4649

@@ -76,12 +79,12 @@ export interface BuildActionContext {
7679
modifiedFiles?: Set<string>;
7780

7881
/**
79-
* Asset files to emit to disk for the current build iteration.
82+
* Collected asset changes for the current build iteration.
8083
* Pre-collected in the watch loop to avoid redundant asset matching, or resolved and
8184
* populated by `buildAction` when omitted (such as during the initial build) so the
82-
* caller can register their source paths with the file watcher.
85+
* caller can register their POSIX source paths with the file watcher.
8386
*/
84-
assetsToEmit?: DiskOutputFile[];
87+
collectedAssets?: CollectedAssets;
8588
}
8689

8790
/**
@@ -133,8 +136,12 @@ export async function buildAction(actionContext: BuildActionContext): Promise<Bu
133136
}
134137

135138
const filesToEmit: OutputFile[] = [];
139+
const filesToDelete = new Set<string>();
140+
let nextBundleFiles: Set<string> | undefined;
141+
let nextManifestFiles: Set<string> | undefined;
136142

137143
if (shouldCompileEntryPoints) {
144+
const hadEntryPointsChanges = buildState.hasEntryPointsChanges;
138145
buildState.hasCompilationError = true;
139146
buildState.hasEntryPointsChanges = false;
140147

@@ -212,6 +219,18 @@ export async function buildAction(actionContext: BuildActionContext): Promise<Bu
212219
for (const [name, bundleResult] of bundleOutput.bundleResults) {
213220
buildState.previousBundleResults.set(name, bundleResult);
214221
}
222+
223+
if (
224+
bundleOutput.bundleResults.size > 0 ||
225+
hadEntryPointsChanges ||
226+
buildState.emittedBundleFiles.size === 0
227+
) {
228+
nextBundleFiles = collectBundleOutputFiles(
229+
buildState.previousBundleResults.values(),
230+
buildState.emittedBundleFiles,
231+
filesToDelete,
232+
);
233+
}
215234
}
216235

217236
let hasTslibImport = false;
@@ -223,27 +242,45 @@ export async function buildAction(actionContext: BuildActionContext): Promise<Bu
223242
}
224243

225244
if (!buildState.hasEmittedManifests || buildState.hasEmittedTslib !== hasTslibImport) {
226-
filesToEmit.push(
227-
...(await generatePackageManifests(
228-
{
229-
...options,
230-
compilationMode: buildState.compilationMode ?? options.compilationMode ?? 'partial',
231-
},
232-
isWatchMode,
233-
hasTslibImport,
234-
)),
245+
const manifestFiles = await generatePackageManifests(
246+
{
247+
...options,
248+
compilationMode: buildState.compilationMode ?? options.compilationMode ?? 'partial',
249+
},
250+
isWatchMode,
251+
hasTslibImport,
235252
);
253+
254+
filesToEmit.push(...manifestFiles);
255+
256+
nextManifestFiles = new Set(manifestFiles.map((file) => file.path));
257+
for (const prevFile of buildState.emittedManifestFiles) {
258+
if (!nextManifestFiles.has(prevFile)) {
259+
filesToDelete.add(prevFile);
260+
}
261+
}
236262
}
237263

238-
const resolvedAssetsToEmit = (actionContext.assetsToEmit ??= await collectAssetsToEmit(
264+
const collectedAssets = (actionContext.collectedAssets ??= await collectAssetsToEmit(
239265
options.assets,
240266
options.workspaceRoot,
241267
buildState.hasEmittedAssets ? modifiedFiles : undefined,
268+
buildState.emittedAssets,
242269
));
243-
filesToEmit.push(...resolvedAssetsToEmit);
270+
filesToEmit.push(...collectedAssets.filesToEmit);
244271

245272
await emitFilesToDisk(filesToEmit, (file) => path.posix.join(options.outputPath, file.path));
246273

274+
updateEmittedAssetState(buildState, collectedAssets, filesToDelete);
275+
276+
if (filesToDelete.size) {
277+
await deleteFilesFromDisk(
278+
Array.from(filesToDelete, (file) => path.join(options.outputPath, file)),
279+
);
280+
}
281+
282+
buildState.emittedBundleFiles = nextBundleFiles ?? buildState.emittedBundleFiles;
283+
buildState.emittedManifestFiles = nextManifestFiles ?? buildState.emittedManifestFiles;
247284
buildState.hasEmittedManifests = true;
248285
buildState.hasEmittedTslib = hasTslibImport;
249286
buildState.hasEmittedAssets = true;
@@ -255,6 +292,79 @@ export async function buildAction(actionContext: BuildActionContext): Promise<Bu
255292
return { errors, warnings, logs };
256293
}
257294

295+
/**
296+
* Collects the active POSIX bundle output file paths across all entry points and queues any
297+
* previously emitted bundle files that are no longer present for deletion.
298+
*
299+
* @param bundleResults Active bundle results across all entry points.
300+
* @param previousBundleFiles Relative POSIX output paths emitted in the previous build iteration.
301+
* @param filesToDelete Set of relative POSIX output paths queued for deletion.
302+
* @returns The new set of active relative POSIX bundle output file paths.
303+
*/
304+
function collectBundleOutputFiles(
305+
bundleResults: Iterable<BundleResult>,
306+
previousBundleFiles: ReadonlySet<string>,
307+
filesToDelete: Set<string>,
308+
): Set<string> {
309+
const nextBundleFiles = new Set<string>();
310+
for (const result of bundleResults) {
311+
if (result.esmOutputFiles) {
312+
for (const file of result.esmOutputFiles) {
313+
nextBundleFiles.add(file);
314+
}
315+
}
316+
if (result.dtsOutputFiles) {
317+
for (const file of result.dtsOutputFiles) {
318+
nextBundleFiles.add(file);
319+
}
320+
}
321+
}
322+
323+
for (const prevFile of previousBundleFiles) {
324+
if (!nextBundleFiles.has(prevFile)) {
325+
filesToDelete.add(prevFile);
326+
}
327+
}
328+
329+
return nextBundleFiles;
330+
}
331+
332+
/**
333+
* Updates `buildState.emittedAssets` with newly emitted and deleted assets, queuing any
334+
* deleted asset POSIX output paths that are not produced by another active asset for removal.
335+
*
336+
* @param buildState The state preserved across incremental builds.
337+
* @param collectedAssets The collected asset files to emit and delete.
338+
* @param filesToDelete Set of relative POSIX output paths queued for deletion.
339+
*/
340+
function updateEmittedAssetState(
341+
buildState: SingleBuildState,
342+
collectedAssets: CollectedAssets,
343+
filesToDelete: Set<string>,
344+
): void {
345+
const emittedAssets = buildState.emittedAssets;
346+
const toDelete = collectedAssets.filesToDelete;
347+
const toEmit = collectedAssets.filesToEmit;
348+
349+
for (const deletedAsset of toDelete) {
350+
emittedAssets.delete(deletedAsset.source);
351+
}
352+
353+
for (const emittedAsset of toEmit) {
354+
emittedAssets.set(emittedAsset.source, emittedAsset.path);
355+
}
356+
357+
if (toDelete.length) {
358+
const activeAssetOutputs = new Set(emittedAssets.values());
359+
360+
for (const deletedAsset of toDelete) {
361+
if (!activeAssetOutputs.has(deletedAsset.path)) {
362+
filesToDelete.add(deletedAsset.path);
363+
}
364+
}
365+
}
366+
}
367+
258368
/**
259369
* Checks whether any modified file (excluding `package.json`) is part of the
260370
* tracked compilation file set.

0 commit comments

Comments
 (0)