diff --git a/packages/react-router/src/ReactRouter/utils/computeParentPath.ts b/packages/react-router/src/ReactRouter/utils/computeParentPath.ts index 5172d3f0cd..b36a150eb3 100644 --- a/packages/react-router/src/ReactRouter/utils/computeParentPath.ts +++ b/packages/react-router/src/ReactRouter/utils/computeParentPath.ts @@ -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; } diff --git a/packages/react-router/src/ReactRouter/utils/viewItemUtils.ts b/packages/react-router/src/ReactRouter/utils/viewItemUtils.ts index c5d8b84d19..6ded6d44be 100644 --- a/packages/react-router/src/ReactRouter/utils/viewItemUtils.ts +++ b/packages/react-router/src/ReactRouter/utils/viewItemUtils.ts @@ -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; }); }; diff --git a/packages/react-router/test/base/src/App.tsx b/packages/react-router/test/base/src/App.tsx index bfe95a9222..b90eccc0df 100644 --- a/packages/react-router/test/base/src/App.tsx +++ b/packages/react-router/test/base/src/App.tsx @@ -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 = () => { } /> } /> } /> + } /> diff --git a/packages/react-router/test/base/src/pages/Main.tsx b/packages/react-router/test/base/src/pages/Main.tsx index fd1ad7a13c..e064f29743 100644 --- a/packages/react-router/test/base/src/pages/Main.tsx +++ b/packages/react-router/test/base/src/pages/Main.tsx @@ -95,6 +95,9 @@ const Main: React.FC = () => { Stale View Cleanup + + Index Param Priority + diff --git a/packages/react-router/test/base/src/pages/index-param-priority/IndexParamPriority.tsx b/packages/react-router/test/base/src/pages/index-param-priority/IndexParamPriority.tsx new file mode 100644 index 0000000000..4707a5ef81 --- /dev/null +++ b/packages/react-router/test/base/src/pages/index-param-priority/IndexParamPriority.tsx @@ -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: + * (index route) + * (single-param route) + * (catch-all wildcard) + */ +const IndexParamPriority: React.FC = () => ( + + + + Index Param Priority + + + + + } /> + } /> + } /> + + + +); + +const IndexPage: React.FC = () => ( + + + + Index Page + + + + This is the index page + + Go to Slug "hello" + + + Go to Slug "world" + + + Go to Deep Path (wildcard) + + + +); + +const SlugPage: React.FC = () => { + const { slug } = useParams<{ slug: string }>(); + + return ( + + + + Slug: {slug} + + + + Slug page: {slug} + + Back to Index + + + Go to Slug "world" + + + + ); +}; + +const NotFoundPage: React.FC = () => ( + + + + Not Found + + + + Page not found (wildcard catch-all) + + Back to Index + + + +); + +export default IndexParamPriority; diff --git a/packages/react-router/test/base/tests/e2e/specs/index-param-priority.cy.js b/packages/react-router/test/base/tests/e2e/specs/index-param-priority.cy.js new file mode 100644 index 0000000000..b1858d5ceb --- /dev/null +++ b/packages/react-router/test/base/tests/e2e/specs/index-param-priority.cy.js @@ -0,0 +1,147 @@ +const port = 3000; + +describe('Index Param Priority', () => { + /* + Tests route specificity and priority in an outlet with: + (index route) + (single-param route) + (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'); + }); +});