Skip to content

Commit 6a6da31

Browse files
committed
fix(@angular/ssr): preserve preload metadata for nested empty-path routes
Previously, `handleRoute` yielded a parent route's metadata before appending `loadChildren` preloads or traversing child routes, while `traverseRoutesConfig` appended a trailing slash only to empty-path routes. Under a non-empty parent, the parent ('/pricing') and its empty-path child ('/pricing/') received distinct keys during `getRoutesFromAngularRouterConfig` deduplication (`seenRoutes`) and were only merged later by `RouteTree.insert`. However, when an empty-path route was nested under another empty-path route (such as at the root '/' or under '/es' -> '' -> ''), both the parent and child received the same key, causing `seenRoutes` to keep the outer route's metadata and drop the descendant's accumulated `preload` and redirect metadata. Update `handleRoute` to append `loadChildren` preloads upfront and allow the first matching empty-path child route to override its parent's metadata for the same path, and remove the redundant `addTrailingSlash` keying in `traverseRoutesConfig`. Closes #34320
1 parent 1e97eff commit 6a6da31

17 files changed

Lines changed: 783 additions & 222 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)