fix(react-router): prevent parameterized routes from catching multi-segment paths meant for wildcards

This commit is contained in:
ShaneK
2026-03-11 09:15:42 -07:00
parent cfed206e28
commit 5016e33628
6 changed files with 336 additions and 12 deletions

View File

@@ -68,6 +68,17 @@ const matchesEmbeddedWildcardRoute = (route: React.ReactElement, pathname: strin
return !!matchPath({ pathname, componentProps: route.props });
};
/**
* Checks if a route path consists entirely of parameterized segments (e.g., ":slug", ":category/:id").
* These routes match any single segment and should not drive the parent path deeper
* than the outlet's established mount point.
*/
const isPurelyParameterized = (routePath: string | undefined): boolean => {
if (!routePath) return false;
const segments = routePath.split('/').filter(Boolean);
return segments.length > 0 && segments.every((segment) => segment.startsWith(':'));
};
/**
* Checks if a route is a specific match (not wildcard-only or index).
*/
@@ -134,6 +145,18 @@ const findSpecificMatch = (routeChildren: React.ReactElement[], remainingPath: s
);
};
/**
* Returns the first route that matches as a specific (non-wildcard, non-index) route.
*/
const findFirstSpecificMatchingRoute = (
routeChildren: React.ReactElement[],
remainingPath: string
): React.ReactElement | undefined => {
return routeChildren.find(
(route) => isSpecificRouteMatch(route, remainingPath) || matchesEmbeddedWildcardRoute(route, remainingPath)
);
};
/**
* Checks if any specific route could plausibly match the remaining path.
* Used to determine if we should fall back to a wildcard match.
@@ -152,12 +175,18 @@ const couldSpecificRouteMatch = (
const segments = remainingPath.split('/');
const remainingFirstSegment = segments[0];
// For multi-segment paths, check if consuming more parent segments
// would produce a specific route match at a deeper level
for (let j = 1; j < segments.length; j++) {
const futureRemaining = segments.slice(j).join('/');
if (findSpecificMatch(routeChildren, futureRemaining)) {
return true;
// For multi-segment paths on first visit (no mount path), check if consuming
// more parent segments would produce a specific route match at a deeper level.
// When mount path is established, skip this lookahead: the parent depth is known,
// and purely parameterized routes (e.g., :slug) matching the last segment should
// not prevent the wildcard from claiming the full remaining path.
if (!outletMountPath) {
for (let j = 1; j < segments.length; j++) {
const futureRemaining = segments.slice(j).join('/');
const futureMatch = findFirstSpecificMatchingRoute(routeChildren, futureRemaining);
if (futureMatch && !isPurelyParameterized(futureMatch.props.path as string)) {
return true;
}
}
}
@@ -285,6 +314,22 @@ export const computeParentPath = (options: ComputeParentPathOptions): ParentPath
// Check for specific route match (highest priority)
if (!firstSpecificMatch && findSpecificMatch(routeChildren, remainingPath)) {
// Don't let purely parameterized routes (e.g., :slug, :id) drive the
// parent deeper than where a wildcard already matched. A :slug route
// matching the last segment of "deep/nested/path" shouldn't pull the
// parent to /parent/deep/nested — the wildcard at the correct depth
// should catch the full remaining path instead.
// Literal routes (e.g., "settings", "redirect") can still match beyond
// the wildcard depth to support redirect scenarios.
const shouldSkipParameterized =
(outletMountPath && parentPath.length > outletMountPath.length) ||
(!outletMountPath && firstWildcardMatch);
if (shouldSkipParameterized) {
const matchingRoute = findFirstSpecificMatchingRoute(routeChildren, remainingPath);
if (matchingRoute && isPurelyParameterized(matchingRoute.props.path as string)) {
continue;
}
}
firstSpecificMatch = parentPath;
break;
}

View File

@@ -2,8 +2,12 @@ import type { ViewItem } from '@ionic/react';
/**
* Sorts view items by route specificity (most specific first).
* - Exact matches (no wildcards/params) come first
* - Among wildcard routes, longer paths are more specific
*
* Sort order aligns with findRouteByRouteInfo in StackManager.tsx:
* 1. Index routes come first
* 2. Wildcard-only routes (* or /*) come last
* 3. Exact matches (no wildcards/params) come before wildcard/param routes
* 4. Among routes with same wildcard status, longer paths are more specific
*
* @param views The view items to sort.
* @returns A new sorted array of view items.
@@ -13,14 +17,29 @@ export const sortViewsBySpecificity = (views: ViewItem[]): ViewItem[] => {
const pathA = a.routeData?.childProps?.path || '';
const pathB = b.routeData?.childProps?.path || '';
// Exact matches (no wildcards/params) come first
// Index routes come first
const aIsIndex = !!a.routeData?.childProps?.index;
const bIsIndex = !!b.routeData?.childProps?.index;
if (aIsIndex && !bIsIndex) return -1;
if (!aIsIndex && bIsIndex) return 1;
// Wildcard-only routes (* or /*) should come last
const aIsWildcardOnly = pathA === '*' || pathA === '/*';
const bIsWildcardOnly = pathB === '*' || pathB === '/*';
if (!aIsWildcardOnly && bIsWildcardOnly) return -1;
if (aIsWildcardOnly && !bIsWildcardOnly) return 1;
// Exact matches (no wildcards/params) come before wildcard/param routes
const aHasWildcard = pathA.includes('*') || pathA.includes(':');
const bHasWildcard = pathB.includes('*') || pathB.includes(':');
if (!aHasWildcard && bHasWildcard) return -1;
if (aHasWildcard && !bHasWildcard) return 1;
// Among wildcard routes, longer paths are more specific
return pathB.length - pathA.length;
// Among routes with same wildcard status, longer paths are more specific
if (pathA.length !== pathB.length) {
return pathB.length - pathA.length;
}
return 0;
});
};

View File

@@ -50,6 +50,7 @@ import SearchParams from './pages/search-params/SearchParams';
import IonRoutePropsTest from './pages/ion-route-props/IonRouteProps';
import PrefixMatchWildcard from './pages/prefix-match-wildcard/PrefixMatchWildcard';
import StaleViewCleanup from './pages/stale-view-cleanup/StaleViewCleanup';
import IndexParamPriority from './pages/index-param-priority/IndexParamPriority';
setupIonicReact();
@@ -89,6 +90,7 @@ const App: React.FC = () => {
<Route path="/ion-route-props/*" element={<IonRoutePropsTest />} />
<Route path="/prefix-match-wildcard/*" element={<PrefixMatchWildcard />} />
<Route path="/stale-view-cleanup/*" element={<StaleViewCleanup />} />
<Route path="/index-param-priority/*" element={<IndexParamPriority />} />
</IonRouterOutlet>
</IonReactRouter>
</IonApp>

View File

@@ -95,6 +95,9 @@ const Main: React.FC = () => {
<IonItem routerLink="/stale-view-cleanup/non-ionpage">
<IonLabel>Stale View Cleanup</IonLabel>
</IonItem>
<IonItem routerLink="/index-param-priority">
<IonLabel>Index Param Priority</IonLabel>
</IonItem>
</IonList>
</IonContent>
</IonPage>

View File

@@ -0,0 +1,108 @@
import {
IonButton,
IonContent,
IonHeader,
IonLabel,
IonPage,
IonRouterOutlet,
IonTitle,
IonToolbar,
} from '@ionic/react';
import React from 'react';
import { Route } from 'react-router';
import { useParams } from 'react-router-dom';
/**
* Test page for route specificity and priority:
*
* 1. Sort consistency: index routes should be prioritized over parameterized routes
* in both findRouteByRouteInfo (creation) and sortViewsBySpecificity (lookup).
*
* 2. Wildcard vs param matching: multi-segment paths like "deep/nested/path" should
* match the wildcard (*), NOT the single-param route (:slug). The :slug route
* should only match single-segment paths.
*
* Route configuration:
* <Route index /> (index route)
* <Route path=":slug" /> (single-param route)
* <Route path="*" /> (catch-all wildcard)
*/
const IndexParamPriority: React.FC = () => (
<IonPage data-pageid="index-param-priority-root">
<IonHeader>
<IonToolbar>
<IonTitle>Index Param Priority</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<IonRouterOutlet id="index-param-priority-outlet">
<Route index element={<IndexPage />} />
<Route path=":slug" element={<SlugPage />} />
<Route path="*" element={<NotFoundPage />} />
</IonRouterOutlet>
</IonContent>
</IonPage>
);
const IndexPage: React.FC = () => (
<IonPage data-pageid="index-param-priority-index">
<IonHeader>
<IonToolbar>
<IonTitle>Index Page</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<IonLabel data-testid="index-page-label">This is the index page</IonLabel>
<IonButton routerLink="/index-param-priority/hello" id="go-to-slug">
Go to Slug "hello"
</IonButton>
<IonButton routerLink="/index-param-priority/world" id="go-to-slug-world">
Go to Slug "world"
</IonButton>
<IonButton routerLink="/index-param-priority/deep/nested/path" id="go-to-wildcard">
Go to Deep Path (wildcard)
</IonButton>
</IonContent>
</IonPage>
);
const SlugPage: React.FC = () => {
const { slug } = useParams<{ slug: string }>();
return (
<IonPage data-pageid="index-param-priority-slug">
<IonHeader>
<IonToolbar>
<IonTitle>Slug: {slug}</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<IonLabel data-testid="slug-page-label">Slug page: {slug}</IonLabel>
<IonButton routerLink="/index-param-priority" id="back-to-index">
Back to Index
</IonButton>
<IonButton routerLink="/index-param-priority/world" id="go-to-another-slug">
Go to Slug "world"
</IonButton>
</IonContent>
</IonPage>
);
};
const NotFoundPage: React.FC = () => (
<IonPage data-pageid="index-param-priority-notfound">
<IonHeader>
<IonToolbar>
<IonTitle>Not Found</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<IonLabel data-testid="notfound-page-label">Page not found (wildcard catch-all)</IonLabel>
<IonButton routerLink="/index-param-priority" id="back-to-index-from-notfound">
Back to Index
</IonButton>
</IonContent>
</IonPage>
);
export default IndexParamPriority;

View File

@@ -0,0 +1,147 @@
const port = 3000;
describe('Index Param Priority', () => {
/*
Tests route specificity and priority in an outlet with:
<Route index /> (index route)
<Route path=":slug" /> (single-param route)
<Route path="*" /> (catch-all wildcard)
Validates:
1. Index routes are prioritized over parameterized routes in both
route creation and view lookup (sort consistency fix).
2. Multi-segment paths match the wildcard, not the single-param route
(computeParentPath fix to respect outlet mount path).
*/
it('should show the index page on initial visit', () => {
cy.visit(`http://localhost:${port}/index-param-priority`);
cy.ionPageVisible('index-param-priority-index');
cy.get('[data-testid="index-page-label"]').should('contain', 'This is the index page');
});
it('should navigate to slug and back to index via routerLink', () => {
cy.visit(`http://localhost:${port}/index-param-priority`);
cy.ionPageVisible('index-param-priority-index');
cy.get('#go-to-slug').click();
cy.get('[data-testid="slug-page-label"]').should('contain', 'Slug page: hello');
cy.get('#back-to-index').click();
cy.ionPageVisible('index-param-priority-index');
cy.get('[data-testid="index-page-label"]').should('contain', 'This is the index page');
});
it('should navigate to slug and back to index via browser back', () => {
cy.visit(`http://localhost:${port}/index-param-priority`);
cy.ionPageVisible('index-param-priority-index');
cy.get('#go-to-slug').click();
cy.get('[data-testid="slug-page-label"]').should('contain', 'Slug page: hello');
cy.go('back');
cy.ionPageVisible('index-param-priority-index');
cy.get('[data-testid="index-page-label"]').should('contain', 'This is the index page');
});
it('should handle round-trip: index -> slug -> index -> slug -> index', () => {
cy.visit(`http://localhost:${port}/index-param-priority`);
cy.ionPageVisible('index-param-priority-index');
cy.get('#go-to-slug').click();
cy.get('[data-testid="slug-page-label"]').should('contain', 'Slug page: hello');
cy.go('back');
cy.ionPageVisible('index-param-priority-index');
cy.get('#go-to-slug-world').click();
cy.get('[data-testid="slug-page-label"]').should('contain', 'Slug page: world');
cy.go('back');
cy.ionPageVisible('index-param-priority-index');
cy.get('[data-testid="index-page-label"]').should('contain', 'This is the index page');
});
it('should handle browser back through multiple navigations', () => {
cy.visit(`http://localhost:${port}/index-param-priority`);
cy.ionPageVisible('index-param-priority-index');
cy.get('#go-to-slug').click();
cy.get('[data-testid="slug-page-label"]').should('contain', 'Slug page: hello');
cy.get('#back-to-index').click();
cy.ionPageVisible('index-param-priority-index');
cy.get('#go-to-slug-world').click();
cy.get('[data-testid="slug-page-label"]').should('contain', 'Slug page: world');
cy.go('back');
cy.ionPageVisible('index-param-priority-index');
cy.get('[data-testid="index-page-label"]').should('contain', 'This is the index page');
});
it('should handle direct deep link to slug then navigate to index', () => {
cy.visit(`http://localhost:${port}/index-param-priority/hello`);
cy.get('[data-testid="slug-page-label"]').should('contain', 'Slug page: hello');
cy.get('#back-to-index').click();
cy.ionPageVisible('index-param-priority-index');
cy.get('[data-testid="index-page-label"]').should('contain', 'This is the index page');
});
// Wildcard matching tests - multi-segment paths should match * not :slug
it('should navigate to wildcard page for multi-segment path and back to index', () => {
cy.visit(`http://localhost:${port}/index-param-priority`);
cy.ionPageVisible('index-param-priority-index');
cy.get('#go-to-wildcard').click();
cy.get('[data-testid="notfound-page-label"]').should('contain', 'Page not found');
cy.get('#back-to-index-from-notfound').click();
cy.ionPageVisible('index-param-priority-index');
cy.get('[data-testid="index-page-label"]').should('contain', 'This is the index page');
});
it('should navigate slug -> index -> wildcard -> index round-trip', () => {
cy.visit(`http://localhost:${port}/index-param-priority`);
cy.ionPageVisible('index-param-priority-index');
// Go to slug
cy.get('#go-to-slug').click();
cy.get('[data-testid="slug-page-label"]').should('contain', 'Slug page: hello');
// Back to index
cy.go('back');
cy.ionPageVisible('index-param-priority-index');
// Go to wildcard path
cy.get('#go-to-wildcard').click();
cy.get('[data-testid="notfound-page-label"]').should('contain', 'Page not found');
// Back to index
cy.get('#back-to-index-from-notfound').click();
cy.ionPageVisible('index-param-priority-index');
cy.get('[data-testid="index-page-label"]').should('contain', 'This is the index page');
});
it('should handle browser back from wildcard to index', () => {
cy.visit(`http://localhost:${port}/index-param-priority`);
cy.ionPageVisible('index-param-priority-index');
cy.get('#go-to-wildcard').click();
cy.get('[data-testid="notfound-page-label"]').should('contain', 'Page not found');
cy.go('back');
cy.ionPageVisible('index-param-priority-index');
cy.get('[data-testid="index-page-label"]').should('contain', 'This is the index page');
});
it('should show wildcard page on direct deep-link to multi-segment path', () => {
cy.visit(`http://localhost:${port}/index-param-priority/deep/nested/path`);
cy.get('[data-testid="notfound-page-label"]').should('contain', 'Page not found');
cy.get('#back-to-index-from-notfound').click();
cy.ionPageVisible('index-param-priority-index');
cy.get('[data-testid="index-page-label"]').should('contain', 'This is the index page');
});
});