diff --git a/src/ui/hash.test.ts b/src/ui/hash.test.ts index c3b2d93d23e..341d525e29b 100644 --- a/src/ui/hash.test.ts +++ b/src/ui/hash.test.ts @@ -383,10 +383,10 @@ describe('hash', () => { expect(hash._isValidHash(hash._getCurrentHash())).toBeTruthy(); }); - test('invalidate hash with slashes encoded as %2F', () => { + test('validate hash with slashes encoded as %2F', () => { window.location.hash = '#10%2F3.00%2F-1.00'; - expect(hash._isValidHash(hash._getCurrentHash())).toBeFalsy(); + expect(hash._isValidHash(hash._getCurrentHash())).toBeTruthy(); }); test('invalidate hash with string values', () => { @@ -524,7 +524,7 @@ describe('hash', () => { }); - test('hash with URL in other parameter does not change', () => { + test('hash with URL in other parameter does not change except normalization', () => { const hash = createHash('map') .addTo(map); @@ -533,7 +533,7 @@ describe('hash', () => { map.setZoom(5); map.setCenter([1.0, 2.0]); - expect(window.location.hash).toBe('#map=5/2/1&returnUrl=https://example.com&filter=a&b='); + expect(window.location.hash).toBe('#map=5/2/1&returnUrl=https://example.com&filter=a&b'); window.location.hash = '#search=foo&map=7/4/2&redirect=/path?query=value'; hash._onHashChange(); @@ -542,7 +542,7 @@ describe('hash', () => { expect(map.getCenter().lng).toBe(2); }); - test('hash with URL+path in other parameter does not change', () => { + test('hash with URL+path in other parameter does not change except for normalization', () => { const hash = createHash('map') .addTo(map); @@ -551,7 +551,7 @@ describe('hash', () => { map.setZoom(5); map.setCenter([1.0, 2.0]); - expect(window.location.hash).toBe('#map=5/2/1&returnUrl=https://example.com/abcd/ef&filter=a&b='); + expect(window.location.hash).toBe('#map=5/2/1&returnUrl=https://example.com/abcd/ef&filter=a&b'); window.location.hash = '#search=foo&map=7/4/2&redirect=/path?query=value'; hash._onHashChange(); @@ -601,7 +601,7 @@ describe('hash', () => { }); - test('update to hash with empty parameter values is kept as-is', () => { + test('update to hash with empty parameter are de-normalized', () => { const hash = createHash('map') .addTo(map); @@ -610,7 +610,7 @@ describe('hash', () => { expect(map.getZoom()).toBe(10); map.setZoom(5); - expect(window.location.hash).toBe('#map=5/3/-1&empty='); + expect(window.location.hash).toBe('#map=5/3/-1&empty'); }); describe('geographic boundary values', () => { diff --git a/src/ui/hash.ts b/src/ui/hash.ts index 8c584223e68..1da952630b2 100644 --- a/src/ui/hash.ts +++ b/src/ui/hash.ts @@ -65,40 +65,25 @@ export class Hash { if (pitch) hash += (`/${Math.round(pitch)}`); if (this._hashName) { - const hashName = this._hashName; - let found = false; - const parts = window.location.hash.slice(1).split('&').map(part => { - const key = part.split('=')[0]; - if (key === hashName) { - found = true; - return `${key}=${hash}`; - } - return part; - }).filter(a => a); - if (!found) { - parts.push(`${hashName}=${hash}`); - } - return `#${parts.join('&')}`; + const params = this._getHashParams(); + params.set(this._hashName, hash); + return `#${decodeURIComponent(params.toString()).replace(/=&/g, '&').replace(/=$/g, '')}`; } return `#${hash}`; } + _getHashParams = () => { + return new URLSearchParams(window.location.hash.replace('#', '')); + }; + _getCurrentHash = () => { - // Get the current hash from location, stripped from its number sign - const hash = window.location.hash.replace('#', ''); + const params = this._getHashParams(); if (this._hashName) { - // Split the parameter-styled hash into parts and find the value we need - let keyval; - hash.split('&').map( - part => part.split('=') - ).forEach(part => { - if (part[0] === this._hashName) { - keyval = part; - } - }); - return (keyval ? keyval[1] || '' : '').split('/'); + return (params.get(this._hashName) || '').split('/'); } + // For unnamed hashes, get the first key + const hash = [...params.keys()][0] ?? ''; return hash.split('/'); }; @@ -121,30 +106,25 @@ export class Hash { }; _updateHashUnthrottled = () => { - // Replace if already present, else append the updated hash string const location = window.location.href.replace(/(#.*)?$/, this.getHashString()); window.history.replaceState(window.history.state, null, location); }; _removeHash = () => { - const currentHash = this._getCurrentHash(); - if (currentHash.length === 0) { - return; - } - const baseHash = currentHash.join('/'); - let targetHash = baseHash; - if (targetHash.split('&').length > 0) { - targetHash = targetHash.split('&')[0]; // #3/1/2&foo=bar -> #3/1/2 - } + const params = this._getHashParams(); + if (this._hashName) { - targetHash = `${this._hashName}=${baseHash}`; - } - let replaceString = window.location.hash.replace(targetHash, ''); - if (replaceString.startsWith('#&')) { - replaceString = replaceString.slice(0, 1) + replaceString.slice(2); - } else if (replaceString === '#') { - replaceString = ''; + params.delete(this._hashName); + } else { + // For unnamed hash (#zoom/lat/lng&other=params), remove first entry + const keys = Array.from(params.keys()); + if (keys.length > 0) { + params.delete(keys[0]); + } } + + const newHash = decodeURIComponent(params.toString()).replace(/=&/g, '&').replace(/=$/g, ''); + const replaceString = newHash ? `#${newHash}` : ''; let location = window.location.href.replace(/(#.+)?$/, replaceString); location = location.replace('&&', '&'); window.history.replaceState(window.history.state, null, location); @@ -155,8 +135,8 @@ export class Hash { */ _updateHash: () => ReturnType = throttle(this._updateHashUnthrottled, 30 * 1000 / 100); - _isValidHash(hash: number[]) { - if (hash.length < 3 || hash.some(isNaN)) { + _isValidHash(hash: string[]) { + if (hash.length < 3 || hash.some(h => isNaN(+h))) { return false; }