From 47ecca646c1f9af5002832d439e585486a2e0f41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pudil?= Date: Sat, 10 Aug 2024 15:29:50 +0200 Subject: [PATCH] HistoryHandler: fix title in history entry - Safari still relies on the History API's `title` parameter (should be fixed in 18). - Other browsers populate the current history entry only when the new one is being pushed, at which point SnippetHandler has already updated the title. --- src/core/HistoryHandler.ts | 29 ++++++++++++++++++++++++----- tests/Naja.HistoryHandler.js | 33 ++++++++++++++++++++++++++++----- tests/Naja.SnippetCache.js | 4 ++-- 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/src/core/HistoryHandler.ts b/src/core/HistoryHandler.ts index 914cdb4..271f173 100644 --- a/src/core/HistoryHandler.ts +++ b/src/core/HistoryHandler.ts @@ -3,10 +3,13 @@ import {RedirectEvent} from './RedirectHandler'; import {InteractionEvent} from './UIHandler'; import {onDomReady, TypedEventListener} from '../utils'; +const originalTitleKey = Symbol(); + declare module '../Naja' { interface Options { history?: HistoryMode; href?: string; + [originalTitleKey]?: string; } interface Payload { @@ -22,8 +25,8 @@ export interface HistoryState extends Record { } export interface HistoryAdapter { - replaceState(state: HistoryState, url: string): void; - pushState(state: HistoryState, url: string): void; + replaceState(state: HistoryState, title: string, url: string): void; + pushState(state: HistoryState, title: string, url: string): void; } export type HistoryMode = boolean | 'replace'; @@ -40,6 +43,7 @@ export class HistoryHandler extends EventTarget { naja.addEventListener('init', this.initialize.bind(this)); naja.addEventListener('before', this.saveUrl.bind(this)); + naja.addEventListener('before', this.saveOriginalTitle.bind(this)); naja.addEventListener('before', this.replaceInitialState.bind(this)); naja.addEventListener('success', this.pushNewState.bind(this)); @@ -48,8 +52,8 @@ export class HistoryHandler extends EventTarget { naja.uiHandler.addEventListener('interaction', this.configureMode.bind(this)); this.historyAdapter = { - replaceState: (state, url) => window.history.replaceState(state, '', url), - pushState: (state, url) => window.history.pushState(state, '', url), + replaceState: (state, title, url) => window.history.replaceState(state, title, url), + pushState: (state, title, url) => window.history.pushState(state, title, url), }; } @@ -75,6 +79,11 @@ export class HistoryHandler extends EventTarget { window.addEventListener('popstate', this.popStateHandler); } + private saveOriginalTitle(event: BeforeEvent): void { + const {options} = event.detail; + options[originalTitleKey] = window.document.title; + } + private saveUrl(event: BeforeEvent): void { const {url, options} = event.detail; options.href ??= url; @@ -91,6 +100,7 @@ export class HistoryHandler extends EventTarget { if (mode !== false && ! this.initialized) { onDomReady(() => this.historyAdapter.replaceState( this.buildState(window.location.href, 'replace', this.cursor, options), + window.document.title, window.location.href, )); @@ -130,11 +140,20 @@ export class HistoryHandler extends EventTarget { const method = mode === 'replace' ? 'replaceState' : 'pushState'; const cursor = mode === 'replace' ? this.cursor : ++this.cursor; + const state = this.buildState(options.href!, mode, cursor, options); + + // before the state is pushed into history, revert to the original title + const newTitle = window.document.title; + window.document.title = options[originalTitleKey]!; this.historyAdapter[method]( - this.buildState(options.href!, mode, cursor, options), + state, + newTitle, options.href!, ); + + // after the state is pushed into history, update back to the new title + window.document.title = newTitle; } private buildState(href: string, mode: HistoryMode, cursor: number, options: Options): HistoryState { diff --git a/tests/Naja.HistoryHandler.js b/tests/Naja.HistoryHandler.js index ac3ece8..588b183 100644 --- a/tests/Naja.HistoryHandler.js +++ b/tests/Naja.HistoryHandler.js @@ -48,7 +48,7 @@ describe('HistoryHandler', function () { const mock = sinon.mock(historyHandler.historyAdapter); const href = sinon.match.string.and(sinon.match((value) => value.startsWith('http://localhost:9876/?wtr-session-id='))); - mock.expects('replaceState').withExactArgs({source: 'naja', cursor: 0, href}, href).once(); + mock.expects('replaceState').withExactArgs({source: 'naja', cursor: 0, href}, '', href).once(); historyHandler.replaceInitialState(new CustomEvent('before', {detail: {options: {history: true}}})); @@ -65,7 +65,7 @@ describe('HistoryHandler', function () { naja.historyHandler.initialized = true; const mock = sinon.mock(naja.historyHandler.historyAdapter); - mock.expects('pushState').withExactArgs({source: 'naja', cursor: 1, href: 'http://localhost:9876/HistoryHandler/pushState'}, 'http://localhost:9876/HistoryHandler/pushState').once(); + mock.expects('pushState').withExactArgs({source: 'naja', cursor: 1, href: 'http://localhost:9876/HistoryHandler/pushState'}, '', 'http://localhost:9876/HistoryHandler/pushState').once(); this.fetchMock.respond(200, {'Content-Type': 'application/json'}, {}); return naja.makeRequest('GET', '/HistoryHandler/pushState').then(() => { @@ -83,7 +83,7 @@ describe('HistoryHandler', function () { naja.historyHandler.initialized = true; const mock = sinon.mock(naja.historyHandler.historyAdapter); - mock.expects('replaceState').withExactArgs({source: 'naja', cursor: 0, href: 'http://localhost:9876/HistoryHandler/replaceState'}, 'http://localhost:9876/HistoryHandler/replaceState').once(); + mock.expects('replaceState').withExactArgs({source: 'naja', cursor: 0, href: 'http://localhost:9876/HistoryHandler/replaceState'}, '', 'http://localhost:9876/HistoryHandler/replaceState').once(); this.fetchMock.respond(200, {'Content-Type': 'application/json'}, {}); return naja.makeRequest('GET', '/HistoryHandler/replaceState', null, {history: 'replace'}).then(() => { @@ -101,7 +101,7 @@ describe('HistoryHandler', function () { naja.historyHandler.initialized = true; const mock = sinon.mock(naja.historyHandler.historyAdapter); - mock.expects('pushState').withExactArgs({source: 'naja', cursor: 1, href: '/HistoryHandler/postGet/targetUrl'}, '/HistoryHandler/postGet/targetUrl').once(); + mock.expects('pushState').withExactArgs({source: 'naja', cursor: 1, href: '/HistoryHandler/postGet/targetUrl'}, '', '/HistoryHandler/postGet/targetUrl').once(); this.fetchMock.respond(200, {'Content-Type': 'application/json'}, {url: '/HistoryHandler/postGet/targetUrl', postGet: true}); return naja.makeRequest('GET', '/HistoryHandler/postGet').then(() => { @@ -138,7 +138,7 @@ describe('HistoryHandler', function () { naja.historyHandler.initialized = true; const mock = sinon.mock(naja.historyHandler.historyAdapter); - mock.expects('pushState').withExactArgs({source: 'naja', cursor: 1, href: '/HistoryHandler/redirect/targetUrl'}, '/HistoryHandler/redirect/targetUrl').once(); + mock.expects('pushState').withExactArgs({source: 'naja', cursor: 1, href: '/HistoryHandler/redirect/targetUrl'}, '', '/HistoryHandler/redirect/targetUrl').once(); mock.expects('replaceState').never(); this.fetchMock.when((request) => request.url.endsWith('/redirect')) @@ -156,6 +156,29 @@ describe('HistoryHandler', function () { }); }); + it('stores correct title in the history entry', function () { + const naja = mockNaja({ + snippetHandler: SnippetHandler, + historyHandler: HistoryHandler, + }); + + naja.historyHandler.initialized = true; + + const mock = sinon.mock(naja.historyHandler.historyAdapter); + mock.expects('pushState').withExactArgs({source: 'naja', cursor: 1, href: 'http://localhost:9876/HistoryHandler/title'}, 'new title', 'http://localhost:9876/HistoryHandler/title').once(); + + document.querySelector('title').id = 'snippet--title'; + + this.fetchMock.respond(200, {'Content-Type': 'application/json'}, {snippets: {'snippet--title': 'new title'}}); + return naja.makeRequest('GET', '/HistoryHandler/title') + .then(() => { + mock.verify(); + mock.restore(); + + document.querySelector('title').id = ''; + }); + }); + describe('configures mode properly on interaction', function () { it('missing data-naja-history', () => { const naja = mockNaja({uiHandler: UIHandler}); diff --git a/tests/Naja.SnippetCache.js b/tests/Naja.SnippetCache.js index 9a053dc..23d9c37 100644 --- a/tests/Naja.SnippetCache.js +++ b/tests/Naja.SnippetCache.js @@ -50,7 +50,7 @@ describe('SnippetCache', function () { storage: TEST_STORAGE_TYPE, key: 'key', }, - }, 'http://localhost:9876/SnippetCache/store').once(); + }, '', 'http://localhost:9876/SnippetCache/store').once(); const el = document.createElement('div'); el.id = 'snippet-cache-foo'; @@ -133,7 +133,7 @@ describe('SnippetCache', function () { storage: TEST_STORAGE_TYPE, key: 'key', }, - }, 'http://localhost:9876/SnippetCache/map').once(); + }, '', 'http://localhost:9876/SnippetCache/map').once(); const el = document.createElement('div'); el.id = 'snippet-cache-map';