Skip to content

Normalize fragment encoding to avoid duplicate route fires (#4132)#4303

Open
BigBalli wants to merge 1 commit intojashkenas:masterfrom
BigBalli:fix-4132-encoded-double-fire
Open

Normalize fragment encoding to avoid duplicate route fires (#4132)#4303
BigBalli wants to merge 1 commit intojashkenas:masterfrom
BigBalli:fix-4132-encoded-double-fire

Conversation

@BigBalli
Copy link
Copy Markdown

@BigBalli BigBalli commented Apr 6, 2026

Fixes #4132. Also addresses #4085 and #3941, which are the same
encoding mismatch through different code paths.

Problem

In Firefox (and historically Safari), location.href returns the hash
with percent-encoded non-ASCII characters even when navigate() was
called with the decoded form. After navigating to #search/大阪, the
next hashchange/popstate:

  1. checkUrl() calls getFragment()getHash() → reads
    search/%E5%A4%A7%E9%98%AA from location.href.
  2. Compares against the cached this.fragment, which navigate() set
    to the decoded form search/大阪.
  3. They differ → loadUrl() runs → the route fires a second time.

This breaks any i18n app whose URLs contain CJK, Cyrillic, accented
Latin, etc.: every navigation triggers analytics/fetches/state changes
twice.

Fix

Normalize on the decoded form everywhere a fragment is cached or
compared:

  • History#start stores the initial fragment as
    decodeFragment(getFragment()).
  • History#checkUrl compares decodeFragment(getFragment()) (and the
    iframe hash) against this.fragment.
  • History#loadUrl writes decodeFragment(getFragment(fragment)) to
    this.fragment before dispatching.

decodeFragment is already designed to preserve literal %25, so it
is idempotent on values that are already decoded — safe to call on
the pushState path that already decodes via getPath().

Test

Added a regression test that constructs a History instance with a
mocked location.href containing the encoded form of search/大阪,
sets this.fragment to the decoded form (as navigate would), and
asserts that checkUrl() does not re-invoke the matching route
handler.

npm run lint passes.

…#4132)

In Firefox/Safari, `location.href` returns the hash with percent-
encoded non-ASCII characters even when `navigate()` was called with
the decoded form. As a result, after navigating to e.g. `#search/大阪`,
the next `hashchange`/`popstate` causes `checkUrl()` to read back
`%E5%A4%A7%E9%98%AA`, see it as different from the cached
`this.fragment`, and fire the route a second time.

Normalize on the decoded form everywhere we cache or compare a
fragment:

- `History#start` stores the initial fragment in decoded form.
- `History#checkUrl` decodes both `getFragment()` and the iframe
  hash before comparing against `this.fragment`.
- `History#loadUrl` decodes the fragment before caching it on
  `this.fragment` and dispatching to handlers.

`decodeFragment` is idempotent (it preserves literal `%25`), so
calling it on a value that is already decoded is a safe no-op.

Also fixes jashkenas#4085 and jashkenas#3941, which are the same encoding mismatch
manifesting through different code paths.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unexpected page router navigate in Mozilla

1 participant