diff --git a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx index 902d0b327e..0982258ea7 100644 --- a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx +++ b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx @@ -419,7 +419,9 @@ export class ReactRouterViewStack extends ViewStacks { // Reactivate view if it matches but was previously deactivated // Don't reactivate if this is a parameterized route navigating to a different path instance - if (match && !viewItem.mount && !shouldSkipForDifferentParam) { + // Don't reactivate catch-all wildcard routes — they are created fresh by createViewItem + const isCatchAllWildcard = routePath === '*' || routePath === '/*'; + if (match && !viewItem.mount && !shouldSkipForDifferentParam && !isCatchAllWildcard) { viewItem.mount = true; viewItem.routeData.match = match; } @@ -702,6 +704,11 @@ export class ReactRouterViewStack extends ViewStacks { if (mustBeIonRoute && !v.ionRoute) return false; const viewItemPath = v.routeData.childProps.path || ''; + + // Skip unmounted catch-all wildcard views. After back navigation unmounts + // a wildcard view, it should not be reused for subsequent navigations. + // A fresh wildcard view will be created by createViewItem when needed. + if ((viewItemPath === '*' || viewItemPath === '/*') && !v.mount) return false; const isIndexRoute = !!v.routeData.childProps.index; const previousMatch = v.routeData?.match; const result = v.reactElement ? matchComponent(v.reactElement, pathname) : null; diff --git a/packages/react-router/src/ReactRouter/utils/computeParentPath.ts b/packages/react-router/src/ReactRouter/utils/computeParentPath.ts index 06dbf87876..5172d3f0cd 100644 --- a/packages/react-router/src/ReactRouter/utils/computeParentPath.ts +++ b/packages/react-router/src/ReactRouter/utils/computeParentPath.ts @@ -137,9 +137,31 @@ const findSpecificMatch = (routeChildren: React.ReactElement[], remainingPath: s /** * Checks if any specific route could plausibly match the remaining path. * Used to determine if we should fall back to a wildcard match. + * + * When the outlet's mount path is established, uses exact first-segment + * matching to avoid false positives from routes sharing a prefix + * (e.g., "settings" vs "setup"). On first visit (no mount path), uses a + * conservative 3-char prefix heuristic to prevent premature wildcard + * matching for parent path segments. */ -const couldSpecificRouteMatch = (routeChildren: React.ReactElement[], remainingPath: string): boolean => { - const remainingFirstSegment = remainingPath.split('/')[0]; +const couldSpecificRouteMatch = ( + routeChildren: React.ReactElement[], + remainingPath: string, + outletMountPath: string | undefined +): boolean => { + 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; + } + } + + // Check first-segment overlap with route paths return routeChildren.some((route) => { const routePath = route.props.path as string | undefined; if (!routePath || routePath === '*' || routePath === '/*') return false; @@ -148,7 +170,14 @@ const couldSpecificRouteMatch = (routeChildren: React.ReactElement[], remainingP const routeFirstSegment = routePath.split('/')[0].replace(/[*:]/g, ''); if (!routeFirstSegment) return false; - // Check for prefix overlap (either direction) + if (outletMountPath) { + // After mount path is established, use exact matching to avoid + // false positives from routes sharing a common prefix. + return routeFirstSegment === remainingFirstSegment; + } + + // On first visit (no mount path), use conservative prefix matching + // to prevent premature wildcard matches for parent path segments. return ( routeFirstSegment.startsWith(remainingFirstSegment.slice(0, 3)) || remainingFirstSegment.startsWith(routeFirstSegment.slice(0, 3)) @@ -263,7 +292,11 @@ export const computeParentPath = (options: ComputeParentPathOptions): ParentPath // Check for wildcard match (only if remaining path is non-empty) const hasNonEmptyRemaining = remainingPath !== '' && remainingPath !== '/'; if (!firstWildcardMatch && hasNonEmptyRemaining && hasWildcardRoute) { - if (!couldSpecificRouteMatch(routeChildren, remainingPath)) { + // When mount path is established, don't allow wildcard matches shallower + // than the mount path — the remaining segments at that depth are parent + // path segments, not content for the wildcard to catch. + const isTooShallow = outletMountPath && parentPath.length < outletMountPath.length; + if (!isTooShallow && !couldSpecificRouteMatch(routeChildren, remainingPath, outletMountPath)) { firstWildcardMatch = parentPath; } } diff --git a/packages/react-router/test/base/src/App.tsx b/packages/react-router/test/base/src/App.tsx index b342ea05d4..c2382eda9c 100644 --- a/packages/react-router/test/base/src/App.tsx +++ b/packages/react-router/test/base/src/App.tsx @@ -48,6 +48,7 @@ import RootSplatTabs from './pages/root-splat-tabs/RootSplatTabs'; import ContentChangeNavigation from './pages/content-change-navigation/ContentChangeNavigation'; import SearchParams from './pages/search-params/SearchParams'; import IonRoutePropsTest from './pages/ion-route-props/IonRouteProps'; +import PrefixMatchWildcard from './pages/prefix-match-wildcard/PrefixMatchWildcard'; setupIonicReact(); @@ -85,6 +86,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 2791289139..2d3659811b 100644 --- a/packages/react-router/test/base/src/pages/Main.tsx +++ b/packages/react-router/test/base/src/pages/Main.tsx @@ -89,6 +89,9 @@ const Main: React.FC = () => { IonRoute Props + + Prefix Match Wildcard + diff --git a/packages/react-router/test/base/src/pages/prefix-match-wildcard/PrefixMatchWildcard.tsx b/packages/react-router/test/base/src/pages/prefix-match-wildcard/PrefixMatchWildcard.tsx new file mode 100644 index 0000000000..44d12a1a4b --- /dev/null +++ b/packages/react-router/test/base/src/pages/prefix-match-wildcard/PrefixMatchWildcard.tsx @@ -0,0 +1,85 @@ +import { + IonContent, + IonHeader, + IonPage, + IonTitle, + IonToolbar, + IonRouterOutlet, + IonButton, +} from '@ionic/react'; +import React from 'react'; +import { Route, useNavigate } from 'react-router-dom'; + +/** + * Test page for verifying that wildcard routes work correctly when + * specific routes share a common prefix with the navigation target. + * + * Bug: couldSpecificRouteMatch uses a 3-char prefix heuristic that + * falsely suppresses wildcard matches when routes share a prefix + * (e.g., "settings" vs "setup" both start with "set"). + */ + +const SettingsPage: React.FC = () => ( + + + + Settings + + + +
Settings Page
+
+
+); + +const CatchAllPage: React.FC = () => ( + + + + Catch All + + + +
Wildcard Catch-All Page
+
+
+); + +const PrefixMatchHome: React.FC = () => { + const navigate = useNavigate(); + + return ( + + + + Prefix Match Test + + + +
+ navigate('settings')}> + Go to Settings + + navigate('setup')}> + Go to Setup (should hit wildcard) + + navigate('unknown')}> + Go to Unknown (should hit wildcard) + +
+
+
+ ); +}; + +const PrefixMatchWildcard: React.FC = () => { + return ( + + } /> + } /> + } /> + + ); +}; + +export default PrefixMatchWildcard; diff --git a/packages/react-router/test/base/tests/e2e/specs/prefix-match-wildcard.cy.js b/packages/react-router/test/base/tests/e2e/specs/prefix-match-wildcard.cy.js new file mode 100644 index 0000000000..8344d7b198 --- /dev/null +++ b/packages/react-router/test/base/tests/e2e/specs/prefix-match-wildcard.cy.js @@ -0,0 +1,62 @@ +const port = 3000; + +/** + * Tests that wildcard routes work correctly when specific routes share + * a common prefix with the navigation target. + * + * Bug: couldSpecificRouteMatch used a 3-char prefix heuristic that + * falsely blocked wildcard matches (e.g., "settings" vs "setup" both + * start with "set", causing the wildcard to not match "setup"). + */ +describe('Prefix Match Wildcard', () => { + it('should navigate to settings (specific route match)', () => { + cy.visit(`http://localhost:${port}/prefix-match-wildcard`); + cy.ionPageVisible('prefix-home'); + + cy.get('#go-to-settings').click(); + cy.ionPageVisible('prefix-settings'); + cy.get('[data-testid="settings-content"]').should('exist'); + }); + + it('should navigate to setup via wildcard (shares "set" prefix with settings)', () => { + cy.visit(`http://localhost:${port}/prefix-match-wildcard`); + cy.ionPageVisible('prefix-home'); + + cy.get('#go-to-setup').click(); + cy.ionPageVisible('prefix-catchall'); + cy.get('[data-testid="catchall-content"]').should('exist'); + }); + + it('should navigate to unknown path via wildcard', () => { + cy.visit(`http://localhost:${port}/prefix-match-wildcard`); + cy.ionPageVisible('prefix-home'); + + cy.get('#go-to-unknown').click(); + cy.ionPageVisible('prefix-catchall'); + cy.get('[data-testid="catchall-content"]').should('exist'); + }); + + it('should load settings directly when visiting URL', () => { + cy.visit(`http://localhost:${port}/prefix-match-wildcard/settings`); + cy.ionPageVisible('prefix-settings'); + cy.get('[data-testid="settings-content"]').should('exist'); + }); + + it('should navigate to wildcard, go back, then navigate to settings', () => { + cy.visit(`http://localhost:${port}/prefix-match-wildcard`); + cy.ionPageVisible('prefix-home'); + + // Navigate to a wildcard route + cy.get('#go-to-setup').click(); + cy.ionPageVisible('prefix-catchall'); + + // Go back to home + cy.go('back'); + cy.ionPageVisible('prefix-home'); + + // Navigate to settings — this should work after returning from wildcard + cy.get('#go-to-settings').click(); + cy.ionPageVisible('prefix-settings'); + cy.get('[data-testid="settings-content"]').should('exist'); + }); +});