diff --git a/packages/angular/build/src/builders/library/builder-state.ts b/packages/angular/build/src/builders/library/builder-state.ts index e4d1bf79ef5d..9057ef41b815 100644 --- a/packages/angular/build/src/builders/library/builder-state.ts +++ b/packages/angular/build/src/builders/library/builder-state.ts @@ -37,6 +37,7 @@ export class BuilderState { #emittedBundleFiles = new Set(); #emittedManifestFiles = new Set(); readonly #emittedAssets = new Map>(); + readonly #emittedAssetSources = new Map(); #hasEmittedAssets = false; #executionResult = new ExecutionResult(); @@ -421,13 +422,13 @@ export class BuilderState { continue; } - if (this.#hasEmittedAssets) { - for (const [source, prevOutputs] of this.#emittedAssets) { - if (prevOutputs.delete(emittedAsset.path)) { - if (prevOutputs.size === 0) { - this.#emittedAssets.delete(source); - } - break; + const prevSource = this.#emittedAssetSources.get(emittedAsset.path); + if (prevSource !== undefined) { + const prevOutputs = this.#emittedAssets.get(prevSource); + if (prevOutputs) { + prevOutputs.delete(emittedAsset.path); + if (prevOutputs.size === 0) { + this.#emittedAssets.delete(prevSource); } } } @@ -437,12 +438,14 @@ export class BuilderState { this.#emittedAssets.set(emittedAsset.source, outputs); } outputs.add(emittedAsset.path); + this.#emittedAssetSources.set(emittedAsset.path, emittedAsset.source); } for (const source of toDelete) { const outputs = this.#emittedAssets.get(source); if (outputs) { for (const output of outputs) { + this.#emittedAssetSources.delete(output); filesToDelete.add(output); } this.#emittedAssets.delete(source); @@ -481,6 +484,7 @@ export class BuilderState { this.#emittedBundleFiles.clear(); this.#emittedManifestFiles.clear(); this.#emittedAssets.clear(); + this.#emittedAssetSources.clear(); this.#hasUpdatedBundles = false; } } diff --git a/packages/angular/build/src/builders/library/pipeline/assets.ts b/packages/angular/build/src/builders/library/pipeline/assets.ts index 199b2d2419b0..81ffb98da8cd 100644 --- a/packages/angular/build/src/builders/library/pipeline/assets.ts +++ b/packages/angular/build/src/builders/library/pipeline/assets.ts @@ -39,19 +39,27 @@ export interface AssetMatcher { } /** - * Normalizes a glob pattern to match `tinyglobby` semantics by stripping trailing slashes, - * expanding directory patterns with `/**`, and normalizing POSIX segments. + * Normalizes an ignore glob pattern for `picomatch` to match `tinyglobby` directory-exclusion + * semantics by stripping trailing slashes, normalizing POSIX segments, and expanding patterns + * with `/**` (returning both `normalized` and `${normalized}/**` for single-wildcard suffixes + * because `picomatch` does not make trailing `/**` optional after `*`). * - * @param pattern The raw glob pattern string. - * @returns The normalized POSIX glob pattern. + * @param pattern The raw ignore glob pattern string. + * @returns The normalized POSIX glob patterns. */ -function normalizeGlobPattern(pattern: string): string { - let result = pattern.endsWith('/') ? pattern.slice(0, -1) : pattern; - if (pattern.endsWith('/') || !result.endsWith('*')) { - result += '/**'; +function normalizeGlobPattern(pattern: string): string[] { + const trimmed = pattern.endsWith('/') ? pattern.slice(0, -1) : pattern; + const normalized = path.posix.normalize(trimmed); + + if (!normalized.endsWith('*')) { + return [`${normalized}/**`]; + } + + if (normalized.endsWith('/**')) { + return [normalized]; } - return path.posix.normalize(result); + return [normalized, `${normalized}/**`]; } /** @@ -90,7 +98,7 @@ export function createAssetMatchers(assets: NormalizedAssetsPattern[]): readonly const { glob: rawGlob, ignore: rawIgnore, input } = asset; const ignore = [...DEFAULT_ASSET_IGNORE, ...(rawIgnore ?? [])] .filter((pattern) => pattern.length > 0) - .map(normalizeGlobPattern); + .flatMap(normalizeGlobPattern); const isMatch = picomatch(rawGlob ? path.posix.normalize(rawGlob) : [], { dot: true, windows: false, @@ -186,58 +194,36 @@ export async function collectAssetsToEmit( } if (matchers.length > 1) { - // Ensure lower-priority modified assets do not overwrite existing higher-priority assets on disk. + // Ensure lower-priority modified assets do not overwrite existing higher-priority assets. for (const [outputPath, { file, matcherIndex }] of filesToEmit) { for (let laterIndex = matchers.length - 1; laterIndex > matcherIndex; laterIndex--) { - const laterMatcher = matchers[laterIndex]; - const candidateFile = getCandidateSourceForOutput(laterMatcher, outputPath); - if (!candidateFile || candidateFile === file.source) { - continue; - } - - if (isMatchingAssetFile(candidateFile, laterMatcher.asset.followSymlinks)) { - if (emittedAssets.get(candidateFile)?.has(outputPath)) { - filesToEmit.delete(outputPath); - } else { - filesToEmit.set(outputPath, { - file: createDiskOutputFile(candidateFile, outputPath), - matcherIndex: laterIndex, - }); - } + const candidateFile = getCandidateSourceForOutput(matchers[laterIndex], outputPath); + if ( + candidateFile && + candidateFile !== file.source && + !filesToDelete.has(candidateFile) && + emittedAssets.get(candidateFile)?.has(outputPath) + ) { + filesToEmit.delete(outputPath); break; - } else if (emittedAssets.get(candidateFile)?.has(outputPath)) { - filesToDelete.add(candidateFile); } } } // Restore lower-priority fallback assets when a higher-priority overriding asset is deleted. for (const deletedFile of filesToDelete) { - for ( - let deletedMatcherIndex = 0; - deletedMatcherIndex < matchers.length; - deletedMatcherIndex++ - ) { - const { asset, prefix, isMatch } = matchers[deletedMatcherIndex]; - if (!deletedFile.startsWith(prefix)) { - continue; - } - - const relative = deletedFile.slice(prefix.length); - if (!isMatch(relative)) { - continue; - } - - const outputPath = path.posix.join(asset.output, relative); - if (filesToEmit.has(outputPath)) { - continue; - } - - for (let candidateIndex = matchers.length - 1; candidateIndex >= 0; candidateIndex--) { - if (candidateIndex === deletedMatcherIndex) { - continue; - } + const deletedOutputs = emittedAssets.get(deletedFile); + if (!deletedOutputs) { + continue; + } + for (const outputPath of deletedOutputs) { + const minCandidateIndex = (filesToEmit.get(outputPath)?.matcherIndex ?? -1) + 1; + for ( + let candidateIndex = matchers.length - 1; + candidateIndex >= minCandidateIndex; + candidateIndex-- + ) { const candidateMatcher = matchers[candidateIndex]; const candidateFile = getCandidateSourceForOutput(candidateMatcher, outputPath); if (!candidateFile || filesToDelete.has(candidateFile)) { @@ -245,18 +231,11 @@ export async function collectAssetsToEmit( } if (isMatchingAssetFile(candidateFile, candidateMatcher.asset.followSymlinks)) { - if ( - candidateIndex < deletedMatcherIndex || - !emittedAssets.get(candidateFile)?.has(outputPath) - ) { - filesToEmit.set(outputPath, { - file: createDiskOutputFile(candidateFile, outputPath), - matcherIndex: candidateIndex, - }); - } + filesToEmit.set(outputPath, { + file: createDiskOutputFile(candidateFile, outputPath), + matcherIndex: candidateIndex, + }); break; - } else if (emittedAssets.get(candidateFile)?.has(outputPath)) { - filesToDelete.add(candidateFile); } } } diff --git a/packages/angular/build/src/builders/library/pipeline/assets_spec.ts b/packages/angular/build/src/builders/library/pipeline/assets_spec.ts index 610f783811ab..19c692fc06b7 100644 --- a/packages/angular/build/src/builders/library/pipeline/assets_spec.ts +++ b/packages/angular/build/src/builders/library/pipeline/assets_spec.ts @@ -38,6 +38,9 @@ describe('library pipeline assets', () => { './ignored-dir-2/', '**/ignored-subdir', '{brace-dir-a,brace-dir-b}', + 'wildcard-parent/*/', + 'wildcard-rel/*/sub/..', + 'explicit-globstar/**', 'specific.txt', ], }, @@ -58,6 +61,13 @@ describe('library pipeline assets', () => { expect(matcher.isMatch('nested/ignored-subdir/deep/file.txt')).toBeFalse(); expect(matcher.isMatch('brace-dir-a/file.txt')).toBeFalse(); expect(matcher.isMatch('brace-dir-b/sub/file.txt')).toBeFalse(); + expect(matcher.isMatch('wildcard-parent/file.txt')).toBeFalse(); + expect(matcher.isMatch('wildcard-parent/sub/file.txt')).toBeFalse(); + expect(matcher.isMatch('wildcard-parent/sub/deep/file.txt')).toBeFalse(); + expect(matcher.isMatch('wildcard-rel/file.txt')).toBeFalse(); + expect(matcher.isMatch('wildcard-rel/sub/file.txt')).toBeFalse(); + expect(matcher.isMatch('explicit-globstar/file.txt')).toBeFalse(); + expect(matcher.isMatch('explicit-globstar/sub/file.txt')).toBeFalse(); expect(matcher.isMatch('.gitkeep')).toBeFalse(); expect(matcher.isMatch('nested/.DS_Store')).toBeFalse(); }); @@ -121,17 +131,22 @@ describe('library pipeline assets', () => { it('should not overwrite a higher-priority emitted asset when only a lower-priority asset is modified in watch mode', async () => { const defaultDir = `${tempRoot}/lib`; + const fallbackDir = `${tempRoot}/fallback`; const customDir = `${tempRoot}/docs`; await mkdir(defaultDir, { recursive: true }); + await mkdir(fallbackDir, { recursive: true }); await mkdir(customDir, { recursive: true }); const defaultReadme = `${defaultDir}/README.md`; + const fallbackReadme = `${fallbackDir}/README.md`; const customReadme = `${customDir}/README.md`; await writeFile(defaultReadme, '# Default updated'); + await writeFile(fallbackReadme, '# Fallback'); await writeFile(customReadme, '# Custom'); const assets = [ { glob: 'README.md', input: defaultDir, output: '.' }, + { glob: 'README.md', input: fallbackDir, output: '.' }, { glob: 'README.md', input: customDir, output: '.' }, ]; @@ -146,35 +161,20 @@ describe('library pipeline assets', () => { expect(resultWithEmitted.filesToEmit).toEqual([]); expect(resultWithEmitted.filesToDelete).toEqual([]); - // When customReadme was not yet recorded in emittedAssets, modifying defaultReadme should emit customReadme. - const resultWithoutEmitted = await collectAssetsToEmit( - assets, - tempRoot, - new Map(), - new Set([defaultReadme]), - ); - expect(resultWithoutEmitted.filesToEmit).toEqual([ - { - origin: 'disk', - source: customReadme, - path: 'README.md', - }, - ]); - - // When customReadme was recorded in emittedAssets but no longer exists on disk, - // modifying defaultReadme should queue customReadme for deletion and emit defaultReadme. + // When customReadme is deleted in the same watch iteration while defaultReadme is modified, + // customReadme should be queued for deletion and higher-priority fallbackReadme should be emitted. await rm(customReadme); - const resultWithMissingHigherPriority = await collectAssetsToEmit( + const resultWithDeletedHigherPriority = await collectAssetsToEmit( assets, tempRoot, emittedAssets, - new Set([defaultReadme]), + new Set([defaultReadme, customReadme]), ); - expect(resultWithMissingHigherPriority.filesToDelete).toEqual([customReadme]); - expect(resultWithMissingHigherPriority.filesToEmit).toEqual([ + expect(resultWithDeletedHigherPriority.filesToDelete).toEqual([customReadme]); + expect(resultWithDeletedHigherPriority.filesToEmit).toEqual([ { origin: 'disk', - source: defaultReadme, + source: fallbackReadme, path: 'README.md', }, ]);