Skip to content

Commit 6d590c8

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. Also normalize paths across the library builder pipeline to POSIX format.
1 parent 639d31f commit 6d590c8

14 files changed

Lines changed: 615 additions & 177 deletions

File tree

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

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,10 @@ export async function* executeLibraryBuilder(
111111
);
112112

113113
// Track all referenced compilation files for watch mode
114-
const watchedCompilationFiles = new Set<string>([
115-
toPosixPath(tsConfigPath),
116-
toPosixPath(packageJsonPath),
117-
]);
114+
const watchedCompilationFiles = new Set<string>([tsConfigPath, packageJsonPath]);
118115

119116
for (const entryPoint of normalizedOptions.entryPoints.values()) {
120-
watchedCompilationFiles.add(toPosixPath(entryPoint.entryFilePath));
117+
watchedCompilationFiles.add(entryPoint.entryFilePath);
121118
}
122119

123120
if (isWatchMode) {
@@ -130,7 +127,10 @@ export async function* executeLibraryBuilder(
130127
poll,
131128
preserveSymlinks,
132129
signal,
133-
watchFiles: watchedCompilationFiles,
130+
watchFiles: [
131+
...watchedCompilationFiles,
132+
...normalizedOptions.assets.map((asset) => asset.input),
133+
],
134134
});
135135

136136
context.addTeardown?.(() => void watcher?.close());
@@ -227,16 +227,27 @@ async function executeBuild(
227227
if (watcher) {
228228
watcher.add(Array.from(watchedCompilationFiles));
229229

230-
const { assetsToEmit } = actionContext;
231-
if (assetsToEmit?.length) {
232-
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));
233233
}
234234
}
235235
}
236236
}
237237

238238
/**
239239
* Runs the watch loop, rebuilding the library as watched files are modified.
240+
*
241+
* @param watcher The file watcher instance.
242+
* @param options The normalized library builder options with POSIX paths.
243+
* @param stylesheetBundler The component stylesheet bundler instance.
244+
* @param watchedCompilationFiles Set of POSIX file paths tracked for compilation.
245+
* @param context The Architect builder context.
246+
* @param withProgress Progress wrapper function (`withSpinner` or `withNoProgress`).
247+
* @param buildState The state preserved across incremental builds.
248+
* @param buildAction The library build action function to execute.
249+
* @param hasModifiedWatchedFile Helper to check whether watched compilation files changed.
250+
* @param signal Optional abort signal to terminate the watch loop.
240251
*/
241252
async function* runWatchLoop(
242253
watcher: BuildWatcher,
@@ -253,7 +264,6 @@ async function* runWatchLoop(
253264
const { collectAssetsToEmit } = await import('./pipeline/assets');
254265

255266
const { workspaceRoot, packageJsonPath, assets, clearScreen } = options;
256-
const posixPackageJsonPath = toPosixPath(packageJsonPath);
257267

258268
for await (const changes of watcher) {
259269
if (signal?.aborted) {
@@ -293,13 +303,13 @@ async function* runWatchLoop(
293303

294304
// Check if package.json was modified
295305
let hasPackageJsonChanges = false;
296-
if (changedFiles.has(posixPackageJsonPath)) {
306+
if (changedFiles.has(packageJsonPath)) {
297307
try {
298308
const packageJson = await loadPackageJson(packageJsonPath);
299-
options.packageJson = packageJson;
300309
if (!packageJson.name) {
301310
throw new Error(`The package.json at '${packageJsonPath}' must contain a 'name'.`);
302311
}
312+
options.packageJson = packageJson;
303313
options.packageName = packageJson.name;
304314

305315
updateWatchedEntryPoints(
@@ -328,15 +338,21 @@ async function* runWatchLoop(
328338
!buildState.singleProgramCache ||
329339
buildState.hasCompilationError ||
330340
buildState.hasEntryPointsChanges ||
331-
hasModifiedWatchedFile(changedFiles, watchedCompilationFiles, posixPackageJsonPath);
341+
hasModifiedWatchedFile(changedFiles, watchedCompilationFiles, packageJsonPath);
332342

333-
const assetsToEmit = await collectAssetsToEmit(
343+
const collectedAssets = await collectAssetsToEmit(
334344
assets,
335345
workspaceRoot,
336346
buildState.hasEmittedAssets ? changedFiles : undefined,
347+
buildState.emittedAssets,
337348
);
338349

339-
if (!hasSourceChanges && !hasPackageJsonChanges && assetsToEmit.length === 0) {
350+
if (
351+
!hasSourceChanges &&
352+
!hasPackageJsonChanges &&
353+
collectedAssets.filesToEmit.length === 0 &&
354+
collectedAssets.filesToDelete.length === 0
355+
) {
340356
continue;
341357
}
342358

@@ -350,7 +366,7 @@ async function* runWatchLoop(
350366
context,
351367
buildState,
352368
modifiedFiles: changedFiles,
353-
assetsToEmit,
369+
collectedAssets,
354370
},
355371
withProgress,
356372
watcher,
@@ -388,7 +404,7 @@ function logMessages(
388404
* @param logger The Architect logger API.
389405
* @param startTime High-resolution timestamp recorded when the build iteration started.
390406
* @param success Whether the build iteration succeeded without errors.
391-
* @param outputPath Optional output directory path to log on successful builds.
407+
* @param outputPath Optional POSIX output directory path to log on successful builds.
392408
*/
393409
function logBuildResult(
394410
logger: logging.LoggerApi,

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

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { normalizeAssetPatterns } from '../../utils';
1313
import { supportColor } from '../../utils/color';
1414
import { assertIsError } from '../../utils/error';
1515
import { normalizeCacheOptions } from '../../utils/normalize-cache';
16+
import { toPosixPath } from '../../utils/path';
1617
import {
1718
generateSearchDirectories,
1819
getTailwindConfig,
@@ -24,14 +25,25 @@ import { loadPackageJson } from './pipeline/utils';
2425
import type { Schema as LibraryBuilderOptions } from './schema';
2526
import type { NormalizedLibraryOptions, PackageJsonData } from './types';
2627

28+
/**
29+
* Normalizes raw library builder options into {@link NormalizedLibraryOptions} with all
30+
* file and directory paths normalized to POSIX format.
31+
*
32+
* @param context The Architect builder context.
33+
* @param projectName The name of the library project.
34+
* @param options The raw library builder schema options.
35+
* @returns A promise resolving to the normalized library options.
36+
*/
2737
export async function normalizeLibraryOptions(
2838
context: BuilderContext,
2939
projectName: string,
3040
options: LibraryBuilderOptions,
3141
): Promise<NormalizedLibraryOptions> {
32-
const { workspaceRoot } = context;
42+
const workspaceRoot = toPosixPath(context.workspaceRoot);
3343
const projectMetadata = await context.getProjectMetadata(projectName);
34-
const { projectRoot, projectSourceRoot } = getProjectRootPaths(workspaceRoot, projectMetadata);
44+
const projectRootPaths = getProjectRootPaths(workspaceRoot, projectMetadata);
45+
const projectRoot = toPosixPath(projectRootPaths.projectRoot);
46+
const projectSourceRoot = toPosixPath(projectRootPaths.projectSourceRoot);
3547

3648
const {
3749
tsConfig,
@@ -50,8 +62,8 @@ export async function normalizeLibraryOptions(
5062
clearScreen,
5163
} = options;
5264

53-
const resolvedTsConfigPath = path.resolve(workspaceRoot, tsConfig);
54-
const packageJsonPath = path.join(projectRoot, 'package.json');
65+
const resolvedTsConfigPath = toPosixPath(path.resolve(workspaceRoot, tsConfig));
66+
const packageJsonPath = path.posix.join(projectRoot, 'package.json');
5567

5668
let packageJson: PackageJsonData;
5769
try {
@@ -99,7 +111,7 @@ export async function normalizeLibraryOptions(
99111
}
100112
defaultAssets.push({
101113
glob: 'README.md',
102-
input: path.dirname(entryPoint.entryFilePath),
114+
input: path.posix.dirname(entryPoint.entryFilePath),
103115
output: entryPoint.name,
104116
});
105117
}
@@ -109,7 +121,11 @@ export async function normalizeLibraryOptions(
109121
workspaceRoot,
110122
projectRoot,
111123
projectSourceRoot,
112-
);
124+
).map((asset) => ({
125+
...asset,
126+
input: toPosixPath(path.resolve(workspaceRoot, asset.input)),
127+
output: toPosixPath(asset.output),
128+
}));
113129

114130
const cacheOptions = normalizeCacheOptions(
115131
projectMetadata,
@@ -119,7 +135,7 @@ export async function normalizeLibraryOptions(
119135
);
120136

121137
const styleIncludePaths = (stylePreprocessorOptions?.includePaths ?? []).map((p: string) =>
122-
path.resolve(workspaceRoot, p),
138+
toPosixPath(path.resolve(workspaceRoot, p)),
123139
);
124140

125141
const searchDirectories = await generateSearchDirectories([projectRoot, workspaceRoot]);
@@ -133,7 +149,9 @@ export async function normalizeLibraryOptions(
133149
projectRoot,
134150
packageName,
135151
packageJson,
136-
outputPath: path.resolve(workspaceRoot, options.outputPath ?? path.join('dist', projectName)),
152+
outputPath: toPosixPath(
153+
path.resolve(workspaceRoot, options.outputPath ?? path.posix.join('dist', projectName)),
154+
),
137155
deleteOutputPath,
138156
packageJsonPath,
139157
tsConfigPath: resolvedTsConfigPath,

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

Lines changed: 72 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,69 +9,115 @@
99
import { statSync } from 'node:fs';
1010
import path from 'node:path';
1111
import picomatch from 'picomatch';
12-
import { toPosixPath } from '../../../utils/path';
1312
import { DEFAULT_ASSET_IGNORE, resolveAssets } from '../../../utils/resolve-assets';
1413
import type { NormalizedLibraryOptions } from '../types';
1514
import { type DiskOutputFile, createDiskOutputFile } from './utils';
1615

1716
/**
18-
* 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.
1937
*
20-
* @param assets The normalized asset patterns.
21-
* @param workspaceRoot The workspace root directory path.
22-
* @param modifiedFiles Optional set of modified file paths for incremental copying in watch mode.
23-
* @returns An array of disk file emission descriptors.
38+
* @param assets The normalized asset patterns with POSIX input and output paths.
39+
* @param workspaceRoot The workspace root POSIX directory path.
40+
* @param modifiedFiles Optional set of modified POSIX file paths for incremental copying in watch mode.
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.
2443
*/
2544
export async function collectAssetsToEmit(
2645
assets: NormalizedLibraryOptions['assets'],
2746
workspaceRoot: string,
2847
modifiedFiles?: ReadonlySet<string>,
29-
): Promise<DiskOutputFile[]> {
48+
emittedAssets?: ReadonlyMap<string, string>,
49+
): Promise<CollectedAssets> {
3050
if (assets.length === 0 || modifiedFiles?.size === 0) {
31-
return [];
51+
return { filesToEmit: [], filesToDelete: [] };
3252
}
3353

3454
if (modifiedFiles) {
35-
const matchers = createAssetMatchers(assets, workspaceRoot);
55+
const matchers = getAssetMatchers(assets);
3656
const filesToEmit: DiskOutputFile[] = [];
57+
const filesToDelete: DiskOutputFile[] = [];
3758

3859
for (const file of modifiedFiles) {
39-
const resolvedFile = path.isAbsolute(file) ? file : path.resolve(workspaceRoot, file);
40-
const posixFile = toPosixPath(resolvedFile);
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+
}
4170

4271
for (const { asset, posixInputPrefix, isMatch } of matchers) {
43-
if (!posixFile.startsWith(posixInputPrefix)) {
72+
if (!file.startsWith(posixInputPrefix)) {
4473
continue;
4574
}
4675

47-
const relative = posixFile.slice(posixInputPrefix.length);
76+
const relative = file.slice(posixInputPrefix.length);
4877
if (!isMatch(relative)) {
4978
continue;
5079
}
5180

52-
if (statSync(resolvedFile, { throwIfNoEntry: false })?.isFile()) {
53-
filesToEmit.push(createDiskOutputFile(resolvedFile, path.join(asset.output, relative)));
81+
isFile ??= statSync(file, { throwIfNoEntry: false })?.isFile() ?? false;
82+
if (isFile) {
83+
filesToEmit.push(createDiskOutputFile(file, path.posix.join(asset.output, relative)));
5484
}
5585
}
5686
}
5787

58-
return filesToEmit;
88+
return { filesToEmit, filesToDelete };
5989
}
6090

6191
const resolvedAssets = await resolveAssets(assets, workspaceRoot);
6292

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

66-
function createAssetMatchers(assets: NormalizedLibraryOptions['assets'], workspaceRoot: string) {
67-
return assets.map((asset) => {
68-
const absInput = path.resolve(workspaceRoot, asset.input);
69-
const posixInput = toPosixPath(absInput).replace(/\/+$/, '');
70-
const isMatch = picomatch(asset.glob, {
71-
dot: true,
72-
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 };
73118
});
119+
assetMatcherCache.set(assets, matchers);
120+
}
74121

75-
return { asset, posixInputPrefix: `${posixInput}/`, isMatch };
76-
});
122+
return matchers;
77123
}

0 commit comments

Comments
 (0)