New Web History API Proposal

Introduction

The widely available History API is difficult to use even for the most basic use cases. This has become an impediment for custom-built web apps and shared libraries. The description below explains why and how it can be changed to address these issues.

Status-quo

History API has been around for a few years and implemented in all major browsers. It defines methods and properties that include: history.state, history.length, history.pushState, history.replaceState, as well as popstate window event. However, the use of these APIs is very complicated and unreliable. For instance, take a look at a fairly high-quality Polymer Dialog, at least by default, does not support closing via hardware back button.

Something like this would “just work” for a native set of components, but it’s very hard to achieve reliably on the Web. To demonstrate why this is hard, refer to a simple code snippet:

window.history.replaceState({a: 1}, '', '');
// Now I know my state!
window.history.state.a === 1
// User navigates to a fragment via <a href="#b">
// Oops. The state is lost.
window.history.state === null

In reality, the history state can change unexpectedly due to many reasons: user navigation, 3p libraries, history manipulation in child iframes. The case of child iframes in particular seems to be in the area of the security vector. For more info, see:

Preserving the state

The history.state is unreliable and can disappear from under a web app at any time. As the result, many framework routers patch history API methods to preserve the history state. It looks something like this:

var appKey = 'MY_APP_STATE';
var startAppState = {};
startAppState[appKey] = {};
var lastLength = history.length;
var lastState = merge(startAppState, history.state);
var origPushState = history.pushState.bind(history);
var origReplaceState = history.replaceState.bind(history);
history.pushState = function(state, title, url) {
if (!state || !state[appKey]) {
// Someone is pushing new history state. To preserve my app state
// I have to merge the new state with the last known.
state = merge(lastState, state);
}
return origPushState(state, title, url);
};
history.replaceState = function(state, title, url) {
if (!state || !state[appKey]) {
// Someone is overriding my app state.
// I have to merge the new state with the last known.
state = merge(lastState, state);
}
return origReplaceState(state, title, url);
};
window.onpopstate = function() {
if (!history.state || !history.state[appKey]) {
// Someone overrode my state.
if (history.length > lastLength) {
// This is most likely user navigation that pushed the stack.
// Merge the new state if any with the last known.
origReplaceState(merge(lastState, history.state));
} else {
// Really hard to tell what happened. The user might have
// navigated back or forward to an unknown state.
}
}
};

History.index API (rejected)

Update: this approach has been rejected in https://github.com/whatwg/html/issues/2710 due to implementation differences with iframe removal and other issues. The details are left in this document for posterity.

Some use cases, like the Dialog example above could be potentially solved somewhat easier if history.index API (https://github.com/whatwg/html/issues/2710) was available. In that case, patching the state could be unnecessary:

class Dialog {
constructor() {
this._openAtIndex = null;
this._listener = this.listenToPop_.bind(this);
}
  open() {
this._openAtIndex = history.index;
window.addEventListener('popstate', this._listener);
history.pushState(null, '', '');
// Open UI.
}
  close() {
window.removeEventListener('popstate', this._listener);
if (history.index > this._openAtIndex) {
history.go(-(history.index - this._openAtIndex));
}
}
  listenToPop_() {
if (history.index <= this._openAtIndex) {
// The stack is popped prior to when the menu was open.
this.close();
}
}
}

Scoped state property API

The history.index approach is simple enough, but, without state patching, the pushState call in this example above corrupts global history state. This would be problematic for a more advanced web app. As the result, this makes it very hard to create good UI libraries that provides dialogs, menus, etc, that work out-of-the-box and do not break the hosting web app.

The bottom line is. The current History API is very hard to use and insufficient even for very typical and simple use cases.

Let’s consider an alternative — a scoped state API. In this API, instead of updating the whole state, we instead only update state properties using pushStateValue and replaceStateValue methods:

history.index == 0
// First:
history.pushStateValue('my-app-prop1', 1);
// Predictably now:
history.index == 1
history.getStateValue('my-app-prop1') == 1
// Second, a 3p library uses the history stack:
history.pushStateValue('jquery-menu', 1);
// Now, as expected:
history.index == 2
history.getStateValue('jquery-menu') == 1
// But also, my-app-prop1 is still there:
history.getStateValue('my-app-prop1') == 1
// Then, the user clicks on <a href="#b">
// Stack is pushed and fragment updated.
history.index == 3
location.hash == '#b'
// But the previously stacked properties are still there:
history.getStateValue('jquery-menu') == 1
history.getStateValue('my-app-prop1') == 1
// Events:
history.onStateChange('jquery-menu', () => {
// Menu was popped from the history:
menu.close();
});
// Programmatic pop: this would pop the stack to the state before
// the last time 'jquery-menu' was changed.
history.popStateValue('jquery-menu');
// Maybe even more precise: to last time 'jquery-menu' was set to 1:
history.popStateValue('jquery-menu', 1);
// So, how would `replaceStateValue` work? It should record new
// value for 'jquery-menu' without pushing the history.
history.replaceStateValue('jquery-menu', 2);

This approach could also be an answer to the security issues of child iframes overwriting/pushing parent state (see crbug/705583 and crbug/705550). history.pushStateValue and history.getStateValue could simply be scoped to the state properties created by a particular Window context — global stack is shared, but what you read/write is not:

top.history.getStateValue('jquery-menu') !== 
child.history.getStateValue('jquery-menu')

Notice that such an API is mostly polyfillable via the current API, with some edge cases.

It’d be even better to allow to push multiple properties at the same time.

Other

There are other nuances that could be addressed in the same scope, such as:

  • Transient history states and page refreshes.
  • Exact 3p iframe behavior with history push/pop and iframe removal.