fix(react-router): fix wildcard route reuse preventing navigation to specific routes after back

This commit is contained in:
ShaneK
2026-03-11 04:45:59 -07:00
parent 8ea275b555
commit 97d35179ba
6 changed files with 197 additions and 5 deletions

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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 = () => {
<Route path="/content-change-navigation/*" element={<ContentChangeNavigation />} />
<Route path="/search-params" element={<SearchParams />} />
<Route path="/ion-route-props/*" element={<IonRoutePropsTest />} />
<Route path="/prefix-match-wildcard/*" element={<PrefixMatchWildcard />} />
</IonRouterOutlet>
</IonReactRouter>
</IonApp>

View File

@@ -89,6 +89,9 @@ const Main: React.FC = () => {
<IonItem routerLink="/ion-route-props">
<IonLabel>IonRoute Props</IonLabel>
</IonItem>
<IonItem routerLink="/prefix-match-wildcard">
<IonLabel>Prefix Match Wildcard</IonLabel>
</IonItem>
</IonList>
</IonContent>
</IonPage>

View File

@@ -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 = () => (
<IonPage data-pageid="prefix-settings">
<IonHeader>
<IonToolbar>
<IonTitle>Settings</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<div data-testid="settings-content">Settings Page</div>
</IonContent>
</IonPage>
);
const CatchAllPage: React.FC = () => (
<IonPage data-pageid="prefix-catchall">
<IonHeader>
<IonToolbar>
<IonTitle>Catch All</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<div data-testid="catchall-content">Wildcard Catch-All Page</div>
</IonContent>
</IonPage>
);
const PrefixMatchHome: React.FC = () => {
const navigate = useNavigate();
return (
<IonPage data-pageid="prefix-home">
<IonHeader>
<IonToolbar>
<IonTitle>Prefix Match Test</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<div data-testid="prefix-home-content">
<IonButton id="go-to-settings" onClick={() => navigate('settings')}>
Go to Settings
</IonButton>
<IonButton id="go-to-setup" onClick={() => navigate('setup')}>
Go to Setup (should hit wildcard)
</IonButton>
<IonButton id="go-to-unknown" onClick={() => navigate('unknown')}>
Go to Unknown (should hit wildcard)
</IonButton>
</div>
</IonContent>
</IonPage>
);
};
const PrefixMatchWildcard: React.FC = () => {
return (
<IonRouterOutlet>
<Route index element={<PrefixMatchHome />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="*" element={<CatchAllPage />} />
</IonRouterOutlet>
);
};
export default PrefixMatchWildcard;

View File

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