Skip to content

Commit d2acc66

Browse files
committed
refactor(@angular/build): simplify watch-mode asset precedence and ignore normalization
Simplify incremental asset precedence checks in `collectAssetsToEmit` by relying on in-memory `emittedAssets` state rather than redundant filesystem `statSync` calls, and index emitted asset sources by output path in `BuilderState` for O(1) eviction. Also expand wildcard-suffixed ignore patterns so both direct files and nested directory contents are ignored in watch mode.
1 parent 193abc0 commit d2acc66

3 files changed

Lines changed: 75 additions & 92 deletions

File tree

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

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export class BuilderState {
3737
#emittedBundleFiles = new Set<string>();
3838
#emittedManifestFiles = new Set<string>();
3939
readonly #emittedAssets = new Map<string, Set<string>>();
40+
readonly #emittedAssetSources = new Map<string, string>();
4041
#hasEmittedAssets = false;
4142

4243
#executionResult = new ExecutionResult();
@@ -421,13 +422,13 @@ export class BuilderState {
421422
continue;
422423
}
423424

424-
if (this.#hasEmittedAssets) {
425-
for (const [source, prevOutputs] of this.#emittedAssets) {
426-
if (prevOutputs.delete(emittedAsset.path)) {
427-
if (prevOutputs.size === 0) {
428-
this.#emittedAssets.delete(source);
429-
}
430-
break;
425+
const prevSource = this.#emittedAssetSources.get(emittedAsset.path);
426+
if (prevSource !== undefined) {
427+
const prevOutputs = this.#emittedAssets.get(prevSource);
428+
if (prevOutputs) {
429+
prevOutputs.delete(emittedAsset.path);
430+
if (prevOutputs.size === 0) {
431+
this.#emittedAssets.delete(prevSource);
431432
}
432433
}
433434
}
@@ -437,12 +438,14 @@ export class BuilderState {
437438
this.#emittedAssets.set(emittedAsset.source, outputs);
438439
}
439440
outputs.add(emittedAsset.path);
441+
this.#emittedAssetSources.set(emittedAsset.path, emittedAsset.source);
440442
}
441443

442444
for (const source of toDelete) {
443445
const outputs = this.#emittedAssets.get(source);
444446
if (outputs) {
445447
for (const output of outputs) {
448+
this.#emittedAssetSources.delete(output);
446449
filesToDelete.add(output);
447450
}
448451
this.#emittedAssets.delete(source);
@@ -481,6 +484,7 @@ export class BuilderState {
481484
this.#emittedBundleFiles.clear();
482485
this.#emittedManifestFiles.clear();
483486
this.#emittedAssets.clear();
487+
this.#emittedAssetSources.clear();
484488
this.#hasUpdatedBundles = false;
485489
}
486490
}

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

Lines changed: 42 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -39,19 +39,27 @@ export interface AssetMatcher {
3939
}
4040

4141
/**
42-
* Normalizes a glob pattern to match `tinyglobby` semantics by stripping trailing slashes,
43-
* expanding directory patterns with `/**`, and normalizing POSIX segments.
42+
* Normalizes an ignore glob pattern for `picomatch` to match `tinyglobby` directory-exclusion
43+
* semantics by stripping trailing slashes, normalizing POSIX segments, and expanding patterns
44+
* with `/**` (returning both `normalized` and `${normalized}/**` for single-wildcard suffixes
45+
* because `picomatch` does not make trailing `/**` optional after `*`).
4446
*
45-
* @param pattern The raw glob pattern string.
46-
* @returns The normalized POSIX glob pattern.
47+
* @param pattern The raw ignore glob pattern string.
48+
* @returns The normalized POSIX glob patterns.
4749
*/
48-
function normalizeGlobPattern(pattern: string): string {
49-
let result = pattern.endsWith('/') ? pattern.slice(0, -1) : pattern;
50-
if (pattern.endsWith('/') || !result.endsWith('*')) {
51-
result += '/**';
50+
function normalizeGlobPattern(pattern: string): string[] {
51+
const trimmed = pattern.endsWith('/') ? pattern.slice(0, -1) : pattern;
52+
const normalized = path.posix.normalize(trimmed);
53+
54+
if (!normalized.endsWith('*')) {
55+
return [`${normalized}/**`];
56+
}
57+
58+
if (normalized.endsWith('/**')) {
59+
return [normalized];
5260
}
5361

54-
return path.posix.normalize(result);
62+
return [normalized, `${normalized}/**`];
5563
}
5664

5765
/**
@@ -90,7 +98,7 @@ export function createAssetMatchers(assets: NormalizedAssetsPattern[]): readonly
9098
const { glob: rawGlob, ignore: rawIgnore, input } = asset;
9199
const ignore = [...DEFAULT_ASSET_IGNORE, ...(rawIgnore ?? [])]
92100
.filter((pattern) => pattern.length > 0)
93-
.map(normalizeGlobPattern);
101+
.flatMap(normalizeGlobPattern);
94102
const isMatch = picomatch(rawGlob ? path.posix.normalize(rawGlob) : [], {
95103
dot: true,
96104
windows: false,
@@ -186,77 +194,48 @@ export async function collectAssetsToEmit(
186194
}
187195

188196
if (matchers.length > 1) {
189-
// Ensure lower-priority modified assets do not overwrite existing higher-priority assets on disk.
197+
// Ensure lower-priority modified assets do not overwrite existing higher-priority assets.
190198
for (const [outputPath, { file, matcherIndex }] of filesToEmit) {
191199
for (let laterIndex = matchers.length - 1; laterIndex > matcherIndex; laterIndex--) {
192-
const laterMatcher = matchers[laterIndex];
193-
const candidateFile = getCandidateSourceForOutput(laterMatcher, outputPath);
194-
if (!candidateFile || candidateFile === file.source) {
195-
continue;
196-
}
197-
198-
if (isMatchingAssetFile(candidateFile, laterMatcher.asset.followSymlinks)) {
199-
if (emittedAssets.get(candidateFile)?.has(outputPath)) {
200-
filesToEmit.delete(outputPath);
201-
} else {
202-
filesToEmit.set(outputPath, {
203-
file: createDiskOutputFile(candidateFile, outputPath),
204-
matcherIndex: laterIndex,
205-
});
206-
}
200+
const candidateFile = getCandidateSourceForOutput(matchers[laterIndex], outputPath);
201+
if (
202+
candidateFile &&
203+
candidateFile !== file.source &&
204+
!filesToDelete.has(candidateFile) &&
205+
emittedAssets.get(candidateFile)?.has(outputPath)
206+
) {
207+
filesToEmit.delete(outputPath);
207208
break;
208-
} else if (emittedAssets.get(candidateFile)?.has(outputPath)) {
209-
filesToDelete.add(candidateFile);
210209
}
211210
}
212211
}
213212

214213
// Restore lower-priority fallback assets when a higher-priority overriding asset is deleted.
215214
for (const deletedFile of filesToDelete) {
216-
for (
217-
let deletedMatcherIndex = 0;
218-
deletedMatcherIndex < matchers.length;
219-
deletedMatcherIndex++
220-
) {
221-
const { asset, prefix, isMatch } = matchers[deletedMatcherIndex];
222-
if (!deletedFile.startsWith(prefix)) {
223-
continue;
224-
}
225-
226-
const relative = deletedFile.slice(prefix.length);
227-
if (!isMatch(relative)) {
228-
continue;
229-
}
230-
231-
const outputPath = path.posix.join(asset.output, relative);
232-
if (filesToEmit.has(outputPath)) {
233-
continue;
234-
}
235-
236-
for (let candidateIndex = matchers.length - 1; candidateIndex >= 0; candidateIndex--) {
237-
if (candidateIndex === deletedMatcherIndex) {
238-
continue;
239-
}
215+
const deletedOutputs = emittedAssets.get(deletedFile);
216+
if (!deletedOutputs) {
217+
continue;
218+
}
240219

220+
for (const outputPath of deletedOutputs) {
221+
const minCandidateIndex = (filesToEmit.get(outputPath)?.matcherIndex ?? -1) + 1;
222+
for (
223+
let candidateIndex = matchers.length - 1;
224+
candidateIndex >= minCandidateIndex;
225+
candidateIndex--
226+
) {
241227
const candidateMatcher = matchers[candidateIndex];
242228
const candidateFile = getCandidateSourceForOutput(candidateMatcher, outputPath);
243229
if (!candidateFile || filesToDelete.has(candidateFile)) {
244230
continue;
245231
}
246232

247233
if (isMatchingAssetFile(candidateFile, candidateMatcher.asset.followSymlinks)) {
248-
if (
249-
candidateIndex < deletedMatcherIndex ||
250-
!emittedAssets.get(candidateFile)?.has(outputPath)
251-
) {
252-
filesToEmit.set(outputPath, {
253-
file: createDiskOutputFile(candidateFile, outputPath),
254-
matcherIndex: candidateIndex,
255-
});
256-
}
234+
filesToEmit.set(outputPath, {
235+
file: createDiskOutputFile(candidateFile, outputPath),
236+
matcherIndex: candidateIndex,
237+
});
257238
break;
258-
} else if (emittedAssets.get(candidateFile)?.has(outputPath)) {
259-
filesToDelete.add(candidateFile);
260239
}
261240
}
262241
}

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

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ describe('library pipeline assets', () => {
3838
'./ignored-dir-2/',
3939
'**/ignored-subdir',
4040
'{brace-dir-a,brace-dir-b}',
41+
'wildcard-parent/*/',
42+
'wildcard-rel/*/sub/..',
43+
'explicit-globstar/**',
4144
'specific.txt',
4245
],
4346
},
@@ -58,6 +61,13 @@ describe('library pipeline assets', () => {
5861
expect(matcher.isMatch('nested/ignored-subdir/deep/file.txt')).toBeFalse();
5962
expect(matcher.isMatch('brace-dir-a/file.txt')).toBeFalse();
6063
expect(matcher.isMatch('brace-dir-b/sub/file.txt')).toBeFalse();
64+
expect(matcher.isMatch('wildcard-parent/file.txt')).toBeFalse();
65+
expect(matcher.isMatch('wildcard-parent/sub/file.txt')).toBeFalse();
66+
expect(matcher.isMatch('wildcard-parent/sub/deep/file.txt')).toBeFalse();
67+
expect(matcher.isMatch('wildcard-rel/file.txt')).toBeFalse();
68+
expect(matcher.isMatch('wildcard-rel/sub/file.txt')).toBeFalse();
69+
expect(matcher.isMatch('explicit-globstar/file.txt')).toBeFalse();
70+
expect(matcher.isMatch('explicit-globstar/sub/file.txt')).toBeFalse();
6171
expect(matcher.isMatch('.gitkeep')).toBeFalse();
6272
expect(matcher.isMatch('nested/.DS_Store')).toBeFalse();
6373
});
@@ -121,17 +131,22 @@ describe('library pipeline assets', () => {
121131

122132
it('should not overwrite a higher-priority emitted asset when only a lower-priority asset is modified in watch mode', async () => {
123133
const defaultDir = `${tempRoot}/lib`;
134+
const fallbackDir = `${tempRoot}/fallback`;
124135
const customDir = `${tempRoot}/docs`;
125136
await mkdir(defaultDir, { recursive: true });
137+
await mkdir(fallbackDir, { recursive: true });
126138
await mkdir(customDir, { recursive: true });
127139

128140
const defaultReadme = `${defaultDir}/README.md`;
141+
const fallbackReadme = `${fallbackDir}/README.md`;
129142
const customReadme = `${customDir}/README.md`;
130143
await writeFile(defaultReadme, '# Default updated');
144+
await writeFile(fallbackReadme, '# Fallback');
131145
await writeFile(customReadme, '# Custom');
132146

133147
const assets = [
134148
{ glob: 'README.md', input: defaultDir, output: '.' },
149+
{ glob: 'README.md', input: fallbackDir, output: '.' },
135150
{ glob: 'README.md', input: customDir, output: '.' },
136151
];
137152

@@ -146,35 +161,20 @@ describe('library pipeline assets', () => {
146161
expect(resultWithEmitted.filesToEmit).toEqual([]);
147162
expect(resultWithEmitted.filesToDelete).toEqual([]);
148163

149-
// When customReadme was not yet recorded in emittedAssets, modifying defaultReadme should emit customReadme.
150-
const resultWithoutEmitted = await collectAssetsToEmit(
151-
assets,
152-
tempRoot,
153-
new Map(),
154-
new Set([defaultReadme]),
155-
);
156-
expect(resultWithoutEmitted.filesToEmit).toEqual([
157-
{
158-
origin: 'disk',
159-
source: customReadme,
160-
path: 'README.md',
161-
},
162-
]);
163-
164-
// When customReadme was recorded in emittedAssets but no longer exists on disk,
165-
// modifying defaultReadme should queue customReadme for deletion and emit defaultReadme.
164+
// When customReadme is deleted in the same watch iteration while defaultReadme is modified,
165+
// customReadme should be queued for deletion and higher-priority fallbackReadme should be emitted.
166166
await rm(customReadme);
167-
const resultWithMissingHigherPriority = await collectAssetsToEmit(
167+
const resultWithDeletedHigherPriority = await collectAssetsToEmit(
168168
assets,
169169
tempRoot,
170170
emittedAssets,
171-
new Set([defaultReadme]),
171+
new Set([defaultReadme, customReadme]),
172172
);
173-
expect(resultWithMissingHigherPriority.filesToDelete).toEqual([customReadme]);
174-
expect(resultWithMissingHigherPriority.filesToEmit).toEqual([
173+
expect(resultWithDeletedHigherPriority.filesToDelete).toEqual([customReadme]);
174+
expect(resultWithDeletedHigherPriority.filesToEmit).toEqual([
175175
{
176176
origin: 'disk',
177-
source: defaultReadme,
177+
source: fallbackReadme,
178178
path: 'README.md',
179179
},
180180
]);

0 commit comments

Comments
 (0)