From 6762e5b1c7165025e676140c64ab461795757e9d Mon Sep 17 00:00:00 2001 From: ShaneK Date: Wed, 10 Dec 2025 06:11:26 -0800 Subject: [PATCH] fix(react-router): isolate tab history to prevent cross-tab back navigation --- .../src/ReactRouter/IonRouter.tsx | 20 ++- packages/react-router/test/base/src/App.tsx | 2 + .../react-router/test/base/src/pages/Main.tsx | 3 + .../TabHistoryIsolation.tsx | 162 ++++++++++++++++++ .../e2e/specs/tab-history-isolation.cy.js | 124 ++++++++++++++ 5 files changed, 305 insertions(+), 6 deletions(-) create mode 100644 packages/react-router/test/base/src/pages/tab-history-isolation/TabHistoryIsolation.tsx create mode 100644 packages/react-router/test/base/tests/e2e/specs/tab-history-isolation.cy.js diff --git a/packages/react-router/src/ReactRouter/IonRouter.tsx b/packages/react-router/src/ReactRouter/IonRouter.tsx index 69549956022..f1d475a733b 100644 --- a/packages/react-router/src/ReactRouter/IonRouter.tsx +++ b/packages/react-router/src/ReactRouter/IonRouter.tsx @@ -269,10 +269,15 @@ export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildr * tab and use its `pushedByRoute`. */ const lastRoute = locationHistory.current.getCurrentRouteInfoForTab(routeInfo.tab); - // This helps maintain correct back stack behavior within tabs. - // 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; + /** + * Tab bar switches (direction 'none') should not create cross-tab back + * navigation. Only inherit pushedByRoute from the tab's own history. + */ + if (routeInfo.routeDirection === 'none') { + routeInfo.pushedByRoute = lastRoute?.pushedByRoute; + } else { + routeInfo.pushedByRoute = lastRoute?.pushedByRoute ?? leavingLocationInfo.pathname; + } // Triggered by `history.replace()` or a `` component, etc. } else if (routeInfo.routeAction === 'replace') { /** @@ -465,10 +470,13 @@ export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildr handleNavigate(defaultHref as string, 'pop', 'back', routeAnimation); } /** - * No `pushedByRoute` - * e.g., initial page load + * No `pushedByRoute` (e.g., initial page load or tab root). + * Tabs with no back history should not navigate. */ } else { + if (routeInfo && routeInfo.tab) { + return; + } handleNavigate(defaultHref as string, 'pop', 'back', routeAnimation); } }; diff --git a/packages/react-router/test/base/src/App.tsx b/packages/react-router/test/base/src/App.tsx index 9cc461a4176..a97c04f5d71 100644 --- a/packages/react-router/test/base/src/App.tsx +++ b/packages/react-router/test/base/src/App.tsx @@ -40,6 +40,7 @@ import { SwipeToGoBack } from './pages/swipe-to-go-back/SwipToGoBack'; import TabsContext from './pages/tab-context/TabContext'; import Tabs from './pages/tabs/Tabs'; import TabsSecondary from './pages/tabs/TabsSecondary'; +import TabHistoryIsolation from './pages/tab-history-isolation/TabHistoryIsolation'; import Overlays from './pages/overlays/Overlays'; setupIonicReact(); @@ -66,6 +67,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 da6f3dd0667..4f87061e347 100644 --- a/packages/react-router/test/base/src/pages/Main.tsx +++ b/packages/react-router/test/base/src/pages/Main.tsx @@ -68,6 +68,9 @@ const Main: React.FC = () => { Tabs + + Tab History Isolation + Params diff --git a/packages/react-router/test/base/src/pages/tab-history-isolation/TabHistoryIsolation.tsx b/packages/react-router/test/base/src/pages/tab-history-isolation/TabHistoryIsolation.tsx new file mode 100644 index 00000000000..a3ea324c85c --- /dev/null +++ b/packages/react-router/test/base/src/pages/tab-history-isolation/TabHistoryIsolation.tsx @@ -0,0 +1,162 @@ +import { + IonTabs, + IonRouterOutlet, + IonTabBar, + IonTabButton, + IonIcon, + IonLabel, + IonPage, + IonHeader, + IonToolbar, + IonButtons, + IonBackButton, + IonTitle, + IonContent, + IonButton, +} from '@ionic/react'; +import { triangle, square, ellipse } from 'ionicons/icons'; +import React from 'react'; +import { Route, Navigate } from 'react-router'; + +const TabHistoryIsolation: React.FC = () => { + return ( + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + Tab A + + + + Tab B + + + + Tab C + + + + ); +}; + +const TabA = () => { + return ( + + + + + + + Tab A + + + + Tab A + + Go to A Details + + + + ); +}; + +const TabB = () => { + return ( + + + + + + + Tab B + + + + Tab B + + Go to B Details + + + + ); +}; + +const TabC = () => { + return ( + + + + + + + Tab C + + + + Tab C + + Go to C Details + + + + ); +}; + +const TabADetails = () => { + return ( + + + + + + + Tab A Details + + + Tab A Details + + ); +}; + +const TabBDetails = () => { + return ( + + + + + + + Tab B Details + + + Tab B Details + + ); +}; + +const TabCDetails = () => { + return ( + + + + + + + Tab C Details + + + Tab C Details + + ); +}; + +export default TabHistoryIsolation; diff --git a/packages/react-router/test/base/tests/e2e/specs/tab-history-isolation.cy.js b/packages/react-router/test/base/tests/e2e/specs/tab-history-isolation.cy.js new file mode 100644 index 00000000000..84eeeb34882 --- /dev/null +++ b/packages/react-router/test/base/tests/e2e/specs/tab-history-isolation.cy.js @@ -0,0 +1,124 @@ +const port = 3000; + +describe('Tab History Isolation', () => { + it('should NOT navigate back to previous tab when using back button after tab bar switch', () => { + cy.visit(`http://localhost:${port}/tab-history-isolation/a`); + cy.ionPageVisible('tab-a'); + + cy.ionTabClick('Tab B'); + cy.ionPageHidden('tab-a'); + cy.ionPageVisible('tab-b'); + + cy.get(`div.ion-page[data-pageid=tab-b]`) + .find('ion-back-button') + .click({ force: true }); + + cy.wait(500); + + cy.ionPageVisible('tab-b'); + cy.ionPageHidden('tab-a'); + cy.url().should('include', '/tab-history-isolation/b'); + }); + + it('should NOT allow back navigation through multiple tab switches', () => { + cy.visit(`http://localhost:${port}/tab-history-isolation/a`); + cy.ionPageVisible('tab-a'); + + cy.ionTabClick('Tab B'); + cy.ionPageHidden('tab-a'); + cy.ionPageVisible('tab-b'); + + cy.ionTabClick('Tab C'); + cy.ionPageHidden('tab-b'); + cy.ionPageVisible('tab-c'); + + cy.get(`div.ion-page[data-pageid=tab-c]`) + .find('ion-back-button') + .click({ force: true }); + + cy.wait(500); + + cy.ionPageVisible('tab-c'); + cy.url().should('include', '/tab-history-isolation/c'); + }); + + it('should navigate back within the same tab when using back button', () => { + cy.visit(`http://localhost:${port}/tab-history-isolation/a`); + cy.ionPageVisible('tab-a'); + + cy.get('#go-to-a-details').click(); + cy.ionPageHidden('tab-a'); + cy.ionPageVisible('tab-a-details'); + + cy.ionBackClick('tab-a-details'); + cy.ionPageDoesNotExist('tab-a-details'); + cy.ionPageVisible('tab-a'); + + cy.url().should('include', '/tab-history-isolation/a'); + cy.url().should('not.include', '/details'); + }); + + it('should only navigate back within current tab after switching tabs and navigating', () => { + cy.visit(`http://localhost:${port}/tab-history-isolation/a`); + cy.ionPageVisible('tab-a'); + + cy.ionTabClick('Tab B'); + cy.ionPageHidden('tab-a'); + cy.ionPageVisible('tab-b'); + + cy.get('#go-to-b-details').click(); + cy.ionPageHidden('tab-b'); + cy.ionPageVisible('tab-b-details'); + + cy.ionBackClick('tab-b-details'); + cy.ionPageDoesNotExist('tab-b-details'); + cy.ionPageVisible('tab-b'); + + cy.url().should('include', '/tab-history-isolation/b'); + cy.url().should('not.include', '/details'); + + cy.get(`div.ion-page[data-pageid=tab-b]`) + .find('ion-back-button') + .click({ force: true }); + + cy.wait(500); + + cy.ionPageVisible('tab-b'); + cy.url().should('include', '/tab-history-isolation/b'); + }); + + it('should preserve tab history when switching away and back', () => { + cy.visit(`http://localhost:${port}/tab-history-isolation/a`); + cy.ionPageVisible('tab-a'); + + cy.get('#go-to-a-details').click(); + cy.ionPageHidden('tab-a'); + cy.ionPageVisible('tab-a-details'); + + cy.ionTabClick('Tab B'); + cy.ionPageHidden('tab-a-details'); + cy.ionPageVisible('tab-b'); + + cy.ionTabClick('Tab A'); + cy.ionPageHidden('tab-b'); + cy.ionPageVisible('tab-a-details'); + + cy.ionBackClick('tab-a-details'); + cy.ionPageDoesNotExist('tab-a-details'); + cy.ionPageVisible('tab-a'); + }); + + it('should have no back navigation when first visiting a tab', () => { + cy.visit(`http://localhost:${port}/tab-history-isolation/a`); + cy.ionPageVisible('tab-a'); + + cy.get(`div.ion-page[data-pageid=tab-a]`) + .find('ion-back-button') + .click({ force: true }); + + cy.wait(500); + + cy.ionPageVisible('tab-a'); + cy.url().should('include', '/tab-history-isolation/a'); + }); +});