- Alex Russell slightlyoff@google.com
- Tom Wilkinson twilkinson@google.com
- Introduction
- Goals
- Non-goals
- Navigation Events
- UI State Fragments
- Same-Origin History Stack Introspection
- Considered alternatives
- Open Design Questions
- References & acknowledgments
Application Developers face a range of challenges when performing client-side updates to dynamic web apps including (but not limited to):
- Lack of clarity on when to rely on base URL, query parameters, or hash parameters to represent "persistent" state
- Difficulty in serializing & sharing "UI state" separate from "application state"
- Complexity in capturing browser-initiated page unloading for client-side routers
- Tricky coordination problems between multiple page components which may each want to persist transient UI state but remain largely unaware of each other
- Difficulty in understanding one's location in, and predicting effects of changes to, the HTML5 History API stack due to potentially co-mingled origins
Taken together, these challenges create a "totalizing" effect in applications when client-side state management is introduced. Because a single router system must be responsible for so many aspects, and coordinate so many low-level details, it's challenging to create compatible solutions, or constrain code footprint whilst retaining valuable properties such as lightweight progressive enhancement.
Existing building blocks create subtle, but large, problems.
The History pushState
and replaceState
APIs provide a mechanism for passing a cloneable JavaScript state object, which are returned to a page on change in state (usually via user action), however it is left as an exercise to the developer to map potentially different levels of application semantics into this API. It usually "feels wrong" to encode the state of an accordion component's open/close state in either the path or query parameters, both of which are passed to servers and may have semantic meaning beyond UI state.
URL hashes are a reasonable next place to look for a solution as they are shareable and not meaningful to server applications by default, but HTML already assigns meaning to hashes for anchor elements, creating potential conflicts. Fragments have also become a potential XSS vector, in part, because no safe parsing is provided by default. The hashchange
event can allow components to be notified of state changes, but doesn't provide any semantic for location in stack history or meaningfully integrate into the History API.
This leaves application authors torn. Shareable UI state either requires out-of-band mechanisms for persistence, or overloading of URLs, unwanted server-side implications, or potential loss of state when folding information into History API state objects. And that is to say nothing of navigating the APIs themselves and their various warts.
Lastly, it should be noted that these problems largely arise only when developers have already exerted enough to control to prevent "normal" link navigations via <a href="...">
. Naturally-constructed applications will want to progressively enhance anchors, but the current system prevents this, forcing developers to be vigilant to add very specific local event handlers -- or forgo anchor elements, potentially harming accessibility and adding complexity.
We hope to enable application developers to:
- Easily express transient, shareable UI state separately from semantic navigation state
- Remove complexity and brittleness from responding to requests for state change via links without full page reloads
- Reduce security concerns regarding UI state serialized to URLs
- Reduce conflicts with existing libraries for incremental adoption
These proposals do not seek to:
- Add a full client-side router to the platform (although it should make these systems easier)
- Solve all known issues with history introspection and
history.state
durability
Currently, client-side libraries need to override the browser's default navigation affordances by globally hooking nominally unrelated events, e.g. onclick
. This is brittle, as programmatic navigation (window.location = ...
) can be missed, and may require explicit decoration of elements to be handled within a system, complicating progressive enhancement.
To reduce this pain, we propose the cancelable onbeforenavigate
event.
// Fired *before* window.onbeforeunload
// TODO: should this be on `window.location`` instead?
// It's not currently an EventTarget
history.onbeforenavigate = (e) => {
// Cancel navigation; only available for same-origin navigations
e.preventDefault();
console.log(e.url); // the destination URL; TODO: precedent?
};
The onbeforenavigate
event is cancellable for all navigations that would affect the current document context. It's an open question as to whether or not it should be cancellable for links with the form <a href="..." target="_blank">...</a>
.
onbeforenavigate
also composes with form submission; for example, consider this form:
<form method="POST" action="/app/submit">
<input type="hidden" name="id" value="whatevs">
<label for="content">Content:</label>
<textarea id="content">
...
</textarea>
<button type="submit">Send!</button>
</form>
Submission can be detected (in addition to the non-standard bubbling behavior) like this:
history.onbeforenavigate = (e) => {
// Cancel navigation; only available for same-origin navigations
e.preventDefault();
// Check to see if the source of the navigation is form submission:
if (e.target.tagName == "FORM") {
let form = e.target;
// As we're handling it here, make sure other event handlers don't get a
// crack at the event:
e.stopImmediatePropagation();
// Submit the form via Ajax instead:
fetch(form.action, {
method: form.method,
body: new FormData(form)
}).then(
// Update UI with response
);
// Set loading UI here
}
};
UI State Fragments build on the Fragment Directive syntax :~:
,
with the first recently-launched fragment directive being Text Fragments :~:text
.
For those not familiar, Text Fragments enable browsers to highlight portions of a page using a syntax like:
https://example.com/#:~:text=prefix-,startText,endText,-suffix
This syntax was designed to be extensible via the &
joiner, e.g.:
https://example.com/#:~:text=sometext&anotherdirective=value&etc...
We build on this to encode a single (page-level) serialised object in a (name open for bikeshedding) UI State directive :~:uistate
, e.g.:
https://example.com/#:~:uistate=<value>
The value itself is the result of URI component encoding a JSON serialization of the subset of Structure Cloneable properties that can be naturally represented in JSON. In code, that makes these lines roughly equivalent:
let state = { "foo": true, "bar": [ 1, "ten" ] };
window.location.hash = `#:~:uistate=${
encodeURIComponent(JSON.stringify(state))
}`;
Is long-hand for:
history.uistate = { "foo": true, "bar": [ 1, "ten" ] };
Both result in a URL like:
https://example.com/#:~:uistate=%7B"foo"%3Atrue%2C"bar"%3A%5B1%2C"ten"%5D%7D
Updates to uistate
do not persist to the history stack.
Pages can use uistate
to re-construct visual context by consulting the (browser parsed) history.uistate
property at any point; e.g.:
myAppJs.setInitialUIState(history.uistate);
The history stack has a long list of documented issues, chief among them mixing between first and third party contexts, which renders the go()
and back()
methods nearly pointless.
We propose extensions to the history stack to provide visibility into same-origin stack state and extensions to existing methods to iterate through them. The following snippet captures the early proposal:
// Returns an iterator of HistoryStateEntries (a new type)
let initialState = history.current; // wraps `.state`; name TBD
let currentState = history.pushSatate({ foo: 1 }, "", ""); // return value
let sameOriginStack = history.states(); // TODO: async? array?
console.log(history.length); // perhaps 5
console.log(sameOriginStack.length); // can be less
let item = null;
for (item of sameOriginStack) {
console.log(currentState === item); // `true` for first iteration
console.log(item.url);
console.log(item.state); // the state in history.state
}
history.go(item); // new parameter type overload
Lots of "spelling" issues to be worked out here
We have previously considered (in private docs) a more fullsome rewrite of the history APIs to make it clearer that a single "installable" history manager should own potential navigation decisions. This might turn into the correct alternative as we explore, however this document seeks to find smaller potential changes in the short run.
It's possible control the responses for a document's content inside a Service Worker, and there are proposals for handling some subset of navigation disposition (new window? new tab? re-use?) from within that context. These aren't particularly satisfyign from both a performance and programmability perspecitve. Service Workers may be shut down and would need to be restarted to handle decisions about if/how to navigate. They also lack data to most DOM data, including form information, making it complex to offload enough state to them to make decisions. It also isn't clear that it's good layering to invoke them here.
As a final reason not to go this route, Service Workers install asynchronously and may not be available in time to catch some navigations. The proposed deisgn does not have this problem.
A new type of link could explicitly invoke client side routing, or we could imagine a more maximal route-pre-definition system inside the document to tell the system to "skip" full navigations to specific routes. This somewhat-more-declaraitve approach could give the UA a larger role in helping users decide what they want an app to do at the limit, however we don't have concrete need for this at the moment and such a system could be layered on later without much concern.
- naming for
history.uistate
andonbeforenavigate
is very much TBD - positional param changes to
pushState
andreplaceState
? - we don't think the new history stack introspection API adds more fingerprinting attacks, but need to validate
Many thanks for valuable feedback and advice from:
- Dima Voytenko
- David Bokan
- Jake Archibald
Prior work in this area includes:
- Many, many client-side routing packages (TODO: enumerate)
- the HTML5 History API and associated
hashchange
event