Skip to content

bug: IonRouter.handleHistoryChange inconsistently compares search-bearing leavingUrl to search-free location.pathname, causing spurious transitions on every popstate for routes with query params #31152

@EricHech

Description

@EricHech

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:

  1. The current route's tracked routeInfo has any search string, AND
  2. 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:

  1. 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.

  2. 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).

  3. In the console, run:

    window.history.back();

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions