fix(react-router): correct tab and nested outlet navigation

This commit is contained in:
ShaneK
2025-11-24 12:52:33 -08:00
parent 10c31eda6c
commit 045b0a71bc
4 changed files with 85 additions and 20 deletions

View File

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

View File

@@ -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 `<Redirect />` 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);
}
/**

View File

@@ -63,6 +63,11 @@ export class StackManager extends React.PureComponent<StackManagerProps, StackMa
private waitingForIonPage = false;
private ionPageWaitTimeout?: ReturnType<typeof setTimeout>;
private outOfScopeUnmountTimeout?: ReturnType<typeof setTimeout>;
/**
* 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<StackManagerProps, StackMa
* route or on an initial page load (i.e. refreshing). In cases when loading
* /tabs/tab-1, we need to transition the /tabs page element into the view.
*/
/**
* Check if we've already started a transition for the same entering/leaving pair.
* This can happen during rapid navigation (e.g., Navigate redirects) where
* multiple handlePageTransition calls occur before the first transition completes.
*
* Only skip if there's an actual leaving view involved - we don't want to skip
* transitions where leaving is undefined as those could be legitimate initial loads
* or transitions to new views.
*/
const currentTransition = {
enteringId: enteringViewItem.id,
leavingId: leavingViewItem?.id,
};
if (
leavingViewItem &&
this.lastTransition &&
this.lastTransition.leavingId &&
this.lastTransition.enteringId === currentTransition.enteringId &&
this.lastTransition.leavingId === currentTransition.leavingId
) {
console.log(
`[StackManager] Skipping duplicate transition: entering=${currentTransition.enteringId}, leaving=${currentTransition.leavingId}`
);
return;
}
this.lastTransition = currentTransition;
this.transitionPage(routeInfo, enteringViewItem, leavingViewItem);
if (shouldUnmountLeavingViewItem && leavingViewItem && enteringViewItem !== leavingViewItem) {
@@ -495,11 +529,23 @@ export class StackManager extends React.PureComponent<StackManagerProps, StackMa
// For replace actions, remove actual pages (with ionPageElement) from the stack entirely
// Don't remove utility components like Navigate that don't have ionPageElement
if (routeInfo.routeAction === 'replace' && leavingViewItem.ionPageElement) {
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);
// 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) {

View File

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