mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2026-03-13 10:22:08 +08:00
fix(react-router): fix wildcard route reuse preventing navigation to specific routes after back
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user