Skip to content

Commit ab8ae6c

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 d71c830 commit ab8ae6c

9 files changed

Lines changed: 443 additions & 56 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: 72 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,58 +14,110 @@ 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+
posixInputPrefix: 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) {
38-
for (const { asset, isMatch } of matchers) {
39-
const relative = path.posix.relative(asset.input, file);
40-
if (!relative || relative.startsWith('..') || path.posix.isAbsolute(relative)) {
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));
4167
continue;
4268
}
69+
}
4370

71+
for (const { asset, posixInputPrefix, isMatch } of matchers) {
72+
if (!file.startsWith(posixInputPrefix)) {
73+
continue;
74+
}
75+
76+
const relative = file.slice(posixInputPrefix.length);
4477
if (!isMatch(relative)) {
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 posixInput = asset.input.replace(/\/+$/, '');
112+
const isMatch = picomatch(asset.glob, {
113+
dot: true,
114+
ignore: [...DEFAULT_ASSET_IGNORE, ...(asset.ignore ?? [])],
115+
});
116+
117+
return { asset, posixInputPrefix: `${posixInput}/`, isMatch };
67118
});
119+
assetMatcherCache.set(assets, matchers);
120+
}
68121

69-
return { asset, isMatch };
70-
});
122+
return matchers;
71123
}

0 commit comments

Comments
 (0)