Skip to content

Commit 4df97d1

Browse files
committed
fix(@angular/ssr): enhance dynamic route matching for better performance and accuracy
Updated route matching logic to prioritize closest matches, improving the accuracy of dynamic route resolution. Also we optimized performance by eliminating unnecessary recursive checks, reducing overhead during route matching. Closes #29452
1 parent f836be9 commit 4df97d1

File tree

4 files changed

+200
-176
lines changed

4 files changed

+200
-176
lines changed

packages/angular/ssr/src/routes/ng-routes.ts

+23
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ async function* traverseRoutesConfig(options: {
199199
if (metadata.renderMode === RenderMode.Prerender) {
200200
// Handle SSG routes
201201
yield* handleSSGRoute(
202+
serverConfigRouteTree,
202203
typeof redirectTo === 'string' ? redirectTo : undefined,
203204
metadata,
204205
parentInjector,
@@ -301,6 +302,7 @@ function appendPreloadToMetadata(
301302
* Handles SSG (Static Site Generation) routes by invoking `getPrerenderParams` and yielding
302303
* all parameterized paths, returning any errors encountered.
303304
*
305+
* @param serverConfigRouteTree - The tree representing the server's routing setup.
304306
* @param redirectTo - Optional path to redirect to, if specified.
305307
* @param metadata - The metadata associated with the route tree node.
306308
* @param parentInjector - The dependency injection container for the parent route.
@@ -309,6 +311,7 @@ function appendPreloadToMetadata(
309311
* @returns An async iterable iterator that yields route tree node metadata for each SSG path or errors.
310312
*/
311313
async function* handleSSGRoute(
314+
serverConfigRouteTree: RouteTree<ServerConfigRouteTreeAdditionalMetadata> | undefined,
312315
redirectTo: string | undefined,
313316
metadata: ServerConfigRouteTreeNodeMetadata,
314317
parentInjector: Injector,
@@ -354,6 +357,19 @@ async function* handleSSGRoute(
354357
return;
355358
}
356359

360+
if (serverConfigRouteTree) {
361+
// Automatically resolve dynamic parameters for nested routes.
362+
const catchAllRoutePath = joinUrlParts(currentRoutePath, '**');
363+
const match = serverConfigRouteTree.match(catchAllRoutePath);
364+
if (match && match.renderMode === RenderMode.Prerender && !('getPrerenderParams' in match)) {
365+
serverConfigRouteTree.insert(catchAllRoutePath, {
366+
...match,
367+
presentInClientRouter: true,
368+
getPrerenderParams,
369+
});
370+
}
371+
}
372+
357373
const parameters = await runInInjectionContext(parentInjector, () => getPrerenderParams());
358374
try {
359375
for (const params of parameters) {
@@ -458,6 +474,13 @@ function buildServerConfigRouteTree({ routes, appShellRoute }: ServerRoutesConfi
458474
continue;
459475
}
460476

477+
if (path.includes('*') && 'getPrerenderParams' in metadata) {
478+
errors.push(
479+
`Invalid '${path}' route configuration: 'getPrerenderParams' cannot be used with a '*' or '**' route.`,
480+
);
481+
continue;
482+
}
483+
461484
serverConfigRouteTree.insert(path, metadata);
462485
}
463486

packages/angular/ssr/src/routes/route-tree.ts

+29-73
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import { addLeadingSlash, stripTrailingSlash } from '../utils/url';
9+
import { addLeadingSlash } from '../utils/url';
1010
import { RenderMode } from './route-config';
1111

1212
/**
@@ -78,13 +78,6 @@ export interface RouteTreeNodeMetadata {
7878
* The `AdditionalMetadata` type parameter allows for extending the node metadata with custom data.
7979
*/
8080
interface RouteTreeNode<AdditionalMetadata extends Record<string, unknown>> {
81-
/**
82-
* The index indicating the order in which the route was inserted into the tree.
83-
* This index helps determine the priority of routes during matching, with lower indexes
84-
* indicating earlier inserted routes.
85-
*/
86-
insertionIndex: number;
87-
8881
/**
8982
* A map of child nodes, keyed by their corresponding route segment or wildcard.
9083
*/
@@ -110,13 +103,6 @@ export class RouteTree<AdditionalMetadata extends Record<string, unknown> = {}>
110103
*/
111104
private readonly root = this.createEmptyRouteTreeNode();
112105

113-
/**
114-
* A counter that tracks the order of route insertion.
115-
* This ensures that routes are matched in the order they were defined,
116-
* with earlier routes taking precedence.
117-
*/
118-
private insertionIndexCounter = 0;
119-
120106
/**
121107
* Inserts a new route into the route tree.
122108
* The route is broken down into segments, and each segment is added to the tree.
@@ -134,7 +120,6 @@ export class RouteTree<AdditionalMetadata extends Record<string, unknown> = {}>
134120
// Replace parameterized segments (e.g., :id) with a wildcard (*) for matching
135121
const normalizedSegment = segment[0] === ':' ? '*' : segment;
136122
let childNode = node.children.get(normalizedSegment);
137-
138123
if (!childNode) {
139124
childNode = this.createEmptyRouteTreeNode();
140125
node.children.set(normalizedSegment, childNode);
@@ -149,8 +134,6 @@ export class RouteTree<AdditionalMetadata extends Record<string, unknown> = {}>
149134
...metadata,
150135
route: addLeadingSlash(normalizedSegments.join('/')),
151136
};
152-
153-
node.insertionIndex = this.insertionIndexCounter++;
154137
}
155138

156139
/**
@@ -222,7 +205,7 @@ export class RouteTree<AdditionalMetadata extends Record<string, unknown> = {}>
222205
* @returns An array of path segments.
223206
*/
224207
private getPathSegments(route: string): string[] {
225-
return stripTrailingSlash(route).split('/');
208+
return route.split('/').filter(Boolean);
226209
}
227210

228211
/**
@@ -232,74 +215,48 @@ export class RouteTree<AdditionalMetadata extends Record<string, unknown> = {}>
232215
* This function prioritizes exact segment matches first, followed by wildcard matches (`*`),
233216
* and finally deep wildcard matches (`**`) that consume all segments.
234217
*
235-
* @param remainingSegments - The remaining segments of the route path to match.
236-
* @param node - The current node in the route tree to start traversal from.
218+
* @param segments - The array of route path segments to match against the route tree.
219+
* @param node - The current node in the route tree to start traversal from. Defaults to the root node.
220+
* @param currentIndex - The index of the segment in `remainingSegments` currently being matched.
221+
* Defaults to `0` (the first segment).
237222
*
238223
* @returns The node that best matches the remaining segments or `undefined` if no match is found.
239224
*/
240225
private traverseBySegments(
241-
remainingSegments: string[],
226+
segments: string[],
242227
node = this.root,
228+
currentIndex = 0,
243229
): RouteTreeNode<AdditionalMetadata> | undefined {
244-
const { metadata, children } = node;
245-
246-
// If there are no remaining segments and the node has metadata, return this node
247-
if (!remainingSegments.length) {
248-
return metadata ? node : node.children.get('**');
230+
if (currentIndex >= segments.length) {
231+
return node.metadata ? node : node.children.get('**');
249232
}
250233

251-
// If the node has no children, end the traversal
252-
if (!children.size) {
253-
return;
234+
if (!node.children.size) {
235+
return undefined;
254236
}
255237

256-
const [segment, ...restSegments] = remainingSegments;
257-
let currentBestMatchNode: RouteTreeNode<AdditionalMetadata> | undefined;
258-
259-
// 1. Exact segment match
260-
const exactMatchNode = node.children.get(segment);
261-
currentBestMatchNode = this.getHigherPriorityNode(
262-
currentBestMatchNode,
263-
this.traverseBySegments(restSegments, exactMatchNode),
264-
);
238+
const segment = segments[currentIndex];
265239

266-
// 2. Wildcard segment match (`*`)
267-
const wildcardNode = node.children.get('*');
268-
currentBestMatchNode = this.getHigherPriorityNode(
269-
currentBestMatchNode,
270-
this.traverseBySegments(restSegments, wildcardNode),
271-
);
272-
273-
// 3. Deep wildcard segment match (`**`)
274-
const deepWildcardNode = node.children.get('**');
275-
currentBestMatchNode = this.getHigherPriorityNode(currentBestMatchNode, deepWildcardNode);
276-
277-
return currentBestMatchNode;
278-
}
279-
280-
/**
281-
* Compares two nodes and returns the node with higher priority based on insertion index.
282-
* A node with a lower insertion index is prioritized as it was defined earlier.
283-
*
284-
* @param currentBestMatchNode - The current best match node.
285-
* @param candidateNode - The node being evaluated for higher priority based on insertion index.
286-
* @returns The node with higher priority (i.e., lower insertion index). If one of the nodes is `undefined`, the other node is returned.
287-
*/
288-
private getHigherPriorityNode(
289-
currentBestMatchNode: RouteTreeNode<AdditionalMetadata> | undefined,
290-
candidateNode: RouteTreeNode<AdditionalMetadata> | undefined,
291-
): RouteTreeNode<AdditionalMetadata> | undefined {
292-
if (!candidateNode) {
293-
return currentBestMatchNode;
240+
// 1. Attempt exact match with the current segment.
241+
const exactMatch = node.children.get(segment);
242+
if (exactMatch) {
243+
const match = this.traverseBySegments(segments, exactMatch, currentIndex + 1);
244+
if (match) {
245+
return match;
246+
}
294247
}
295248

296-
if (!currentBestMatchNode) {
297-
return candidateNode;
249+
// 2. Attempt wildcard match ('*').
250+
const wildcardMatch = node.children.get('*');
251+
if (wildcardMatch) {
252+
const match = this.traverseBySegments(segments, wildcardMatch, currentIndex + 1);
253+
if (match) {
254+
return match;
255+
}
298256
}
299257

300-
return candidateNode.insertionIndex < currentBestMatchNode.insertionIndex
301-
? candidateNode
302-
: currentBestMatchNode;
258+
// 3. Attempt double wildcard match ('**').
259+
return node.children.get('**');
303260
}
304261

305262
/**
@@ -310,7 +267,6 @@ export class RouteTree<AdditionalMetadata extends Record<string, unknown> = {}>
310267
*/
311268
private createEmptyRouteTreeNode(): RouteTreeNode<AdditionalMetadata> {
312269
return {
313-
insertionIndex: -1,
314270
children: new Map(),
315271
};
316272
}

0 commit comments

Comments
 (0)