Skip to content

Commit 7fbc59b

Browse files
committed
fix(react-router): prevent tail-slice false positives for parameterized routes
1 parent 49dde14 commit 7fbc59b

File tree

6 files changed

+224
-22
lines changed

6 files changed

+224
-22
lines changed

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

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,12 +452,35 @@ export class ReactRouterViewStack extends ViewStacks {
452452
if (routePath === '*' || routePath === '') {
453453
// Check if any other view in this outlet has a match for the current route
454454
const outletViews = this.getViewItemsForOutlet(viewItem.outletId);
455+
456+
// When parent path context is available, compute the relative pathname once
457+
// outside the loop since both routeInfo.pathname and parentPath are invariant.
458+
const relativePathname = parentPath
459+
? computeRelativeToParent(routeInfo.pathname, parentPath)
460+
: null;
461+
455462
let hasSpecificMatch = outletViews.some((v) => {
456463
if (v.id === viewItem.id) return false; // Skip self
457464
const vRoutePath = v.reactElement?.props?.path || '';
458465
if (vRoutePath === '*' || vRoutePath === '') return false; // Skip other wildcard/empty routes
459466

460-
// Check if this view item would match the current route
467+
// When parent path context is available and the route is relative, use
468+
// parent-path-aware matching. This avoids false positives from
469+
// derivePathnameToMatch's tail-slice heuristic, which can incorrectly
470+
// match route literals that appear at the wrong position in the pathname.
471+
// Example: pathname /parent/extra/details/99 with route details/:id —
472+
// the tail-slice extracts ["details","99"] producing a false match.
473+
if (parentPath && vRoutePath && !vRoutePath.startsWith('/')) {
474+
if (relativePathname === null) {
475+
return false; // Pathname is outside this outlet's parent scope
476+
}
477+
return !!matchPath({
478+
pathname: relativePathname,
479+
componentProps: v.reactElement.props,
480+
});
481+
}
482+
483+
// Fallback to matchComponent when no parent path context is available
461484
const vMatch = v.reactElement ? matchComponent(v.reactElement, routeInfo.pathname) : null;
462485
return !!vMatch;
463486
});

packages/react-router/src/ReactRouter/utils/computeParentPath.ts

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -68,17 +68,6 @@ const matchesEmbeddedWildcardRoute = (route: React.ReactElement, pathname: strin
6868
return !!matchPath({ pathname, componentProps: route.props });
6969
};
7070

71-
/**
72-
* Checks if a route path consists entirely of parameterized segments (e.g., ":slug", ":category/:id").
73-
* These routes match any single segment and should not drive the parent path deeper
74-
* than the outlet's established mount point.
75-
*/
76-
const isPurelyParameterized = (routePath: string | undefined): boolean => {
77-
if (!routePath) return false;
78-
const segments = routePath.split('/').filter(Boolean);
79-
return segments.length > 0 && segments.every((segment) => segment.startsWith(':'));
80-
};
81-
8271
/**
8372
* Checks if a route is a specific match (not wildcard-only or index).
8473
*/
@@ -180,12 +169,21 @@ const couldSpecificRouteMatch = (
180169
// When mount path is established, skip this lookahead: the parent depth is known,
181170
// and purely parameterized routes (e.g., :slug) matching the last segment should
182171
// not prevent the wildcard from claiming the full remaining path.
172+
//
173+
// Only allow purely literal routes (no :params) in the lookahead. Routes with
174+
// parameters are positionally ambiguous — their params match any segment, so a
175+
// coincidental match at a deeper level (e.g., details/:id matching "details/99"
176+
// inside "extra/details/99") should not prevent the wildcard from capturing the
177+
// full remaining path.
183178
if (!outletMountPath) {
184179
for (let j = 1; j < segments.length; j++) {
185180
const futureRemaining = segments.slice(j).join('/');
186181
const futureMatch = findFirstSpecificMatchingRoute(routeChildren, futureRemaining);
187-
if (futureMatch && !isPurelyParameterized(futureMatch.props.path as string)) {
188-
return true;
182+
if (futureMatch) {
183+
const futurePath = futureMatch.props.path as string | undefined;
184+
if (futurePath && !futurePath.includes(':')) {
185+
return true;
186+
}
189187
}
190188
}
191189
}
@@ -314,13 +312,12 @@ export const computeParentPath = (options: ComputeParentPathOptions): ParentPath
314312

315313
// Check for specific route match (highest priority)
316314
if (!firstSpecificMatch && findSpecificMatch(routeChildren, remainingPath)) {
317-
// Don't let purely parameterized routes (e.g., :slug, :id) drive the
318-
// parent deeper than where a wildcard already matched. A :slug route
319-
// matching the last segment of "deep/nested/path" shouldn't pull the
320-
// parent to /parent/deep/nested — the wildcard at the correct depth
321-
// should catch the full remaining path instead.
322-
// Literal routes (e.g., "settings", "redirect") can still match beyond
323-
// the wildcard depth to support redirect scenarios.
315+
// Don't let routes containing parameter segments (e.g., :slug, details/:id)
316+
// drive the parent deeper than where a wildcard already matched. Parameter
317+
// segments match any value, making tail-slice matches positionally ambiguous:
318+
// e.g., "details/:id" matching "details/99" inside "extra/details/99" is a
319+
// coincidental match at the wrong depth. Only purely literal routes (e.g.,
320+
// "settings", "redirect") can override the wildcard at deeper levels.
324321
//
325322
// Also don't let empty/default path routes (path="" or undefined) drive
326323
// the parent deeper than a wildcard match. An empty path route matching
@@ -334,7 +331,13 @@ export const computeParentPath = (options: ComputeParentPathOptions): ParentPath
334331
if (matchingRoute) {
335332
const matchingPath = matchingRoute.props.path as string | undefined;
336333
const isEmptyPath = !matchingPath || matchingPath === '';
337-
if (shouldSkipParameterized && (isPurelyParameterized(matchingPath as string) || isEmptyPath)) {
334+
// When the parent path is deeper than expected (shouldSkipParameterized),
335+
// skip routes containing ANY parameterized segments. Parameters make tail-
336+
// slice matches positionally ambiguous: e.g., "details/:id" matching
337+
// "details/99" inside "extra/details/99" is a coincidental match at the
338+
// wrong depth. Only purely literal routes (e.g., "settings") can override
339+
// the wildcard at deeper levels.
340+
if (shouldSkipParameterized && (matchingPath?.includes(':') || isEmptyPath)) {
338341
continue;
339342
}
340343
if (firstWildcardMatch && isEmptyPath) {

packages/react-router/test/base/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import PrefixMatchWildcard from './pages/prefix-match-wildcard/PrefixMatchWildca
5252
import StaleViewCleanup from './pages/stale-view-cleanup/StaleViewCleanup';
5353
import IndexParamPriority from './pages/index-param-priority/IndexParamPriority';
5454
import IndexRouteReuse from './pages/index-route-reuse/IndexRouteReuse';
55+
import TailSliceAmbiguity from './pages/tail-slice-ambiguity/TailSliceAmbiguity';
5556

5657
setupIonicReact();
5758

@@ -93,6 +94,7 @@ const App: React.FC = () => {
9394
<Route path="/stale-view-cleanup/*" element={<StaleViewCleanup />} />
9495
<Route path="/index-param-priority/*" element={<IndexParamPriority />} />
9596
<Route path="/index-route-reuse/*" element={<IndexRouteReuse />} />
97+
<Route path="/tail-slice-ambiguity/*" element={<TailSliceAmbiguity />} />
9698
</IonRouterOutlet>
9799
</IonReactRouter>
98100
</IonApp>

packages/react-router/test/base/src/pages/Main.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ const Main: React.FC = () => {
101101
<IonItem routerLink="/index-route-reuse">
102102
<IonLabel>Index Route Reuse</IonLabel>
103103
</IonItem>
104+
<IonItem routerLink="/tail-slice-ambiguity">
105+
<IonLabel>Tail Slice Ambiguity</IonLabel>
106+
</IonItem>
104107
</IonList>
105108
</IonContent>
106109
</IonPage>
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import {
2+
IonButton,
3+
IonContent,
4+
IonHeader,
5+
IonLabel,
6+
IonPage,
7+
IonRouterOutlet,
8+
IonTitle,
9+
IonToolbar,
10+
} from '@ionic/react';
11+
import React from 'react';
12+
import { Route, useLocation, useParams } from 'react-router-dom';
13+
14+
/**
15+
* Test page for tail-slice ambiguity in derivePathnameToMatch.
16+
*
17+
* Route structure:
18+
* /tail-slice-ambiguity/*
19+
* └── <IonRouterOutlet>
20+
* ├── index → ListPage
21+
* ├── details/:id → DetailsPage
22+
* └── * → CatchAllPage
23+
*
24+
* Bug scenario:
25+
* 1. Navigate to /tail-slice-ambiguity/details/42 → creates details/:id view
26+
* 2. Navigate to /tail-slice-ambiguity/extra/details/99
27+
* 3. derivePathnameToMatch tail-slices the last 2 segments ["details", "99"]
28+
* and matchComponent falsely matches details/:id
29+
* 4. The catch-all * view is incorrectly deactivated because hasSpecificMatch
30+
* finds the false positive from details/:id
31+
* 5. User sees nothing instead of the catch-all page
32+
*/
33+
const TailSliceAmbiguity: React.FC = () => (
34+
<IonRouterOutlet id="tail-slice-outlet">
35+
<Route index element={<ListPage />} />
36+
<Route path="details/:id" element={<DetailsPage />} />
37+
<Route path="*" element={<CatchAllPage />} />
38+
</IonRouterOutlet>
39+
);
40+
41+
const ListPage: React.FC = () => (
42+
<IonPage data-pageid="tail-slice-list">
43+
<IonHeader>
44+
<IonToolbar>
45+
<IonTitle>List</IonTitle>
46+
</IonToolbar>
47+
</IonHeader>
48+
<IonContent>
49+
<IonButton routerLink="/tail-slice-ambiguity/details/42" id="go-to-details">
50+
Details 42
51+
</IonButton>
52+
<IonButton routerLink="/tail-slice-ambiguity/extra/details/99" id="go-to-ambiguous">
53+
Ambiguous Path
54+
</IonButton>
55+
</IonContent>
56+
</IonPage>
57+
);
58+
59+
const DetailsPage: React.FC = () => {
60+
const { id } = useParams<{ id: string }>();
61+
return (
62+
<IonPage data-pageid="tail-slice-details">
63+
<IonHeader>
64+
<IonToolbar>
65+
<IonTitle>Details</IonTitle>
66+
</IonToolbar>
67+
</IonHeader>
68+
<IonContent>
69+
<IonLabel data-testid="details-id">Details ID: {id}</IonLabel>
70+
<IonButton routerLink="/tail-slice-ambiguity" id="back-to-list">
71+
Back to List
72+
</IonButton>
73+
</IonContent>
74+
</IonPage>
75+
);
76+
};
77+
78+
const CatchAllPage: React.FC = () => {
79+
const location = useLocation();
80+
return (
81+
<IonPage data-pageid="tail-slice-catchall">
82+
<IonHeader>
83+
<IonToolbar>
84+
<IonTitle>Catch All</IonTitle>
85+
</IonToolbar>
86+
</IonHeader>
87+
<IonContent>
88+
<IonLabel data-testid="catchall-path">Catch-all: {location.pathname}</IonLabel>
89+
<IonButton routerLink="/tail-slice-ambiguity" id="catchall-back-to-list">
90+
Back to List
91+
</IonButton>
92+
</IonContent>
93+
</IonPage>
94+
);
95+
};
96+
97+
export default TailSliceAmbiguity;
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
const port = 3000;
2+
3+
/**
4+
* Tests that derivePathnameToMatch's tail-slice heuristic does not produce
5+
* false positive matches that incorrectly deactivate catch-all routes.
6+
*
7+
* Route structure:
8+
* /tail-slice-ambiguity/*
9+
* ├── index → ListPage
10+
* ├── details/:id → DetailsPage
11+
* └── * → CatchAllPage
12+
*
13+
* Bug: navigating to /tail-slice-ambiguity/extra/details/99 after visiting
14+
* /tail-slice-ambiguity/details/42 causes the tail-slice to extract
15+
* ["details", "99"] which falsely matches details/:id, deactivating the
16+
* catch-all page.
17+
*/
18+
describe('Tail-Slice Ambiguity', () => {
19+
it('should show details page for /details/:id', () => {
20+
cy.visit(`http://localhost:${port}/tail-slice-ambiguity`);
21+
cy.ionPageVisible('tail-slice-list');
22+
23+
cy.get('#go-to-details').click();
24+
cy.ionPageVisible('tail-slice-details');
25+
cy.get('[data-testid="details-id"]').should('contain', 'Details ID: 42');
26+
});
27+
28+
it('should show catch-all when path has extra segments before details', () => {
29+
cy.visit(`http://localhost:${port}/tail-slice-ambiguity`);
30+
cy.ionPageVisible('tail-slice-list');
31+
32+
// First create the details/:id view
33+
cy.get('#go-to-details').click();
34+
cy.ionPageVisible('tail-slice-details');
35+
cy.get('[data-testid="details-id"]').should('contain', 'Details ID: 42');
36+
37+
// Go back to list
38+
cy.get('#back-to-list').click();
39+
cy.ionPageVisible('tail-slice-list');
40+
41+
// Navigate to ambiguous path - should show catch-all, NOT details
42+
cy.get('#go-to-ambiguous').click();
43+
cy.ionPageVisible('tail-slice-catchall');
44+
cy.get('[data-testid="catchall-path"]').should('contain', '/tail-slice-ambiguity/extra/details/99');
45+
});
46+
47+
it('should show catch-all for ambiguous path on direct navigation', () => {
48+
cy.visit(`http://localhost:${port}/tail-slice-ambiguity/extra/details/99`);
49+
cy.ionPageVisible('tail-slice-catchall');
50+
cy.get('[data-testid="catchall-path"]').should('contain', '/tail-slice-ambiguity/extra/details/99');
51+
});
52+
53+
it('should correctly navigate: details → list → ambiguous → back to list', () => {
54+
cy.visit(`http://localhost:${port}/tail-slice-ambiguity`);
55+
cy.ionPageVisible('tail-slice-list');
56+
57+
// Visit details
58+
cy.get('#go-to-details').click();
59+
cy.ionPageVisible('tail-slice-details');
60+
cy.get('[data-testid="details-id"]').should('contain', 'Details ID: 42');
61+
62+
// Back to list
63+
cy.get('#back-to-list').click();
64+
cy.ionPageVisible('tail-slice-list');
65+
66+
// Visit ambiguous path (catch-all)
67+
cy.get('#go-to-ambiguous').click();
68+
cy.ionPageVisible('tail-slice-catchall');
69+
70+
// Back to list via browser back
71+
cy.go('back');
72+
cy.ionPageVisible('tail-slice-list');
73+
});
74+
});

0 commit comments

Comments
 (0)