Skip to content

Commit

Permalink
HistoryHandler: fix title in history entry (#407)
Browse files Browse the repository at this point in the history
- 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.
  • Loading branch information
jiripudil authored Aug 16, 2024
1 parent e9fbdb0 commit 201ecf4
Show file tree
Hide file tree
Showing 3 changed files with 54 additions and 12 deletions.
29 changes: 24 additions & 5 deletions src/core/HistoryHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -22,8 +25,8 @@ export interface HistoryState extends Record<string, any> {
}

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';
Expand All @@ -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));

Expand All @@ -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),
};
}

Expand All @@ -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;
Expand All @@ -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,
));

Expand Down Expand Up @@ -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 {
Expand Down
33 changes: 28 additions & 5 deletions tests/Naja.HistoryHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}}}));

Expand All @@ -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(() => {
Expand All @@ -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(() => {
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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'))
Expand All @@ -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});
Expand Down
4 changes: 2 additions & 2 deletions tests/Naja.SnippetCache.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down

0 comments on commit 201ecf4

Please sign in to comment.