Skip to content

Commit

Permalink
fix(@angular/ssr): enhance dynamic route matching for better performa…
Browse files Browse the repository at this point in the history
…nce 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

(cherry picked from commit 4df97d1)
  • Loading branch information
alan-agius4 committed Jan 24, 2025
1 parent df9865f commit 94643d5
Show file tree
Hide file tree
Showing 4 changed files with 200 additions and 176 deletions.
23 changes: 23 additions & 0 deletions packages/angular/ssr/src/routes/ng-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ async function* traverseRoutesConfig(options: {
if (metadata.renderMode === RenderMode.Prerender) {
// Handle SSG routes
yield* handleSSGRoute(
serverConfigRouteTree,
typeof redirectTo === 'string' ? redirectTo : undefined,
metadata,
parentInjector,
Expand Down Expand Up @@ -301,6 +302,7 @@ function appendPreloadToMetadata(
* Handles SSG (Static Site Generation) routes by invoking `getPrerenderParams` and yielding
* all parameterized paths, returning any errors encountered.
*
* @param serverConfigRouteTree - The tree representing the server's routing setup.
* @param redirectTo - Optional path to redirect to, if specified.
* @param metadata - The metadata associated with the route tree node.
* @param parentInjector - The dependency injection container for the parent route.
Expand All @@ -309,6 +311,7 @@ function appendPreloadToMetadata(
* @returns An async iterable iterator that yields route tree node metadata for each SSG path or errors.
*/
async function* handleSSGRoute(
serverConfigRouteTree: RouteTree<ServerConfigRouteTreeAdditionalMetadata> | undefined,
redirectTo: string | undefined,
metadata: ServerConfigRouteTreeNodeMetadata,
parentInjector: Injector,
Expand Down Expand Up @@ -354,6 +357,19 @@ async function* handleSSGRoute(
return;
}

if (serverConfigRouteTree) {
// Automatically resolve dynamic parameters for nested routes.
const catchAllRoutePath = joinUrlParts(currentRoutePath, '**');
const match = serverConfigRouteTree.match(catchAllRoutePath);
if (match && match.renderMode === RenderMode.Prerender && !('getPrerenderParams' in match)) {
serverConfigRouteTree.insert(catchAllRoutePath, {
...match,
presentInClientRouter: true,
getPrerenderParams,
});
}
}

const parameters = await runInInjectionContext(parentInjector, () => getPrerenderParams());
try {
for (const params of parameters) {
Expand Down Expand Up @@ -458,6 +474,13 @@ function buildServerConfigRouteTree({ routes, appShellRoute }: ServerRoutesConfi
continue;
}

if (path.includes('*') && 'getPrerenderParams' in metadata) {
errors.push(
`Invalid '${path}' route configuration: 'getPrerenderParams' cannot be used with a '*' or '**' route.`,
);
continue;
}

serverConfigRouteTree.insert(path, metadata);
}

Expand Down
102 changes: 29 additions & 73 deletions packages/angular/ssr/src/routes/route-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/

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

/**
Expand Down Expand Up @@ -78,13 +78,6 @@ export interface RouteTreeNodeMetadata {
* The `AdditionalMetadata` type parameter allows for extending the node metadata with custom data.
*/
interface RouteTreeNode<AdditionalMetadata extends Record<string, unknown>> {
/**
* The index indicating the order in which the route was inserted into the tree.
* This index helps determine the priority of routes during matching, with lower indexes
* indicating earlier inserted routes.
*/
insertionIndex: number;

/**
* A map of child nodes, keyed by their corresponding route segment or wildcard.
*/
Expand All @@ -110,13 +103,6 @@ export class RouteTree<AdditionalMetadata extends Record<string, unknown> = {}>
*/
private readonly root = this.createEmptyRouteTreeNode();

/**
* A counter that tracks the order of route insertion.
* This ensures that routes are matched in the order they were defined,
* with earlier routes taking precedence.
*/
private insertionIndexCounter = 0;

/**
* Inserts a new route into the route tree.
* The route is broken down into segments, and each segment is added to the tree.
Expand All @@ -134,7 +120,6 @@ export class RouteTree<AdditionalMetadata extends Record<string, unknown> = {}>
// Replace parameterized segments (e.g., :id) with a wildcard (*) for matching
const normalizedSegment = segment[0] === ':' ? '*' : segment;
let childNode = node.children.get(normalizedSegment);

if (!childNode) {
childNode = this.createEmptyRouteTreeNode();
node.children.set(normalizedSegment, childNode);
Expand All @@ -149,8 +134,6 @@ export class RouteTree<AdditionalMetadata extends Record<string, unknown> = {}>
...metadata,
route: addLeadingSlash(normalizedSegments.join('/')),
};

node.insertionIndex = this.insertionIndexCounter++;
}

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

/**
Expand All @@ -232,74 +215,48 @@ export class RouteTree<AdditionalMetadata extends Record<string, unknown> = {}>
* This function prioritizes exact segment matches first, followed by wildcard matches (`*`),
* and finally deep wildcard matches (`**`) that consume all segments.
*
* @param remainingSegments - The remaining segments of the route path to match.
* @param node - The current node in the route tree to start traversal from.
* @param segments - The array of route path segments to match against the route tree.
* @param node - The current node in the route tree to start traversal from. Defaults to the root node.
* @param currentIndex - The index of the segment in `remainingSegments` currently being matched.
* Defaults to `0` (the first segment).
*
* @returns The node that best matches the remaining segments or `undefined` if no match is found.
*/
private traverseBySegments(
remainingSegments: string[],
segments: string[],
node = this.root,
currentIndex = 0,
): RouteTreeNode<AdditionalMetadata> | undefined {
const { metadata, children } = node;

// If there are no remaining segments and the node has metadata, return this node
if (!remainingSegments.length) {
return metadata ? node : node.children.get('**');
if (currentIndex >= segments.length) {
return node.metadata ? node : node.children.get('**');
}

// If the node has no children, end the traversal
if (!children.size) {
return;
if (!node.children.size) {
return undefined;
}

const [segment, ...restSegments] = remainingSegments;
let currentBestMatchNode: RouteTreeNode<AdditionalMetadata> | undefined;

// 1. Exact segment match
const exactMatchNode = node.children.get(segment);
currentBestMatchNode = this.getHigherPriorityNode(
currentBestMatchNode,
this.traverseBySegments(restSegments, exactMatchNode),
);
const segment = segments[currentIndex];

// 2. Wildcard segment match (`*`)
const wildcardNode = node.children.get('*');
currentBestMatchNode = this.getHigherPriorityNode(
currentBestMatchNode,
this.traverseBySegments(restSegments, wildcardNode),
);

// 3. Deep wildcard segment match (`**`)
const deepWildcardNode = node.children.get('**');
currentBestMatchNode = this.getHigherPriorityNode(currentBestMatchNode, deepWildcardNode);

return currentBestMatchNode;
}

/**
* Compares two nodes and returns the node with higher priority based on insertion index.
* A node with a lower insertion index is prioritized as it was defined earlier.
*
* @param currentBestMatchNode - The current best match node.
* @param candidateNode - The node being evaluated for higher priority based on insertion index.
* @returns The node with higher priority (i.e., lower insertion index). If one of the nodes is `undefined`, the other node is returned.
*/
private getHigherPriorityNode(
currentBestMatchNode: RouteTreeNode<AdditionalMetadata> | undefined,
candidateNode: RouteTreeNode<AdditionalMetadata> | undefined,
): RouteTreeNode<AdditionalMetadata> | undefined {
if (!candidateNode) {
return currentBestMatchNode;
// 1. Attempt exact match with the current segment.
const exactMatch = node.children.get(segment);
if (exactMatch) {
const match = this.traverseBySegments(segments, exactMatch, currentIndex + 1);
if (match) {
return match;
}
}

if (!currentBestMatchNode) {
return candidateNode;
// 2. Attempt wildcard match ('*').
const wildcardMatch = node.children.get('*');
if (wildcardMatch) {
const match = this.traverseBySegments(segments, wildcardMatch, currentIndex + 1);
if (match) {
return match;
}
}

return candidateNode.insertionIndex < currentBestMatchNode.insertionIndex
? candidateNode
: currentBestMatchNode;
// 3. Attempt double wildcard match ('**').
return node.children.get('**');
}

/**
Expand All @@ -310,7 +267,6 @@ export class RouteTree<AdditionalMetadata extends Record<string, unknown> = {}>
*/
private createEmptyRouteTreeNode(): RouteTreeNode<AdditionalMetadata> {
return {
insertionIndex: -1,
children: new Map(),
};
}
Expand Down
Loading

0 comments on commit 94643d5

Please sign in to comment.