mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2026-03-13 10:22:08 +08:00
fix(react-router): prevent parameterized routes from catching multi-segment paths meant for wildcards
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user