Skip to content

Commit 584dcf2

Browse files
committed
fix(react-router): prioritize specific route matches
1 parent 3073a24 commit 584dcf2

File tree

3 files changed

+365
-28
lines changed

3 files changed

+365
-28
lines changed

packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx

Lines changed: 81 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -458,34 +458,104 @@ export class ReactRouterViewStack extends ViewStacks {
458458

459459
if (hasRelativeRoutes || hasIndexRoute) {
460460
const segments = routeInfo.pathname.split('/').filter(Boolean);
461+
462+
if (process.env.NODE_ENV !== 'production') {
463+
console.log(`[ReactRouterViewStack] getChildrenToRender outlet=${outletId}: computing parentPath for ${routeInfo.pathname}`);
464+
}
465+
466+
// Two-pass algorithm:
467+
// Pass 1: Look for specific route matches OR index routes (prefer real routes)
468+
// Pass 2: If no match found, use wildcard fallback
469+
//
470+
// Key insight: Index routes should match when remaining is empty at the longest
471+
// valid parent path. Wildcards should only be used when no specific/index match exists.
472+
473+
let wildcardFallbackPath: string | undefined = undefined;
474+
475+
// Pass 1: Look for specific or index matches, tracking wildcard fallback
461476
for (let i = 1; i <= segments.length; i++) {
462477
const testParentPath = '/' + segments.slice(0, i).join('/');
463478
const testRemainingPath = segments.slice(i).join('/');
464479

465-
const couldMatchRemaining = routeChildren.some((route) => {
480+
// Check for specific (non-wildcard, non-index) route matches
481+
const hasSpecificMatch = routeChildren.some((route) => {
466482
const props = route.props as any;
467483
const routePath = props.path as string | undefined;
468484
const isIndex = !!props.index;
469-
470-
// Index routes only match at the outlet root
471-
if (isIndex) {
472-
return testRemainingPath === '' || testRemainingPath === '/';
473-
}
474-
475-
// Treat wildcard-only routes as matching only at the outlet root
476485
const isWildcardOnly = routePath === '*' || routePath === '/*';
477-
if (isWildcardOnly) {
478-
return testRemainingPath === '' || testRemainingPath === '/';
486+
487+
if (isIndex || isWildcardOnly) {
488+
return false;
479489
}
480490

481491
const m = matchPath({ pathname: testRemainingPath, componentProps: props });
482492
return !!m;
483493
});
484494

485-
if (couldMatchRemaining) {
495+
if (hasSpecificMatch) {
496+
if (process.env.NODE_ENV !== 'production') {
497+
console.log(`[ReactRouterViewStack] Found specific match at parentPath=${testParentPath}, remaining=${testRemainingPath}`);
498+
}
486499
parentPath = testParentPath;
487500
break;
488501
}
502+
503+
// Check for index match (only when remaining is empty AND no wildcard fallback)
504+
// If we already found a wildcard fallback at a shorter path, it means
505+
// the remaining path at that level didn't match any routes, so the
506+
// index match at this longer path is not valid.
507+
if (!wildcardFallbackPath && (testRemainingPath === '' || testRemainingPath === '/')) {
508+
const hasIndexMatch = routeChildren.some((route) => !!(route.props as any).index);
509+
if (hasIndexMatch) {
510+
if (process.env.NODE_ENV !== 'production') {
511+
console.log(`[ReactRouterViewStack] Found index match at parentPath=${testParentPath}`);
512+
}
513+
parentPath = testParentPath;
514+
break;
515+
}
516+
}
517+
518+
// Track wildcard fallback at first level where remaining is non-empty
519+
// and no specific route could even START to match the remaining path
520+
if (!wildcardFallbackPath && testRemainingPath !== '' && testRemainingPath !== '/') {
521+
const hasWildcard = routeChildren.some((route) => {
522+
const routePath = (route.props as any).path;
523+
return routePath === '*' || routePath === '/*';
524+
});
525+
526+
if (hasWildcard) {
527+
// Check if any specific route could plausibly match this remaining path
528+
// by checking if the first segment overlaps with any route's first segment
529+
const remainingFirstSegment = testRemainingPath.split('/')[0];
530+
const couldAnyRouteMatch = routeChildren.some((route) => {
531+
const props = route.props as any;
532+
const routePath = props.path as string | undefined;
533+
if (!routePath || routePath === '*' || routePath === '/*') return false;
534+
if (props.index) return false;
535+
536+
// Get the route's first segment (before any / or *)
537+
const routeFirstSegment = routePath.split('/')[0].replace(/[*:]/g, '');
538+
if (!routeFirstSegment) return false;
539+
540+
// Check for prefix overlap (either direction)
541+
return routeFirstSegment.startsWith(remainingFirstSegment.slice(0, 3)) ||
542+
remainingFirstSegment.startsWith(routeFirstSegment.slice(0, 3));
543+
});
544+
545+
// Only save wildcard fallback if no specific route could match
546+
if (!couldAnyRouteMatch) {
547+
wildcardFallbackPath = testParentPath;
548+
}
549+
}
550+
}
551+
}
552+
553+
// Pass 2: If no specific/index match found, use wildcard fallback
554+
if (!parentPath && wildcardFallbackPath) {
555+
if (process.env.NODE_ENV !== 'production') {
556+
console.log(`[ReactRouterViewStack] Using wildcard fallback at parentPath=${wildcardFallbackPath}`);
557+
}
558+
parentPath = wildcardFallbackPath;
489559
}
490560
}
491561
}

packages/react-router/src/ReactRouter/StackManager.tsx

Lines changed: 104 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,46 @@ import { findRoutesNode } from './utils/findRoutesNode';
1414
import { derivePathnameToMatch } from './utils/derivePathnameToMatch';
1515
import { matchPath } from './utils/matchPath';
1616

17+
/**
18+
* Checks if a route matches the remaining path.
19+
* Note: This function is used for checking if ANY route could match, not for determining priority.
20+
* Wildcard routes are handled specially - they're always considered potential matches but should
21+
* be used as fallbacks when no specific route matches.
22+
*/
1723
const doesRouteMatchRemainingPath = (route: React.ReactElement, remainingPath: string) => {
1824
const routePath = route.props.path;
1925
const isWildcardOnly = routePath === '*' || routePath === '/*';
26+
const isIndex = route.props.index;
2027

21-
if (isWildcardOnly) {
22-
// Treat wildcard-only routes as matching only when we're already at the outlet root.
23-
// This prevents them from forcing parent paths to resolve too early (e.g. "/routing"
24-
// instead of "/routing/tabs") while still letting them act as fallbacks when no
25-
// additional segments remain.
28+
// Index routes only match when remaining path is empty
29+
if (isIndex) {
2630
return remainingPath === '' || remainingPath === '/';
2731
}
2832

33+
// Wildcard routes can match any path (used as fallback)
34+
if (isWildcardOnly) {
35+
return true; // Wildcards can always potentially match
36+
}
37+
38+
return !!matchPath({
39+
pathname: remainingPath,
40+
componentProps: route.props,
41+
});
42+
};
43+
44+
/**
45+
* Checks if a route is a specific match (not wildcard or index).
46+
*/
47+
const isSpecificRouteMatch = (route: React.ReactElement, remainingPath: string) => {
48+
const routePath = route.props.path;
49+
const isWildcardOnly = routePath === '*' || routePath === '/*';
50+
const isIndex = route.props.index;
51+
52+
// Skip wildcards and index routes
53+
if (isIndex || isWildcardOnly) {
54+
return false;
55+
}
56+
2957
return !!matchPath({
3058
pathname: remainingPath,
3159
componentProps: route.props,
@@ -118,19 +146,32 @@ export class StackManager extends React.PureComponent<StackManagerProps, StackMa
118146
const segments = currentPathname.split('/').filter(Boolean);
119147

120148
if (segments.length >= 1) {
149+
let wildcardFallbackPath: string | undefined = undefined;
150+
let foundPath: string | undefined = undefined;
151+
121152
// Store the mount path when we first successfully match a route
122153
// This helps us determine our scope for future navigations
154+
// We need to find specific matches, not just wildcard fallbacks
123155
if (!this.outletMountPath) {
124-
// Try to find the mount path by looking for successful matches
125156
for (let i = 1; i <= segments.length; i++) {
126157
const testParentPath = '/' + segments.slice(0, i).join('/');
127158
const testRemainingPath = segments.slice(i).join('/');
128-
const hasMatch = routeChildren.some((route) => doesRouteMatchRemainingPath(route, testRemainingPath));
129159

130-
if (hasMatch) {
160+
// Check for specific matches first
161+
const hasSpecific = routeChildren.some((route) => isSpecificRouteMatch(route, testRemainingPath));
162+
if (hasSpecific) {
131163
this.outletMountPath = testParentPath;
132164
break;
133165
}
166+
167+
// Check for index match when remaining is empty
168+
if (testRemainingPath === '' || testRemainingPath === '/') {
169+
const hasIndex = routeChildren.some((route) => route.props.index);
170+
if (hasIndex) {
171+
this.outletMountPath = testParentPath;
172+
break;
173+
}
174+
}
134175
}
135176
}
136177

@@ -141,23 +182,69 @@ export class StackManager extends React.PureComponent<StackManagerProps, StackMa
141182
}
142183

143184
// Try different parent path possibilities
144-
// Start with shorter parent paths first to find the most appropriate match
185+
// Prefer specific routes over wildcards and index routes
145186
for (let i = 1; i <= segments.length; i++) {
146187
const parentPath = '/' + segments.slice(0, i).join('/');
147188
const remainingPath = segments.slice(i).join('/');
148189

149-
// Check if any relative route could match the remaining path
150-
const couldMatchRemaining = routeChildren.some((route) => doesRouteMatchRemainingPath(route, remainingPath));
190+
// Check for specific (non-wildcard, non-index) route matches
191+
const hasSpecificMatch = routeChildren.some((route) => isSpecificRouteMatch(route, remainingPath));
192+
if (hasSpecificMatch) {
193+
foundPath = parentPath;
194+
break;
195+
}
151196

152-
if (couldMatchRemaining) {
153-
return parentPath;
197+
// Check for index route match (only when remaining path is empty AND no wildcard fallback)
198+
// If we already found a wildcard fallback at a shorter path, it means
199+
// the remaining path at that level didn't match any routes, so the
200+
// index match at this longer path is not valid.
201+
if (!wildcardFallbackPath && (remainingPath === '' || remainingPath === '/')) {
202+
const hasIndexMatch = routeChildren.some((route) => route.props.index);
203+
if (hasIndexMatch) {
204+
foundPath = parentPath;
205+
break;
206+
}
154207
}
208+
209+
// Track wildcard fallback only if:
210+
// 1. Remaining is non-empty (wildcard matches something)
211+
// 2. No specific route could even START to match the remaining path
212+
if (!wildcardFallbackPath && remainingPath !== '' && remainingPath !== '/') {
213+
const hasWildcard = routeChildren.some((route) => {
214+
const routePath = route.props.path;
215+
return routePath === '*' || routePath === '/*';
216+
});
217+
218+
if (hasWildcard) {
219+
// Check if any specific route could plausibly match this remaining path
220+
const remainingFirstSegment = remainingPath.split('/')[0];
221+
const couldAnyRouteMatch = routeChildren.some((route) => {
222+
const routePath = route.props.path as string | undefined;
223+
if (!routePath || routePath === '*' || routePath === '/*') return false;
224+
if (route.props.index) return false;
225+
226+
const routeFirstSegment = routePath.split('/')[0].replace(/[*:]/g, '');
227+
if (!routeFirstSegment) return false;
228+
229+
// Check for prefix overlap (either direction)
230+
return routeFirstSegment.startsWith(remainingFirstSegment.slice(0, 3)) ||
231+
remainingFirstSegment.startsWith(routeFirstSegment.slice(0, 3));
232+
});
233+
234+
// Only save wildcard fallback if no specific route could match
235+
if (!couldAnyRouteMatch) {
236+
wildcardFallbackPath = parentPath;
237+
}
238+
}
239+
}
240+
}
241+
242+
// If no specific match found, use wildcard fallback
243+
if (!foundPath && wildcardFallbackPath) {
244+
return wildcardFallbackPath;
155245
}
156246

157-
// If we couldn't find any parent path that makes sense for our relative routes,
158-
// it means this outlet is being rendered outside its expected routing context
159-
// Return undefined to signal that this outlet shouldn't handle routes
160-
return undefined;
247+
return foundPath;
161248
}
162249
}
163250
}

0 commit comments

Comments
 (0)