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');
+ });
+});