From c046ea9f435f6d61a7ecd8b3caae619aed01da83 Mon Sep 17 00:00:00 2001 From: Giacomo Balli Date: Mon, 6 Apr 2026 13:37:09 -0700 Subject: [PATCH] Preserve percent-encoded trailing whitespace in fragments (#4198) The route stripper used to remove trailing whitespace from any fragment, including the value returned by getPath(), which decodes percent-encoded characters first. As a result, a fragment ending in %20 (e.g. an item id with a literal trailing space) was silently chopped, breaking router lookups for legitimate parameters. Move the trailing whitespace strip onto the raw hash value in getHash(), where the original fix for #1794 belongs (location.hash returning trailing whitespace in old browsers). The path branch through getPath() and any explicit fragment passed to getFragment() now preserve trailing whitespace, fixing #4198 while keeping #1794 covered. Updated existing #1794/#1820 tests to assert at the right level and added a regression test for #4198. --- backbone.js | 12 +++++++++--- test/router.js | 27 +++++++++++++++++++++++---- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/backbone.js b/backbone.js index 5bfd974cd..0b23ade18 100644 --- a/backbone.js +++ b/backbone.js @@ -1795,8 +1795,12 @@ } }; - // Cached regex for stripping a leading hash/slash and trailing space. - var routeStripper = /^[#\/]|\s+$/g; + // Cached regex for stripping a leading hash/slash from a fragment. + var routeStripper = /^[#\/]/; + + // Cached regex for stripping trailing whitespace from a raw hash value. + // Only applied to the hash before decoding (see #1794, #4198). + var trailingSpaceStripper = /\s+$/; // Cached regex for stripping leading and trailing slashes. var rootStripper = /^\/+|\/+$/g; @@ -1843,9 +1847,11 @@ // Gets the true hash value. Cannot use location.hash directly due to bug // in Firefox where location.hash will always be decoded. + // Trailing whitespace from the raw hash is stripped (see #1794), but + // percent-encoded whitespace inside the fragment is preserved (see #4198). getHash: function(window) { var match = (window || this).location.href.match(/#(.*)$/); - return match ? match[1] : ''; + return match ? match[1].replace(trailingSpaceStripper, '') : ''; }, // Get the pathname and search params, without the root. diff --git a/test/router.js b/test/router.js index 9b5bdbdf2..04760ecb9 100644 --- a/test/router.js +++ b/test/router.js @@ -660,16 +660,35 @@ new MyRouter; }); - QUnit.test('#1794 - Trailing space in fragments.', function(assert) { + QUnit.test('#1794 - Trailing space stripped from raw hash.', function(assert) { + assert.expect(2); + var history = new Backbone.History; + history.location = {href: 'http://example.com/#fragment '}; + assert.strictEqual(history.getHash(), 'fragment'); + // An explicit fragment passed to getFragment must not lose its + // trailing whitespace (regression test for #4198). + assert.strictEqual(history.getFragment('fragment '), 'fragment '); + }); + + QUnit.test('#1820 - Leading slash stripped, trailing space preserved.', function(assert) { assert.expect(1); var history = new Backbone.History; - assert.strictEqual(history.getFragment('fragment '), 'fragment'); + assert.strictEqual(history.getFragment('/fragment '), 'fragment '); }); - QUnit.test('#1820 - Leading slash and trailing space.', function(assert) { + QUnit.test('#4198 - Percent-encoded trailing space preserved in path.', function(assert) { assert.expect(1); var history = new Backbone.History; - assert.strictEqual(history.getFragment('/fragment '), 'fragment'); + history.root = '/'; + history._wantsHashChange = false; + history.location = { + pathname: '/outbound/22130600/po/powithspacetest%20', + href: 'http://example.com/outbound/22130600/po/powithspacetest%20' + }; + assert.strictEqual( + history.getFragment(), + 'outbound/22130600/po/powithspacetest ' + ); }); QUnit.test('#1980 - Optional parameters.', function(assert) {