From 045b0a71bce7ba5931e3d7c2012c3e0eaead5024 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Mon, 24 Nov 2025 12:52:33 -0800 Subject: [PATCH] fix(react-router): correct tab and nested outlet navigation --- packages/react-router/scripts/test_runner.sh | 3 +- .../src/ReactRouter/IonRouter.tsx | 28 ++++++---- .../src/ReactRouter/StackManager.tsx | 56 +++++++++++++++++-- packages/react/src/routing/LocationHistory.ts | 18 +++++- 4 files changed, 85 insertions(+), 20 deletions(-) diff --git a/packages/react-router/scripts/test_runner.sh b/packages/react-router/scripts/test_runner.sh index a57ad589cd..9e616448a8 100755 --- a/packages/react-router/scripts/test_runner.sh +++ b/packages/react-router/scripts/test_runner.sh @@ -64,6 +64,5 @@ fi echo "Server is healthy." echo "Running Cypress tests..." -# Run specific failing tests first -npm run cypress -- --spec "tests/e2e/specs/swipe-to-go-back.cy.js" +npm run cypress diff --git a/packages/react-router/src/ReactRouter/IonRouter.tsx b/packages/react-router/src/ReactRouter/IonRouter.tsx index 74a9bd400e..b921eb86e4 100644 --- a/packages/react-router/src/ReactRouter/IonRouter.tsx +++ b/packages/react-router/src/ReactRouter/IonRouter.tsx @@ -233,7 +233,9 @@ export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildr }; // It's a linear navigation. if (isPushed) { - routeInfo.tab = leavingLocationInfo.tab; + // Only inherit tab from leaving route if we don't already have one. + // This preserves tab context for same-tab navigation while allowing cross-tab navigation. + routeInfo.tab = routeInfo.tab || leavingLocationInfo.tab; routeInfo.pushedByRoute = leavingLocationInfo.pathname; // Triggered by a browser back button or handleNavigateBack. } else if (routeInfo.routeAction === 'pop') { @@ -248,7 +250,10 @@ export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildr */ const lastRoute = locationHistory.current.getCurrentRouteInfoForTab(routeInfo.tab); // This helps maintain correct back stack behavior within tabs. - routeInfo.pushedByRoute = lastRoute?.pushedByRoute; + // If this is the first time entering this tab from a different context, + // use the leaving route's pathname as the pushedByRoute to maintain the back stack. + routeInfo.pushedByRoute = lastRoute?.pushedByRoute ?? leavingLocationInfo.pathname; + console.log('[IonRouter TAB SWITCH] pathname=' + routeInfo.pathname + ' tab=' + routeInfo.tab + ' leavingTab=' + leavingLocationInfo.tab + ' leavingPathname=' + leavingLocationInfo.pathname + ' lastRoutePushedBy=' + (lastRoute?.pushedByRoute || 'undefined') + ' FINAL_pushedByRoute=' + routeInfo.pushedByRoute); // Triggered by `history.replace()` or a `` component, etc. } else if (routeInfo.routeAction === 'replace') { /** @@ -403,9 +408,11 @@ export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildr const config = getConfig(); defaultHref = defaultHref ? defaultHref : config && config.get('backButtonDefaultHref' as any); const routeInfo = locationHistory.current.current(); + console.log('[IonRouter BACK] START currentPath=' + (routeInfo?.pathname || 'undefined') + ' currentTab=' + (routeInfo?.tab || 'undefined') + ' pushedByRoute=' + (routeInfo?.pushedByRoute || 'undefined')); // It's a linear navigation. if (routeInfo && routeInfo.pushedByRoute) { const prevInfo = locationHistory.current.findLastLocation(routeInfo); + console.log('[IonRouter BACK] findLastLocation result: prevPath=' + (prevInfo?.pathname || 'undefined') + ' prevTab=' + (prevInfo?.tab || 'undefined')); if (prevInfo) { /** * This needs to be passed to handleNavigate @@ -423,22 +430,18 @@ export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildr * Check if it's a simple linear back navigation (not tabbed). * e.g., `/home` → `/settings` → back to `/home` */ - if ( - routeInfo.lastPathname === routeInfo.pushedByRoute || - /** - * We need to exclude tab switches/tab - * context changes here because tabbed - * navigation is not linear, but router.back() - * will go back in a linear fashion. - */ - (prevInfo.pathname === routeInfo.pushedByRoute && routeInfo.tab === '' && prevInfo.tab === '') - ) { + const condition1 = routeInfo.lastPathname === routeInfo.pushedByRoute; + const condition2 = prevInfo.pathname === routeInfo.pushedByRoute && routeInfo.tab === '' && prevInfo.tab === ''; + console.log('[IonRouter BACK] Decision: condition1=' + condition1 + ' (lastPathname=' + routeInfo.lastPathname + ' == pushedByRoute=' + routeInfo.pushedByRoute + ') condition2=' + condition2 + ' (prevPath=' + prevInfo.pathname + ' == pushedByRoute=' + routeInfo.pushedByRoute + ' && currentTab=' + routeInfo.tab + ' && prevTab=' + prevInfo.tab + ')'); + if (condition1 || condition2) { + console.log('[IonRouter BACK] Using navigate(-1) - LINEAR navigation'); navigate(-1); } else { /** * It's a non-linear back navigation. * e.g., direct link or tab switch or nested navigation with redirects */ + console.log('[IonRouter BACK] Using handleNavigate - NON-LINEAR navigation to: ' + prevInfo.pathname); handleNavigate(prevInfo.pathname + (prevInfo.search || ''), 'pop', 'back', incomingAnimation); } /** @@ -446,6 +449,7 @@ export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildr * the history stack. */ } else { + console.log('[IonRouter BACK] No prevInfo found! Using defaultHref: ' + defaultHref); handleNavigate(defaultHref as string, 'pop', 'back', routeAnimation); } /** diff --git a/packages/react-router/src/ReactRouter/StackManager.tsx b/packages/react-router/src/ReactRouter/StackManager.tsx index 09161bb358..c52152eb50 100644 --- a/packages/react-router/src/ReactRouter/StackManager.tsx +++ b/packages/react-router/src/ReactRouter/StackManager.tsx @@ -63,6 +63,11 @@ export class StackManager extends React.PureComponent; private outOfScopeUnmountTimeout?: ReturnType; + /** + * Track the last transition's entering and leaving view IDs to prevent + * duplicate transitions during rapid navigation (e.g., Navigate redirects) + */ + private lastTransition?: { enteringId: string; leavingId?: string }; constructor(props: StackManagerProps) { super(props); @@ -488,6 +493,35 @@ export class StackManager extends React.PureComponent { - // Use a timeout to ensure the transition completes before removal - this.context.unMountViewItem(leavingViewItem); - }, 250); + // Check if the entering view contains a nested outlet that's responsible for the replace. + // If the entering view's route has a wildcard (e.g., /tabs/*), it means this outlet + // is showing a container view and the replace is happening inside the nested outlet. + // In this case, we should NOT remove the leaving view from this outlet's stack, + // as it may be needed for back navigation. + const enteringRoutePath = enteringViewItem.reactElement?.props?.path as string | undefined; + const isEnteringContainerRoute = enteringRoutePath && enteringRoutePath.endsWith('/*'); + + if (isEnteringContainerRoute) { + console.log(`[StackManager] Skipping removal of ${leavingViewItem.id} - entering route ${enteringRoutePath} is a container with nested outlet`); + } else { + console.log(`[StackManager] Removing page view ${leavingViewItem.id} from stack after replace action`); + setTimeout(() => { + // Use a timeout to ensure the transition completes before removal + this.context.unMountViewItem(leavingViewItem); + }, 250); + } } } } else if (enteringViewItem && !enteringViewItem.ionPageElement) { diff --git a/packages/react/src/routing/LocationHistory.ts b/packages/react/src/routing/LocationHistory.ts index e99e5a88cc..f2c185a723 100644 --- a/packages/react/src/routing/LocationHistory.ts +++ b/packages/react/src/routing/LocationHistory.ts @@ -90,8 +90,24 @@ export class LocationHistory { private _replace(routeInfo: RouteInfo) { const routeInfos = this._getRouteInfosByKey(routeInfo.tab); + const hadPreviousTabHistory = routeInfos && routeInfos.length > 0; routeInfos && routeInfos.pop(); - this.locationHistory.pop(); + + // Get the current route that's being replaced + const currentRoute = this.locationHistory[this.locationHistory.length - 1]; + + console.log('[LocationHistory._replace] currentRoute:', currentRoute?.pathname, 'tab:', currentRoute?.tab); + console.log('[LocationHistory._replace] newRoute:', routeInfo.pathname, 'tab:', routeInfo.tab); + + // Only pop from global history if we're replacing in the same outlet context. + // Don't pop if we're entering a nested outlet (current route has no tab, new route has a tab) + const isEnteringNestedOutlet = currentRoute && !currentRoute.tab && !!routeInfo.tab; + console.log('[LocationHistory._replace] isEnteringNestedOutlet:', isEnteringNestedOutlet); + + if (!isEnteringNestedOutlet) { + this.locationHistory.pop(); + } + this._add(routeInfo); }