Prerequisites
Ionic Framework Version
v8.x
Current Behavior
In packages/react-router/src/ReactRouter/IonRouter.tsx, handleHistoryChange contains:
const leavingUrl = leavingLocationInfo.pathname + leavingLocationInfo.search;
if (leavingUrl !== location.pathname) {
// ...transition / locationHistory mutation block...
}
The two sides of the comparison are not comparable: the left side is pathname + search, the right side is pathname only. For any route where leavingLocationInfo.search is non-empty (e.g. ?foo=1), the comparison is always unequal, so the transition block runs on every history event — including no-op popstates that didn't actually change the URL.
Concretely, this causes a false POP transition to whatever pushedByRoute points at whenever:
- The current route's tracked
routeInfo has any search string, AND
- A
popstate fires that doesn't actually change the URL (e.g. browser-back over a history entry pushed at the same URL via window.history.pushState).
Because the block treats this as an action === 'POP', it looks up currentRoute.pushedByRoute, sets incomingRouteParams to the previous routeInfo, and setState({routeInfo}) → IonRouterOutlet swaps the view. The browser URL is unchanged, but the rendered route is now the previous entry in locationHistory.
Expected Behavior
The transition block should only run when the URL actually changed. Comparing like with like:
const leavingUrl = leavingLocationInfo.pathname + leavingLocationInfo.search;
const currentUrl = location.pathname + (location.search || '');
if (leavingUrl !== currentUrl) {
// ...
}
With that change:
- Popstates that don't actually change the URL: comparison equal → block skipped (correct).
- Pathname changes: comparison unequal → block runs (correct, same as today).
- Search-only changes via
routerPush(samePath + newSearch): comparison unequal → block runs → push correctly tracked (correct, same as today).
Steps to Reproduce
Minimal manual repro in any @ionic/react app, no setup beyond a route that can receive a search param:
-
Add two routes that push each other so they have a pushedByRoute chain: e.g. /a and /b?foo=1. Navigate /a → /b?foo=1 via an IonRouterLink so Ionic's locationHistory tracks both.
-
While on /b?foo=1, open the devtools console and run:
window.history.pushState({ marker: true }, '', window.location.href);
This pushes a stub at the same URL — browser history grows by one entry; Ionic's locationHistory is unchanged (direct window.history.pushState doesn't fire React Router's listener, so IonRouter.handleHistoryChange doesn't see it).
-
In the console, run:
Observed: Ionic transitions back to /a. The browser URL is still /b?foo=1, but the rendered view is /a. Now the URL and the rendered view are out of sync — useLocation() returns /b?foo=1, but the IRO is showing /a's component.
Expected: No transition. Browser URL didn't change, so nothing should happen visually. (Steps 1–2 establish the precondition; step 3's popstate over a same-URL entry shouldn't trigger any UI change.)
If you swap step 1 for two routes without any search params (/a → /b, no query), step 3 produces no transition — the bug only manifests when the current route has search.
Code Reproduction URL
I haven't built a hosted StackBlitz, but the bug is isolated enough that the file/line is the actual reproduction surface: - File: packages/react-router/src/ReactRouter/IonRouter.tsx - Method: handleHistoryChange - The comparison: the line that reads if (leavingUrl !== location.pathname) Current main: https://github.com/ionic-team/ionic-framework/blob/main/packages/react-router/src/ReactRouter/IonRouter.tsx — the buggy comparison is still present.
Ionic Info
Ionic:
Ionic Framework : @ionic/react 8.5.5
@ionic/react-router : 8.5.5
Capacitor:
Capacitor CLI : 8.1.0
@capacitor/android : 8.1.0
@capacitor/core : 8.1.0
@capacitor/ios : 8.1.0
Dependencies:
react : 18.3.1
react-dom : 18.3.1
react-router-dom : 5.3.4
System:
NodeJS : v22.12.0
npm : 10.9.0
OS : macOS 26.4.1
Additional Information
Related but distinct: #25534 — that issue describes the user-visible symptom on its own repro but the root cause was never named, and the maintainer's workaround was about how consumers read URL params, not about the comparison itself. This issue identifies the exact line and proposes a one-line fix.
Why this matters in practice
We hit this building a back-button-handler for in-app modals: open a non-URL modal → window.history.pushState({overlay: true}, '', href) → on dismiss or browser-back, pop the stub. The pattern works perfectly on every search-free route. On routes with ? search params (e.g. our chat route uses ?jumpTo=<messageId> to deep-link to a message), the stub pop fires the false POP transition and the user is silently teleported to a different page in the IRO stack, while the URL stays put.
Any pattern that uses direct window.history.pushState/back for transient state on a search-bearing route has this problem. The same root cause is likely behind the user-visible behavior reported in #25534, though that issue was framed as a transition-rerender flash rather than as a comparison bug.
Suggested Fix
One-line change in handleHistoryChange:
- const leavingUrl = leavingLocationInfo.pathname + leavingLocationInfo.search;
- if (leavingUrl !== location.pathname) {
+ const leavingUrl = leavingLocationInfo.pathname + leavingLocationInfo.search;
+ const currentUrl = location.pathname + (location.search || '');
+ if (leavingUrl !== currentUrl) {
Happy to open a PR if useful.
Prerequisites
Ionic Framework Version
v8.x
Current Behavior
In
packages/react-router/src/ReactRouter/IonRouter.tsx,handleHistoryChangecontains:The two sides of the comparison are not comparable: the left side is
pathname + search, the right side ispathnameonly. For any route whereleavingLocationInfo.searchis non-empty (e.g.?foo=1), the comparison is always unequal, so the transition block runs on every history event — including no-op popstates that didn't actually change the URL.Concretely, this causes a false
POPtransition to whateverpushedByRoutepoints at whenever:routeInfohas anysearchstring, ANDpopstatefires that doesn't actually change the URL (e.g. browser-back over a history entry pushed at the same URL viawindow.history.pushState).Because the block treats this as an
action === 'POP', it looks upcurrentRoute.pushedByRoute, setsincomingRouteParamsto the previous routeInfo, andsetState({routeInfo})→ IonRouterOutlet swaps the view. The browser URL is unchanged, but the rendered route is now the previous entry inlocationHistory.Expected Behavior
The transition block should only run when the URL actually changed. Comparing like with like:
With that change:
routerPush(samePath + newSearch): comparison unequal → block runs → push correctly tracked (correct, same as today).Steps to Reproduce
Minimal manual repro in any
@ionic/reactapp, no setup beyond a route that can receive a search param:Add two routes that push each other so they have a
pushedByRoutechain: e.g./aand/b?foo=1. Navigate/a→/b?foo=1via anIonRouterLinkso Ionic'slocationHistorytracks both.While on
/b?foo=1, open the devtools console and run:This pushes a stub at the same URL — browser history grows by one entry; Ionic's
locationHistoryis unchanged (directwindow.history.pushStatedoesn't fire React Router's listener, soIonRouter.handleHistoryChangedoesn't see it).In the console, run:
Observed: Ionic transitions back to
/a. The browser URL is still/b?foo=1, but the rendered view is/a. Now the URL and the rendered view are out of sync —useLocation()returns/b?foo=1, but the IRO is showing/a's component.Expected: No transition. Browser URL didn't change, so nothing should happen visually. (Steps 1–2 establish the precondition; step 3's popstate over a same-URL entry shouldn't trigger any UI change.)
If you swap step 1 for two routes without any search params (
/a→/b, no query), step 3 produces no transition — the bug only manifests when the current route has search.Code Reproduction URL
I haven't built a hosted StackBlitz, but the bug is isolated enough that the file/line is the actual reproduction surface: - File:
packages/react-router/src/ReactRouter/IonRouter.tsx- Method:handleHistoryChange- The comparison: the line that readsif (leavingUrl !== location.pathname)Currentmain: https://github.com/ionic-team/ionic-framework/blob/main/packages/react-router/src/ReactRouter/IonRouter.tsx — the buggy comparison is still present.Ionic Info
Ionic:
Ionic Framework : @ionic/react 8.5.5
@ionic/react-router : 8.5.5
Capacitor:
Capacitor CLI : 8.1.0
@capacitor/android : 8.1.0
@capacitor/core : 8.1.0
@capacitor/ios : 8.1.0
Dependencies:
react : 18.3.1
react-dom : 18.3.1
react-router-dom : 5.3.4
System:
NodeJS : v22.12.0
npm : 10.9.0
OS : macOS 26.4.1
Additional Information
Related but distinct: #25534 — that issue describes the user-visible symptom on its own repro but the root cause was never named, and the maintainer's workaround was about how consumers read URL params, not about the comparison itself. This issue identifies the exact line and proposes a one-line fix.
Why this matters in practice
We hit this building a back-button-handler for in-app modals: open a non-URL modal →
window.history.pushState({overlay: true}, '', href)→ on dismiss or browser-back, pop the stub. The pattern works perfectly on every search-free route. On routes with?search params (e.g. our chat route uses?jumpTo=<messageId>to deep-link to a message), the stub pop fires the falsePOPtransition and the user is silently teleported to a different page in the IRO stack, while the URL stays put.Any pattern that uses direct
window.history.pushState/backfor transient state on a search-bearing route has this problem. The same root cause is likely behind the user-visible behavior reported in #25534, though that issue was framed as a transition-rerender flash rather than as a comparison bug.Suggested Fix
One-line change in
handleHistoryChange:Happy to open a PR if useful.