Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions packages/angular/build/src/builders/library/builder-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export class BuilderState {
#emittedBundleFiles = new Set<string>();
#emittedManifestFiles = new Set<string>();
readonly #emittedAssets = new Map<string, Set<string>>();
readonly #emittedAssetSources = new Map<string, string>();
#hasEmittedAssets = false;

#executionResult = new ExecutionResult();
Expand Down Expand Up @@ -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);
}
}
}
Expand All @@ -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);
Expand Down Expand Up @@ -481,6 +484,7 @@ export class BuilderState {
this.#emittedBundleFiles.clear();
this.#emittedManifestFiles.clear();
this.#emittedAssets.clear();
this.#emittedAssetSources.clear();
this.#hasUpdatedBundles = false;
}
}
105 changes: 42 additions & 63 deletions packages/angular/build/src/builders/library/pipeline/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}/**`];
}

/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -186,77 +194,48 @@ 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)) {
continue;
}

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);
}
}
}
Expand Down
44 changes: 22 additions & 22 deletions packages/angular/build/src/builders/library/pipeline/assets_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
},
Expand All @@ -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();
});
Expand Down Expand Up @@ -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: '.' },
];

Expand All @@ -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',
},
]);
Expand Down
Loading