From 433768ab5999ddca7931898a9a140c2e4e26c056 Mon Sep 17 00:00:00 2001 From: Eric Hechavarria Date: Tue, 19 May 2026 03:51:59 -0400 Subject: [PATCH 1/2] fix(react-router): symmetric pathname+search url comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The leavingUrl/currentUrl comparison in handleHistoryChange used `leavingLocationInfo.pathname + leavingLocationInfo.search` on the left side but `location.pathname` (no search) on the right. For any route with a non-empty search string, the comparison was always unequal, so the transition block ran on every history event — including no-op popstates over same-URL entries (e.g. pushed via window.history.pushState). That triggered a false POP transition to the previous route while the browser URL stayed put. Compares pathname+search on both sides so the block runs only when the URL actually changed. Verified with a new Cypress regression that pushes a same-URL state on a search-bearing route, calls history.back(), and asserts the page does not teleport. --- .../src/ReactRouter/IonRouter.tsx | 3 ++- .../test/base/tests/e2e/specs/routing.cy.js | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/react-router/src/ReactRouter/IonRouter.tsx b/packages/react-router/src/ReactRouter/IonRouter.tsx index fcadd6561a6..6faee1e3a50 100644 --- a/packages/react-router/src/ReactRouter/IonRouter.tsx +++ b/packages/react-router/src/ReactRouter/IonRouter.tsx @@ -107,7 +107,8 @@ class IonRouterInner extends React.PureComponent { } const leavingUrl = leavingLocationInfo.pathname + leavingLocationInfo.search; - if (leavingUrl !== location.pathname) { + const currentUrl = location.pathname + (location.search || ''); + if (leavingUrl !== currentUrl) { if (!this.incomingRouteParams) { if (action === 'REPLACE') { this.incomingRouteParams = { diff --git a/packages/react-router/test/base/tests/e2e/specs/routing.cy.js b/packages/react-router/test/base/tests/e2e/specs/routing.cy.js index fd28ee573c0..258e528a364 100644 --- a/packages/react-router/test/base/tests/e2e/specs/routing.cy.js +++ b/packages/react-router/test/base/tests/e2e/specs/routing.cy.js @@ -344,6 +344,25 @@ describe('Routing Tests', () => { cy.get('div.ion-page[data-pageid=home-details-page-1] [data-testid="details-input"]').should('have.value', '1'); }); + it('Details 1 with Query Params > pop a same-URL history entry, should stay on details 1', () => { + // Regression: popstate over a same-URL entry on a search-bearing route used to trigger a false POP transition. + cy.visit(`http://localhost:${port}/routing`); + cy.ionNav('ion-item', 'Details 1 with Query Params'); + cy.ionPageVisible('home-details-page-1'); + cy.location('search').should('eq', '?hello=there'); + + cy.window().then((win) => { + win.history.pushState({ marker: true }, '', win.location.href); + }); + + cy.go('back'); + + // No teleport: still on details 1, URL unchanged. + cy.ionPageVisible('home-details-page-1'); + cy.location('pathname').should('eq', '/routing/tabs/home/details/1'); + cy.location('search').should('eq', '?hello=there'); + }); + /* Tests to add: Test that lifecycle events fire From 5e616ab85a4781e4ad52a5c9d8b38383b78ecc93 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Tue, 4 Aug 2026 08:29:52 -0700 Subject: [PATCH 2/2] fix(react-router): prevent false pop transition when changing tabs --- .../src/ReactRouter/IonRouter.tsx | 9 +++- .../test/base/tests/e2e/specs/routing.cy.js | 44 ++++++++++++++++++- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/packages/react-router/src/ReactRouter/IonRouter.tsx b/packages/react-router/src/ReactRouter/IonRouter.tsx index 6faee1e3a50..761b19295d7 100644 --- a/packages/react-router/src/ReactRouter/IonRouter.tsx +++ b/packages/react-router/src/ReactRouter/IonRouter.tsx @@ -84,10 +84,15 @@ class IonRouterInner extends React.PureComponent { this.incomingRouteParams.routeOptions = routeOptions; this.props.history.push(routeInfo.pathname + (routeInfo.search || '')); } else { + /** + * The recorded search has to match the URL we push, otherwise + * handleHistoryChange sees a URL change where there is none. + */ + const normalizedSearch = search ? '?' + search : ''; this.incomingRouteParams.pathname = pathname; - this.incomingRouteParams.search = search ? '?' + search : undefined; + this.incomingRouteParams.search = normalizedSearch; this.incomingRouteParams.routeOptions = routeOptions; - this.props.history.push(pathname + (search ? '?' + search : '')); + this.props.history.push(pathname + normalizedSearch); } } else { this.handleNavigate(pathname, 'push', 'none', undefined, routeOptions, tab); diff --git a/packages/react-router/test/base/tests/e2e/specs/routing.cy.js b/packages/react-router/test/base/tests/e2e/specs/routing.cy.js index 258e528a364..bf71c305c3b 100644 --- a/packages/react-router/test/base/tests/e2e/specs/routing.cy.js +++ b/packages/react-router/test/base/tests/e2e/specs/routing.cy.js @@ -250,7 +250,7 @@ describe('Routing Tests', () => { it('/routing/ > Details 1 > Details 2 > Details 3 > Back > Settings Tab > Home Tab > Should be at details 2 page', () => { // fixes an issue where route history was being lost after starting to go back, switching tabs // and switching back to the same tab again - // for bug https://github.com/ionic-team/ionic-framework/issues/21834 + // For bug https://github.com/ionic-team/ionic-framework/issues/21834 cy.visit(`http://localhost:${port}/routing`); cy.ionPageVisible('home-page'); cy.ionNav('ion-item', 'Details 1'); @@ -346,6 +346,7 @@ describe('Routing Tests', () => { it('Details 1 with Query Params > pop a same-URL history entry, should stay on details 1', () => { // Regression: popstate over a same-URL entry on a search-bearing route used to trigger a false POP transition. + // For bug https://github.com/ionic-team/ionic-framework/issues/31152 cy.visit(`http://localhost:${port}/routing`); cy.ionNav('ion-item', 'Details 1 with Query Params'); cy.ionPageVisible('home-details-page-1'); @@ -357,12 +358,51 @@ describe('Routing Tests', () => { cy.go('back'); - // No teleport: still on details 1, URL unchanged. + // Wait for a late teleport, otherwise the assertions pass against the pre-transition state. + cy.wait(1000); + cy.ionPageVisible('home-details-page-1'); + cy.ionPageHidden('home-page'); cy.location('pathname').should('eq', '/routing/tabs/home/details/1'); cy.location('search').should('eq', '?hello=there'); }); + it('Details 1 > Settings Details 1 > Back > Settings Tab > pop a same-URL history entry, should stay on settings', () => { + // Regression: switching to a tab recorded a route with no search, so a popstate over a + // same-URL entry used to teleport to the home tab. + // For bug https://github.com/ionic-team/ionic-framework/issues/31152 + cy.visit(`http://localhost:${port}/routing`); + cy.ionNav('ion-item', 'Details 1'); + cy.ionPageVisible('home-details-page-1'); + + cy.ionNav('ion-button', 'Go to Settings Details 1'); + cy.ionPageVisible('settings-details-page-1'); + + cy.go('back'); + cy.ionPageVisible('home-details-page-1'); + + cy.ionTabClick('Settings'); + cy.ionPageVisible('settings-page'); + cy.location('pathname').should('eq', '/routing/tabs/settings'); + + // Let the tab switch finish so the pop doesn't race the transition. + cy.wait(1000); + + cy.window().then((win) => { + win.history.pushState({ marker: true }, '', win.location.href); + }); + + cy.go('back'); + + // Wait for a late teleport, otherwise the assertions pass against the pre-transition state. + cy.wait(1000); + + cy.ionPageVisible('settings-page'); + cy.ionPageHidden('home-details-page-1'); + cy.location('pathname').should('eq', '/routing/tabs/settings'); + cy.location('search').should('eq', ''); + }); + /* Tests to add: Test that lifecycle events fire