Normalize fragment encoding to avoid duplicate route fires (#4132)#4303
Normalize fragment encoding to avoid duplicate route fires (#4132)#4303BigBalli wants to merge 1 commit intojashkenas:masterfrom
Conversation
…#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.
jgonggrijp
left a comment
There was a problem hiding this comment.
This seems correct to me. 👍
@paulfalgout could you take a look as well?
There was a problem hiding this comment.
Pull request overview
This PR fixes a cross-browser fragment encoding mismatch where Firefox/Safari may expose percent-encoded non-ASCII hash fragments via location.href, causing History#checkUrl to detect a “changed” fragment and fire routes twice. It normalizes fragment caching/comparisons to the decoded form and adds a regression test for an encoded CJK hash fragment.
Changes:
- Decode and store the initial fragment in
History#startfor consistent caching. - Compare decoded fragments in
History#checkUrl(including iframe hash comparisons). - Store the decoded fragment in
History#loadUrlbefore dispatching handlers. - Add a QUnit regression test for
checkUrl()not double-firing on encoded non-ASCII hashes.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| backbone.js | Normalize fragment storage/comparison to decoded form across start/checkUrl/loadUrl to prevent duplicate route fires. |
| test/router.js | Add regression test ensuring checkUrl() does not re-fire a route when location.href contains an encoded non-ASCII hash. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // form in `this.fragment` while the next `hashchange` event reads | ||
| // back the encoded form, so checkUrl thinks the URL changed and | ||
| // fires the route a second time. See #4132, #4085, #3941. | ||
| var current = this.decodeFragment(this.getFragment()); | ||
|
|
There was a problem hiding this comment.
decodeFragment uses decodeURI, which throws URIError on malformed percent-escapes (e.g. a user manually enters a hash containing a stray % or incomplete UTF-8 sequence). By decoding unconditionally in start/checkUrl/loadUrl now, hashChange navigation that previously tolerated such URLs can start crashing the router. Consider wrapping the decode in a try/catch (returning the original fragment on failure) or adding a safe decode helper used by these call sites so invalid fragments don’t break history handling.
There was a problem hiding this comment.
so it's suggesting:
decodeFragment: function(fragment) {
var raw = fragment;
fragment = fragment.replace(/%25/g, '%2525');
try {
return decodeURI(fragment);
} catch (e) {
return raw;
}
}I suppose it could be breaking.. although seems unlikely
| // form in `this.fragment` while the next `hashchange` event reads | ||
| // back the encoded form, so checkUrl thinks the URL changed and | ||
| // fires the route a second time. See #4132, #4085, #3941. | ||
| var current = this.decodeFragment(this.getFragment()); | ||
|
|
There was a problem hiding this comment.
so it's suggesting:
decodeFragment: function(fragment) {
var raw = fragment;
fragment = fragment.replace(/%25/g, '%2525');
try {
return decodeURI(fragment);
} catch (e) {
return raw;
}
}I suppose it could be breaking.. although seems unlikely
| // value cached in `this.fragment` is consistent with what | ||
| // `navigate()` writes — see checkUrl for the cross-browser | ||
| // rationale (#4132). | ||
| fragment = this.fragment = this.decodeFragment(this.getFragment(fragment)); |
There was a problem hiding this comment.
fragment = this.getFragment(fragment);
this.fragment = this.decodeFragment(fragment);Decode only the cached fragment for comparison; don’t decode the fragment you match routes against, or you change routing semantics.
Fixes #4132. Also addresses #4085 and #3941, which are the same
encoding mismatch through different code paths.
Problem
In Firefox (and historically Safari),
location.hrefreturns the hashwith percent-encoded non-ASCII characters even when
navigate()wascalled with the decoded form. After navigating to
#search/大阪, thenext
hashchange/popstate:checkUrl()callsgetFragment()→getHash()→ readssearch/%E5%A4%A7%E9%98%AAfromlocation.href.this.fragment, whichnavigate()setto the decoded form
search/大阪.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#startstores the initial fragment asdecodeFragment(getFragment()).History#checkUrlcomparesdecodeFragment(getFragment())(and theiframe hash) against
this.fragment.History#loadUrlwritesdecodeFragment(getFragment(fragment))tothis.fragmentbefore dispatching.decodeFragmentis already designed to preserve literal%25, so itis 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
Historyinstance with amocked
location.hrefcontaining the encoded form ofsearch/大阪,sets
this.fragmentto the decoded form (asnavigatewould), andasserts that
checkUrl()does not re-invoke the matching routehandler.
npm run lintpasses.