Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

The new navigation API provides a new interface for navigations and session history, with a focus on single-page application navigations.

NotificationsYou must be signed in to change notification settings

WICG/navigation-api

Repository files navigation

⚠️⚠️Status: This is now developed underhttps://github.com/whatwg/html/⚠️⚠️

Formerly known as the app history API

The web's existinghistory API is problematic for a number of reasons, which makes it hard to use for web applications. This proposal introduces a new API encompassing navigation and history traversal, which is more directly usable by web application developers. Its scope is: initiating navigations, intercepting navigations, and history introspection and mutation.

This newwindow.navigation APIlayers on top of the existing API and specification infrastructure, with well-defined interaction points. The main differences are that it is scoped to the current origin and frame, and it is designed to be pleasant to use instead of being a historical accident with many sharp edges.

Summary

The existinghistory API, including its ability to cause same-document navigations viahistory.pushState() andhistory.replaceState(), is hard to deal with in practice, especially for single-page applications. In the best case, developers can work around this with various hacks. In the worst case, it causes user-facing pain in the form of lost state and broken back buttons, or the inability to achieve the desired navigation flow for a web app.

The main problems are:

  • Managing and introspecting your application's history list, and associated application state, is fragile. State can be lost sometimes (e.g. due to fragment navigations); the browser will spontaneously insert entries due to iframe navigations; and the existingpopstate andhashchange events areunreliable. We solve this by providing a view only onto the history entries created directly by the application, and the ability to look at all previous entries for your app so that no state is ever lost.

  • It's hard to figure out all the ways that navigations can occur, so that an application can synchronize its state or convert those navigations into single-page navigations. We solve this by exposing events that allow the application to observe all navigation actions, and substitute their own behavior in place of the default.

  • Various parts of the platform, e.g. accessibility technology, the browser's UI, and performance APIs, do not have good visibility into single-page navigations. We solve this by providing a standardized API for telling the browser when a single-page navigation starts and finishes.

  • Some of the current navigation and history APIs are clunky and hard to understand. We solve this by providing a new interface that is easy for developers to use and understand.

Sample code

An application or framework's centralized router can use thenavigate event to implement single-page app routing:

navigation.addEventListener("navigate",e=>{if(!e.canIntercept||e.hashChange||e.downloadRequest!==null){return;}if(routesTable.has(e.destination.url)){constrouteHandler=routesTable.get(e.destination.url);e.intercept({handler:routeHandler});}});

A page-supplied "back" button can actually take you back, even after reload, by inspecting the previous history entries:

backButtonEl.addEventListener("click",()=>{if(navigation.entries()[navigation.currentEntry.index-1]?.url==="/product-listing"){navigation.back();}else{// If the user arrived here by typing the URL directly:navigation.navigate("/product-listing",{history:"replace"});}});

Table of contents

Problem statement

Web application developers, as well as the developers of router libraries for single-page applications, want to accomplish a number of use cases related to history:

  • Intercepting cross-document navigations, replacing them with single-page navigations (i.e. loading content into the appropriate part of the existing document), and then updating the URL bar.

  • Performing single-page navigations that create and push a new entry onto the history list, to represent a new conceptual history entry.

  • Navigating backward or forward through the history list via application-provided UI.

  • Synchronizing application or UI state with the current position in the history list, so that user- or application-initiated navigations through the history list appropriately restore application/UI state.

The existinghistory API is difficult to use for these purposes. The fundamental problem is thatwindow.history surfaces the joint session history of a browsing session, and so gets updated in response to navigations in nested frames, or cross-origin navigations. Although this view is important for the user, especially in terms of how it impacts their back button, it doesn't map well to web application development. A web application cares about its own, same-origin, current-frame history entries, and having to deal with the entire joint session history makes this very painful. Even in a carefully-crafted web app, a single iframe can completely mess up the application's history.

The existing history API also has a number of less-fundamental, but still very painful, problems around how its API shape has grown organically, with only very slight considerations for single-page app architectures. For example, it provides no mechanism for intercepting navigations; to do this, developers have to intercept allclick events, cancel them, and perform the appropriatehistory.pushState() call. Thehistory.state property is a very bad storage mechanism for application and UI state, as it disappears and reappears as you transition throughout the history list, instead of allowing access to earlier entries in the list. And the ability to navigate throughout the list is limited to numeric offsets, withhistory.go(-2) or similar; thus, navigating back to an actual specific state requires keeping a side table mapping history indices to application states.

To hear more detail about these problems, in the words of a web developer, see@dvoytenko's"The case for the new Web History API". See also@housseindjirdeh's"History API and JavaScript frameworks".

Goals

Overall, our guiding principle is to make it easy for web application developers to write applications which give good user experiences in terms of the history list, back button, and other navigation UI (such as open-in-new-tab). We believe this is too hard today with thewindow.history API.

From an API perspective, our primary goals are as follows:

  • Allow easy conversion of cross-document navigations into single-page app same-document navigations, without fragile hacks like a globalclick handler.

  • Improve the accessibility of single-page app navigations (1,2,3), ideally to be on par with cross-document navigations, when they are implemented using this API.

  • Provide a uniform way to signal single-page app navigations, including their duration.

  • Provide a reliable system to tie application and UI state to history entries.

  • Continue to support the pattern of allowing the history list to contain state that is not serialized to the URL. (This is possible withhistory.pushState() today.)

  • Provide events for notifying the application about navigations and traversals, which they can use to synchronize application or UI state.

  • Allow metrics code to watch for navigations, including gathering timing information about how long they took, without interfering with the rest of the application.

  • Provide a way for an application to reliably navigate through its own history list.

  • Provide a reasonable layering onto and integration with the existingwindow.history API, in terms of spec primitives and ensuring non-terrible behavior when both are used.

Non-goals:

  • Allow web applications to intercept user-initiated navigations in a way that would trap the user (e.g., disabling the URL bar or back button).

  • Provide applications knowledge of cross-origin history entries or state.

  • Provide applications knowledge of other frames' entries or state.

  • Provide platform support for the coordination problem of multiple routers (e.g., per-UI-component routers) on a single page. We plan to leave this coordination to frameworks for now (with the frameworks using the new API).

  • Handle the case where the Android back button is being used as a "close signal"; instead, we believe that's best handled bya separate API.

  • Provide any handling for preventing navigations that might lose data: this is already handled orthogonally by the platform'sbeforeunload event.

  • Provide anelegant layering onto or integration with the existingwindow.history API. That API is quite problematic, and we can't be tied down by a need to make every operation in the new API isomorphic to one in the old API.

A goal that might not be possible, but we'd like to try:

  • It would be ideal if this API were polyfillable, especially in its mainline usage scenarios.

Finally, although it's really a goal for all web APIs, we want to call out a strong focus on interoperability, backstopped byweb platform tests. The existing history API and its interactions with navigation have terrible interoperability (seethis vivid example). We hope to have solid and well-tested specifications for:

  • Every aspect and self-interaction of the new API

  • Every aspect of how the new API integrates and interacts with thewindow.history API (including things like relative timing of events)

Additionally, we hope to drive interoperability through tests, spec updates, and browser bugfixes for the existingwindow.history API while we're in the area, to the extent that is possible; some of this work is being done inwhatwg/html#5767.

Proposal

The current entry

The entry point for the new navigation API iswindow.navigation. Let's start withnavigation.currentEntry, which is an instance of the newNavigationHistoryEntry class. This class has the following readonly properties:

  • id: a user-agent-generated UUID identifying this particularNavigationHistoryEntry. This will be changed upon any mutation of the current history entry, such as replacing its state or updating the current URL.

  • key: a user-agent-generated UUID identifying this history entry "slot". This will stay the same even if the entry is replaced.

  • index: the index of thisNavigationHistoryEntry within the (Window- and origin-specific) history entry list. (Or,-1 if the entry is no longer in the list, or not yet in the list.)

  • url: the URL of this history entry (as a string).

  • sameDocument: a boolean indicating whether this entry is for the current document, or whether navigating to it will require a full navigation (either from the network, or from the browser's back/forward cache). Note: fornavigation.currentEntry, this will always betrue.

It also has a methodgetState(), which retrieve the navigation API state for the entry. This is somewhat similar tohistory.state, but it will survive fragment navigations, andgetState() always returns a fresh clone of the state to avoid themisleading nature ofhistory.state:

navigation.reload({state:{test:2}});// Don't do this: it won't be saved to the stored state.navigation.currentEntry.getState().test=3;console.assert(navigation.currentEntry.getState().test===2);// Instead do this, combined with a `navigate` event handler:navigation.reload({state:{ ...navigation.currentEntry.getState(),test:3}});

Crucially,navigation.currentEntry stays the same regardless of what iframe navigations happen. It only reflects the current entry for the current frame. The complete list of ways the current navigation API history entry can change to a new entry (with a newNavigationHistoryEntry object, and a newkey value) are:

  • A fragment navigation, which will copy over the navigation API state to the new entry.

  • Viahistory.pushState(). (Nothistory.replaceState().)

  • A full-page navigation to a different document. This could be an existing document in the browser's back/forward cache, or a new document. In the latter case, this will generate a new entry on the new page'swindow.navigation.entries() list, somewhat similar tonavigation.navigate(navigatedToURL, { state: undefined }). Note that if the navigation is cross-origin, then we'll end up in a separate navigation API history entries list for that other origin.

  • When using thenavigate event toconvert a cross-document non-replace navigation into a same-document navigation.

The current entry can be replaced with a new entry, with a newNavigationHistoryEntry object and a newid (but usually the samekey), in the following ways:

  • Viahistory.replaceState().

  • Via cross-document replace navigations generated bylocation.replace() ornavigation.navigate(url, { history: "replace", ... }). Note that if the navigation is cross-origin, then we'll end up in a separate navigation API history entry list for that other origin, wherekey will not be preserved.

  • When using thenavigate event toconvert a cross-document replace navigation into a same-document navigation.

For any same-document navigation, traversal, or replacement, thecurrententrychange event will fire onnavigation:

navigation.addEventListener("currententrychange",()=>{// navigation.currentEntry has changed: either to a completely new entry (with a new key),// or it has been replaced (keeping the same key but with a new id).});

Inspection of the history entry list

In addition to the current entry, the entire list of history entries can be inspected, usingnavigation.entries(), which returns an array ofNavigationHistoryEntry instances. (Recall that all navigation API history entries are same-origin contiguous entries for the current frame, so this is not a security issue.)

This solves the problem of allowing applications to reliably store state in aNavigationHistoryEntry's state: because they can inspect the values stored in previous entries at any time, it can be used as real application state storage, without needing to keep a side table like one has to do when usinghistory.state.

Note that we have a method,navigation.entries(), instead of a static array,navigation.entries, to emphasize that retrieving the entries gives you a snapshot at a given point in time. That is, the current set of history entries could change at any point due to manipulations of the history list, including by the user.

In combination with the following section, theentries() API also allows applications to display a UI allowing navigation through the entry list.

Navigation through the history entry list

The way for an application to navigate through the history entry list is usingnavigation.traverseTo(key). For example:

functionrenderHomepage(){consthomepageKey=navigation.currentEntry.key;// ... set up some UI ...document.querySelector("#home-button").addEventListener("click",asynce=>{try{awaitnavigation.traverseTo(homepageKey).finished;}catch{// Fall back to a normal push navigationnavigation.navigate("/");}});}

Unlike the existing history API'shistory.go() method, which navigates by offset, traversing by key allows the application to not care about intermediate history entries; it just specifies its desired destination entry. There are also convenience methods,navigation.back() andnavigation.forward(), and convenience booleans,navigation.canGoBack andnavigation.canGoForward.

All of these methods return{ committed, finished } pairs, where both values are promises. This because navigations can be intercepted and made asynchronous by thenavigate event handlers that we're about to describe in the next section. There are then several possible outcomes:

  • Anavigate event handler callsevent.preventDefault(), in which case both promises reject with an"AbortError"DOMException, andlocation.href andnavigation.currentEntry stay on their original value.

  • It's not possible to navigate to the given entry, e.g.navigation.traverseTo(key) was given a non-existantkey, ornavigation.back() was called when there's no previous entries in the list of accessible history entries. In this case, both promises reject with an"InvalidStateError"DOMException, andlocation.href andnavigation.currentEntry stay on their original value.

  • Thenavigate event responds to the navigation usingevent.intercept() with acommit option of"immediate" (the default). In this case thecommitted promise immediately fulfills, while thefinished promise fulfills or rejects according to any promise(s) returned by handlers passed tointercept(). (However, even if thefinished promise rejects,location.href andnavigation.currentEntry will change.)

  • Thenavigate event listener responds to the navigation usingevent.intercept() with acommit option of"after-transition". In this case thecommitted promise fulfills andlocation.href andnavigation.currentEntry change whenevent.commit() is called. Thefinished promise fulfills or rejects according to any promise(s) returned by handlers passed tointercept(). If a promise returned by a handler rejects beforeevent.commit() is called, then both thecommitted andfinished promises reject andlocation.href andnavigation.currentEntry do not update. If all promise(s) returned by handlers fulfill, but thecommitted promise has not yet fulfilled, thecommitted promise will be fulfilled and andlocation.href andnavigation.currentEntry will be updated first, thenfinished will fulfill.

  • The navigation succeeds, and was a same-document navigation (but not intercepted usingevent.intercept()). Then both promises immediately fulfill, andlocation.href andnavigation.currentEntry will have been set to their new value.

  • The navigation succeeds, and it was a different-document navigation. Then the promise will never settle, because the entire document and all its promises will disappear.

In all cases, the fulfillment value for the promises is theNavigationHistoryEntry being navigated to. This can be useful for setting upper-entry event handlers.

As discussed in more detail in the section onintegration with the existing history API and spec, navigating through the navigation API history list does navigate through the joint session history. This means itcan impact other frames on the page. It's just that, unlikehistory.back() and friends, such other-frame navigations always happen as a side effect of navigating your own frame; they are never the sole result of a navigation API traversal. (An interesting consequence of this is thatnavigation.back() andnavigation.forward() are not always opposites.)

Keys and IDs

As notedabove,key stays stable to represent the "slot" in the history list, whereasid gets updated whenever the history entry is updated. This allows them to serve distinct purposes:

  • key provides a stable identifier for a given slot in the history entry list, for use by thenavigation.traverseTo() method which allows navigating to specific waypoints within the history list.

  • id provides an identifier for the specific URL and navigation API state currently in the entry, which can be used to correlate a history entry with an out-of-band resource such as a cache.

With thewindow.history API, web applications have tried to use the URL for such purposes, but the URL is not guaranteed to be unique within a given history list.

Note that bothkey andid are user-agent-generated random UUIDs. This is done, instead of e.g. using a numeric index, to encourage treating them as opaque identifiers.

Bothkey andid are stored in the browser's session history, and as such are stable across session restores.

Note thatkey is not a stable identifier for a slot in thejoint session history list, but instead in thenavigation API history entry list. In particular, this means that if a given history entry is replaced with a cross-origin one, which lives in a different navigation API history list, it will get a new key. (This replacement prevents cross-site tracking.)

Navigation monitoring and interception

The most interesting event onwindow.navigation is the one which allows monitoring and interception of navigations: thenavigate event. It fires on almost any navigation, either user-initiated or application-initiated, which would update the value ofnavigation.currentEntry. This includes cross-origin navigations (which will take us out of the current navigation API history entry list).We expect this to be the main event used by application- or framework-level routers.

The event object has several useful properties:

  • cancelable (inherited fromEvent): indicates whetherpreventDefault() is allowed to cancel this navigation.

  • canIntercept: indicates whetherintercept(), discussed below, is allowed for this navigation.

  • navigationType: either"reload","push","replace", or"traverse".

  • userInitiated: a boolean indicating whether the navigation is user-initiated (i.e., a click on an<a>, or a form submission) or application-initiated (e.g.location.href = ...,navigation.navigate(...), etc.). Note that this willnot betrue when you use mechanisms such asbutton.onclick = () => navigation.navigate(...); the user interaction needs to be with a real link or form. See the table in theappendix for more details.

  • destination: an object containing the information about the destination of the navigation. It has many of the same properties as aNavigationHistoryEntry: namelyurl,sameDocument, andgetState() for all navigations, andid,key, andindex for same-origin"traverse" navigations. (See#97 for discussion as to whether we should add the latter to non-"traverse" same-origin navigations as well.)

  • hashChange: a boolean, indicating whether or not this is a same-documentfragment navigation.

  • formData: aFormData object containing form submission data, ornull if the navigation is not a form submission.

  • downloadRequest: a string or null, indicating whether this navigation was initiated by a<a href="..." download> link. If it was, then this will contain the value of the attribute (which could be the empty string).

  • info: any value passed bynavigation.navigate(url, { state, info }),navigation.back({ info }), or similar, if the navigation was initiated by one of those methods and theinfo option was supplied. Otherwise, undefined. Seethe example below for more.

  • sourceElement: anElement or null, indicating what element (if any) initiated this navigation. If the navigation was triggered by a link click, thesourceElement will be the<a href="...">. If the navigation was triggered by a form submission, thesourceElement will be thethe element that sent thesubmit event to the form, or if that is null, the<form> being submitted. ThesourceElement will also be null when a targeting a differentwindow (e.g.,<a href="...">).

  • signal: anAbortSignal which can be monitored for when the navigation gets aborted.

Note that you can check if the navigation will besame-document or cross-document viaevent.destination.sameDocument, and you can check whether the navigation is to an already-existing history entry (i.e. is a back/forward navigation) viaevent.navigationType.

The event object has a special methodevent.intercept(options). This works only under certain circumstances, e.g. it cannot be used on cross-origin navigations. (See below for full details.) It will:

  • Cancel any fragment navigation or cross-document navigation.
  • Immediately update the URL bar,location.href, andnavigation.currentEntry unless theevent.intercept() was called with acommit option of"after-transition".
  • Create thenavigation.transition object.
  • Ifoptions.handler is given, it can be a function that returns a promise. That function will be then be called, and the browser will wait for the returned promise to settle. Once it does, the browser will:
    • If the promise rejects, firenavigateerror onnavigation and rejectnavigation.transition.finished.
    • If the promise fulfills, firenavigatesuccess onnavigation and fulfillnavigation.transition.finished.
    • Setnavigation.transition to null.
  • For the duration of any such promise settling, any browser loading UI such as a spinner will behave as if it were doing a cross-document navigation.

Note that the browser does not wait for any returned promises to settle in order to update its URL/history-displaying UI (such as URL bar or back button), or to updatelocation.href andnavigation.currentEntry, unless acommit option of"after-transition" is provided toevent.intercept().See below for more details.

Ifintercept() is called multiple times (e.g., by multiple different listeners to thenavigate event), then all of the promises returned by any handlers will be combined together using the equivalent ofPromise.all(), so that the navigation only counts as a success once they have all fulfilled, or the navigation counts as an error at the point where any of them reject.

In#66, we are discussing adding the capability to delay URL/current entry updates to not happen immediately, as a future extension.

Example: replacing navigations with single-page app navigations

The following is the kind of code you might see in an application or framework's router:

navigation.addEventListener("navigate",e=>{// Some navigations, e.g. cross-origin navigations, we cannot intercept. Let the browser handle those normally.if(!e.canIntercept){return;}// Don't intercept fragment navigations or downloads.if(e.hashChange||e.downloadRequest!==null){return;}e.intercept({handler(){if(e.formData){processFormDataAndUpdateUI(e.formData,e.sourceElement,e.signal);}else{doSinglePageAppNav(e.destination,e.signal);}}});});

Here,doSinglePageAppNav andprocessFormDataAndUpdateUI are functions that can return a promise. For example:

asyncfunctiondoSinglePageAppNav(destination,signal){consthtmlFromTheServer=await(awaitfetch(destination.url,{ signal})).text();document.querySelector("main").innerHTML=htmlFromTheServer;}

Note how this example responds to various types of navigations:

  • Cross-origin navigations: let the browser handle it as usual.
  • Same-document fragment navigations: let the browser handle it as usual.
  • Same-document URL or state updates (viahistory.pushState() orhistory.replaceState()):
    1. Send the information about the URL/state update todoSinglePageAppNav(), which will use it to modify the current document.
    2. After that UI update is done, potentially asynchronously, notify the app and the browser about the navigation's success or failure.
  • Cross-document normal navigations (including those vianavigation.navigate()):
    1. Prevent the browser handling, which would unload the document and create a new one from the network. Instead, immediately change the URL bar/location.href/navigation.currentEntry, while staying on the same document.
    2. Send the information about the navigation todoSinglePageAppNav(), which will use it to modify the current document.
    3. After that UI update is done, potentially asynchronously, notify the app and the browser about the navigation's success or failure.
  • Cross-document form submissions:
    1. Prevent the browser handling, which would unload the document and create a new one from the network. Instead, immediately change the URL bar/location.href/navigation.currentEntry, while staying on the same document.
    2. Send the form data toprocessFormDataAndUpdateUI(), which will use it to modify the current document.
    3. After that UI update is done, potentially asynchronously, notify the app and the browser about the navigation's success or failure.

Notice also how by passing through theAbortSignal found ine.signal, we ensure that any aborted navigations abort the associated fetch as well.

Example: async transitions with special back/forward handling

Sometimes it's desirable to handle back/forward navigations specially, e.g. reusing cached views by transitioning them onto the screen. This can be done by branching as follows:

navigation.addEventListener("navigate",e=>{// As before.if(!e.canIntercept||e.hashChange||e.downloadRequest!==null){return;}e.intercept({asynchandler(){if(myFramework.currentPage){awaitmyFramework.currentPage.transitionOut();}let{ key}=e.destination;if(e.navigationType==="traverse"&&myFramework.previousPages.has(key)){awaitmyFramework.previousPages.get(key).transitionIn();}else{// This will probably result in myFramework storing the rendered page in myFramework.previousPages.awaitmyFramework.renderPage(e.destination);}}});});

Example: progressively enhancing form submissions

A common pattern for multi-page web apps ispost/redirect/get, which handles POST form submissions by performing a server-side redirect to a page retrieved with GET. This avoids a POST-derived page from ever entering the session history, since this can lead to confusing "Do you want to resubmit the form?" popups andinterop problems.

The navigation API'snavigate event allows emulating this pattern on the client side using code such as the following:

navigation.addEventListener("navigate",e=>{consturl=newURL(e.destination.url);switch(url.pathname){case"/form-submit":{e.intercept({asynchandler(){// Do not navigate to form-submit; instead send the data to that endpoint using fetch().awaitfetch("/form-submit",{body:e.formData});// Perform a client-side "redirect" to /destination.awaitnavigation.navigate("/destination",{history:"replace"}).finished;}});break;}case"/destination":{e.intercept({asynchandler(){document.body.innerHTML="Form submission complete!";}});break;}}});

Note how doing a replace navigation to/destination overrides the in-progress navigation to/form-submit, so that like in the server-driven approach, the session history ends up going straight from the original page to/destination, with no entry for/form-submit. (This example uses thenavigation.navigate() API introducedfurther down, but you could also dolocation.replace("/destination") for much the same effect.)

What's cool about this example is that, if the browser does not support the new navigation API, then the server-driven post/redirect/get flow will still go through, i.e. the user will see a full-page navigation that leaves them at/destination. So this is purely a progressive enhancement.

Seethis interactive demo to check out the technique in action, in browsers with or without the new navigation API implemented.

Restrictions on firing, canceling, and responding

There are many types of navigations a given page can experience; seethis appendix for a full breakdown. Some of these need to be treated specially for the purposes of the navigate event.

First, the following navigationswill not firenavigate at all:

  • User-initiatedcross-document navigations via non-back/forward browser UI, such as the URL bar, bookmarks, or the reload button
  • Cross-document navigations initiated from other cross origin windows, e.g. viawindow.open(url, nameOfYourWindow), or clicking on<a href="...">
  • document.open(), which can strip off the fragment from the current document's URL

Navigations of the first sort are outside the scope of the webpage, and can never be intercepted or prevented. This is true even if they are to same-origin documents, e.g. if the browser is currently displayinghttps://example.com/foo and the user edits the URL bar to readhttps://example.com/bar and presses enter. On the other hand, we do allow the page to intercept user-initiatedsame-document navigations via browser UI, e.g. if the the browser is currently displayinghttps://example.com/foo and the user edits the URL bar to readhttps://example.com/foo#fragment and presses enter. (We do fire anavigate event for browser-UI back/forward buttons; see more discussion below.)

Similarly, cross-document navigations initiated from other windows are not something that can be intercepted today, and for security reasons, we don't want to introduce the ability for your origin to mess with the operation of another origin's scripts. (Even if the purpose of those scripts is to navigate your frame.)

As fordocument.open(), it is a terrible legacy API with lots of strange side effects, which makes supporting it not worth the implementation cost. Modern sites which use the new navigation API should never be usingdocument.open().

Second,traversals have special restrictions on canceling the navigation viaevent.preventDefault(). Traversals are:

  • User-initiated traversals via the browser's back/forward buttons (either same- or cross-document)
  • Programmatic traversals viahistory.back()/history.forward()/history.go()
  • Programmatic traversals vianavigation.back()/navigation.forward()/navigation.traverseTo()

Traversals may only be canceled (andevent.cancelable will be equal to true) if:

  • The navigate event is firing in the top window
  • The traversal is same-document
  • The traversal was not user-initiated, or there is a consumable activation in the current window.

Allowing cancelation only in the top window is to ensure that there is a single authoritative source for deciding whether or not to cancel the traversal. If all windows were allowed to cancel and a traversal navigated multiple windows, and some canceled but others proceeded, there would not be a good way to keep all windows in sync with the joint session history. Cross-document traversals are uncancelable because of performance concerns. If a cross-document traversal were cancelable, it would need to block before any network requests are sent in order to firenavigate. This is already necessary forbeforeunload, butbeforeunload is a single-purpose event and if nobeforeunload handler is present, the blocking step can be skipped.navigate, on the other hand, is intended to be used for many purposes, and it is wasteful to impose a performance penalty on all cross-document traversals simply becausenavigate was being used for something unrelated to canceling cross-document navigations. Finally, user activation is required for user-initiated traversals in order to minimize the possibility of trapping:event.preventDefault() on a traversal consumes the user activation, ensuring that the user can always break out of an application that is canceling traversals by, e.g., pressing the browser's back button twice in a row.

Byconsumable activation, we mean a variant ofuser activation that we wish to add to the HTML spec.Sticky activation is obviously not correct for preventing trapping the user, because then a single errant click or tap could disable back/forward navigations entirely. However, we are also concerned about usingtransient activation: it meets our requirement that the user activation can be used once before it is consumed, but the possibility of it expiring due to itstransient activation duration elapsing means that web applications may suddenly and unexpectedly get an uncancelable traversal if a back or forward button is pressed and the user happens not to have interacted with the page for a modest period of time. We therefore intend to add a third mode of user activation to the HTML spec,consumable activation, which can be consumed liketransient activation, but does not expire based on thetransient activation duration.

Because canceling is limited to same-document traversals, no special timing or handler is required for firingnavigate; the standard fragment navigation timing just works. If the performance concerns around canceling cross-document traversals were to be resolved at some point in the future, special consideration would need to be given to firingnavigate at the correct time in the traversal process (presumably at the same time thatbeforeunload is fired).

Finally, the following navigationscannot be replaced with same-document navigations by usingevent.intercept(), and as such will haveevent.canIntercept equal to false:

  • Any navigation to a URL which differs in scheme, username, password, host, or port. (I.e., you can only intercept URLs which differ in path, query, or fragment.)
  • Anycross-document back/forward navigations. Transitioning two adjacent history entries from cross-document to same-document has unpleasant ripple effects on web application and browser implementation architecture.

We'll note that these restrictions allow canceling cross-origin non-back/forward navigations. Although this might be surprising, in general it doesn't grant additional power. That is, web developers can already intercept<a>click events, or modify their code that would setlocation.href, even if the destination URL is cross-origin.

Measuring standardized single-page navigations

Continuing with the theme ofintercept() giving ecosystem benefits beyond just web developer convenience, telling the browser about the start time, duration, end time, and success/failure if a single-page app navigation has benefits for metrics gathering.

In particular, analytics frameworks would be able to consume this information from the browser in a way that works across all applications using the navigation API. See the discussion onperformance timeline API integration for what we are proposing there.

This standardized notion of single-page navigations also gives a hook for other useful metrics to build off of. For example, you could imagine variants of the"first-paint" and"first-contentful-paint" APIs which are collected after thenavigate event is fired. Or, you could imagine vendor-specific or application-specific measurements likeCumulative Layout Shift or React hydration time being reset after such navigations begin.

This isn't a complete panacea: in particular, such metrics are gameable by bad actors. Such bad actors could try to drive down average measured "load time" by generating excessivenavigate events that don't actually do anything. So in scenarios where the web application is less interested in measuring itself, and more interested in driving down specific metrics, those creating the metrics will need to take into account such misuse of the API. Some potential countermeasures against such gaming could include:

  • Only using the start time of the navigation in creating such metrics, and not using the promise-settling time. This avoids gaming via code such asevent.intercept(/* no handler */); await doActualNavigation(); which makes the navigation appear instant to the browser.

  • Filtering to only count navigations whereevent.userInitiated is true.

  • Filtering to only count navigations where the URL changes (i.e.,navigation.currentEntry.url !== event.destination.url).

  • We hope that most analytics vendors will come to automatically tracknavigate events as page views, and measure their duration. Then, apps using such analytics vendors would have an incentive to keep their page view statistics meaningful, and thus be disincentivized to generate spurious navigations.

Aborted navigations

As shown inthe example above, thenavigate event comes with anevent.signal property that is anAbortSignal. This signal will transition to the aborted state if any of the following occur before any promises returned by handlers passed tointercept() settle:

  • The user presses their browser's stop button (or similar UI, such as theEsc key).
  • Another navigation is started, either by the user or programmatically. This includes back/forward navigations, e.g. the user pressing their browser's back button.

The signal will not transition to the aborted state ifintercept() is not called. This means it cannot be used to observe the interruption of across-document navigation, if that cross-document navigation was left alone and not converted into a same-document navigation by usingintercept().

Whether and how the application responds to this abort is up to the web developer. In many cases, such as inthe example above, this will automatically work: by passing theevent.signal through to anyAbortSignal-consuming APIs likefetch(), those APIs will get aborted, and the resulting"AbortError"DOMException propagated from the handler passed tointercept(). But it's possible to ignore it completely, as in the following example:

navigation.addEventListener("navigate",event=>{event.intercept({asynchandler(){awaitnewPromise(r=>setTimeout(r,10_000));document.body.innerHTML=`Navigated to${event.destination.url}`;}});});

In this case:

  • The user pressing the stop button will have no effect, and after ten secondsdocument.body will get updated anyway with the destination URL of the original navigation.
  • Navigation to another URL will not prevent the fact that in ten secondsdocument.body.innerHTML will be updated to show the original destination URL.

Customizations and consequences of navigation interception

Accessibility technology announcements

Withcross-document navigations, accessibility technology will announce the start of the navigation, and its completion. But traditionally, same-document navigations (i.e. single-page app navigations) have not been communicated in the same way to accessibility technology. This is in part because it is not clear to the browser when a user interaction causes a single-page navigation, because of the app-specific JavaScript that intermediates between such interactions and the eventual call tohistory.pushState()/history.replaceState(). In particular, it's unclear exactly when the navigation begins and ends: trying to use the URL change as a signal doesn't work, since when applications callhistory.pushState() during the content loading process varies.

Any navigation that is intercepted and converted into a single-page navigation usingnavigateEvent.intercept() will be communicated to accessibility technology in the same way as a cross-document one. Usingintercept() serves as a opt-in to this new behavior, and the provided promise allows the browser to know how long the navigation takes, and whether or not it succeeds.

Loading spinners and stop buttons

It is a long-requested feature (seewhatwg/fetch#19 andwhatwg/html#330) to give pages control over the browser's loading indicator, i.e. the one they show while cross-document navigations are ongoing. This proposal gives the browsers the tools to do this: they can display the loading indicator while any promises returned by handlers passed tonavigateEvent.intercept() are settling.

Additionally, in modern browsers, the reload button is replaced with a stop button while such loading is taking place. This can be done for navigation API-intercepted navigations as well, with the result communicated to the developer usingnavigateEvent.signal.

You can see ademo andscreencast of this behavior in Chromium.

Note: specifications do not mandate browser UI, so this is not guaranteed behavior. But it's a nice feature that we hope browsers do end up implementing!

Focus management

Likeaccessibility technology announcements, focus management currently behaves differently between same-document navigations and cross-document navigations. Asthis post discusses:

... a user’s keyboard focus point may be kept in the same place as where they clicked, which isn’t intuitive. In layouts where the page changes partially to include a deep-linked modal dialog or other view layer, a user’s focus point could be left in an entirely wrong spot on the page.

The navigation API's navigation interception again gives us the tool to fix this.

By default, any navigation that is intercepted and converted into a single-page navigation usingnavigateEvent.intercept() will cause focus to reset to the<body> element, or to the first element with theautofocus="" attribute set (if there is one). This focus reset will happen after any promises returned by handlers passed tointercept() settle. However, this focus reset will not take place if the user or developer has manually changed focus while the promise was settling, and that element is still visible and focusable.

This behavior can be customized usingintercept()'sfocusReset option:

  • e.intercept({ handler, focusReset: "after-transition" }): the default behavior, described above.
  • e.intercept({ handler, focusReset: "manual" }): does not reset the focus, and leaves it where it is. (Although, it might getreset anyway if the element is removed from the DOM or similar.) The application will manually manage focus changes.

In general, the default behavior is a best-effort attempt at cross-document navigation parity. But if developers invest some extra work, they can do even better:

  • Per the above-linkedresearch by Fable Tech Labs, screen reader users generally prefer focus to be reset to a heading or wrapper element, instead of the<body> element. So to get the optimal experience with navigation interception, developers should useautofocus="" appropriately on such elements.

  • For traversals (i.e. cases wherenavigateEvent.navigationType === "traverse"), getting parity with theback/forward cache experience requires restoring focus to the same element that was previously focused when that history entry was active. Unfortunately, this isn't something the browser can do automatically for client-side rendered applications; the notion of "the same element"is not generally stable in such cases. So for such cases, usingfocusReset: "manual", storing some identifier for the currently-focused element in the navigation API state, and callingelement.focus() appropriately upon transition could give a better experience, as in the following example:

navigation.addEventListener("navigate",e=>{constfocusedIdentifier=computeIdentifierFor(document.activeElement);navigation.updateCurrentEntry({ ...navigation.currentEntry.getState(), focusedIdentifier});if(e.canIntercept){consthandler=figureOutHandler(e);constfocusReset=e.navigationType==="traverse" ?"manual" :"after-transition";e.intercept({ handler, focusReset});}});navigation.addEventListener("navigatesuccess",()=>{if(navigation.transition.navigationType==="traverse"){const{ focusedIdentifier}=navigation.currentEntry.getState();constelementToFocus=findByIdentifier(focusedIdentifier);if(elementToFocus){elementToFocus.focus();}}});

An additional API that would be helpful, both for cases like these and more generally, would be one forsetting and getting the sequential focus navigation start point. Especially for the custom traversals case, this would give even higher-fidelity focus restoration. (But that proposal is separate from the new navigation API.)

We can also extend thefocusReset option with other behaviors in the future. Here are a couple which have been proposed, but we are not planning to include in the initial version unless we get strong developer feedback that they would be helpful:

  • focusReset: "immediate": immediately resets the focus to the<body> element, without waiting for the promise to settle.
  • focusReset: "two-stage": immediately resets the focus to the<body> element, and then has the same behavior as"after-transition".

Scrolling to fragments and scroll resetting

Note that the discussion in this section applies only to"push" and"replace" navigations. For the behavior for"traverse" and"reload" navigations, see thenext section.

When you change the URL withhistory.pushState()/history.replaceState(), the user's scroll position stays where it is. This is true even if you try to navigate to a fragment, e.g. by doinghistory.pushState("/article#subheading"). The latter has caused significant pain in client-side router libraries; see e.g.remix-run/react-router#394, or the manual code that is needed to handle this case inVue,Angular,React Router Hash Link, and others.

With the navigation API, there is a different default behavior, controllable via another option tonavigateEvent.intercept():

  • e.intercept({ handler, scroll: "after-transition" }): the default behavior. After the promise returned byhandler fulfills, the browser will attempt to scroll to the fragment given bye.destination.url, or if there is no fragment, it will reset the scroll position to the top of the page (like in a cross-document navigation).
  • e.intercept({ handler, scroll: "manual" }): the browser will not change the user's scroll position, although you can later perform the same logic manually usinge.scroll().

ThenavigateEvent.scroll() method could be useful if you know you have loaded the element referenced by the hash, or if you know you want to reset the scroll position to the top of the document early, before the full transition has finished. For example:

constfreshEntry=navigateEvent.navigationType==="push"||navigateEvent.navigationType==="replace";navigateEvent.intercept({scroll:freshEntry ?"manual" :"after-transition",asynchandler(){awaitfetchDataAndSetUpDOM(navigateEvent.url);if(freshEntry){navigateEvent.scroll();}// Note: navigateEvent.scroll() will update what :target points to.awaitfadeInTheScrolledToElement(document.querySelector(":target"));},});

If you want to only perform the scroll-to-a-fragment behavior, and not reset the scroll position to the top if there is no matching fragment, then you can use"manual" combined with only callingnavigateEvent.scroll() when(new URL(navigateEvent.destination.url)).hash points to an element that exists.

Scroll position restoration

A common pain point for web developers is scroll restoration during"traverse" and"reload" navigations. The essential problem is that scroll restoration happens unpredictably, and often at the wrong times. For example:

  • The browser tries to restore the user's scroll position, but the application logic is still setting up the DOM and the relevant elements aren't ready yet.
  • The browser tries to restore the user's scroll position, but the page's contents have changed and scroll restoration doesn't work that well. (For example, going back to a listing of files in a shared folder, after a different user deleted a bunch of the files.)
  • The application needs to perform some measurements in order to do a proper transition, but the browser does scroll restoration during the transition, which messes up those measurements. (Demo of this problem: notice how when going back to the grid view, the transition sends the square to the wrong location.)

The samescroll option tonavigateEvent.intercept() that we described above for"push" and"replace" navigations, similarly controls scroll restoration for"traverse" and"reload" navigations. And similarly to that case, usingintercept() opts you into a more sensible default behavior:

  • e.intercept({ handler, scroll: "after-transition" }): the default behavior. The browser delays its scroll restoration logic untilpromise fulfills; it will perform no scroll restoration if the promise rejects. If the user has scrolled during the transition then no scroll restoration will be performed (like for multi-page navs).
  • e.intercept({ handler, scroll: "manual" }): The browser will perform no automatic scroll restoration. However, the developer can use thee.scroll() API to get semi-automatic scroll restoration, or can usewindow.scrollTo() or similar APIs to take full control.

For"traverse" and"reload", thenavigateEvent.scroll() API performs the browser's scroll restoration logic at the specified time. This allows cases that require precise control over scroll restoration timing, such as a non-broken version of thedemo referenced above, to be written like so:

if(navigateEvent.navigationType==="traverse"||navigateEvent.navigationType==="reload"){navigateEvent.intercept({scroll:"manual",asynchandler(){awaitfetchDataAndSetUpDOM();navigateEvent.scroll();awaitmeasureLayoutAndDoTransition();},});}

Some more details on how the navigation API handles scrolling with"traverse" and"reload" navigations:

  • navigateEvent.scroll() will silently do nothing if called after the user has started scrolling the document.

  • navigateEvent.scroll() doesn't actually perform a single update of the scroll position. Rather, it puts the page in scroll-position-restoring mode. The scroll position could update several times as more elements load andscroll anchoring kicks in.

  • By default, any navigations which are intercepted withnavigateEvent.intercept() willignore the value ofhistory.scrollRestoration from the classic history API. This allows developers to usehistory.scrollRestoration for controlling cross-document scroll restoration, while using the more-granular option tointercept() to control individual same-document navigations.

Precommit handlers

The default behavior of immediately "committing" (i.e., updatinglocation.href andnavigation.currentEntry) works well for most situations, but some developers may find they do not want to immediately update the URL, and may want to retain the option of aborting the navigation without needing to rollback a URL update or cancel-and-restart. This behavior can be customized passing aprecommitHandler callback alongside or instead of thehandler callback:

  • e.intercept({ handler }): the default behavior, immediately commit the navigation and updatelocation.href andnavigation.currentEntry.
  • e.intercept({ precommitHandler }): start the navigation (e.g., show a loading spinner if the UI has one), but do not immediately commit.

The object passed to intercept can include both ahandler and aprecommitHandler. If both are included, they are called individually at the appropriate phase.

When precommit handlers are used, the navigation will commit (and acommitted promise will resolve if present) once all those handlers are fulfilled.

If aprecommitHandler passed tointercept() rejects, then the navigation will be treated as canceled (bothcommitted andfinished promises will reject, and no URL update will occur).

Because precommit handlers can be used to cancel the navigation before the URL updates, they are only available whene.cancelable is true. Seeabove for details on whene.cancelable is set to false, and thus precommit handlers are not available. Callingintercept() with aprecommitHandler on a non-cancelable event would throw a"SecurityError"DOMException.

Redirects during deferred commit

TheprecommitHandler callback accepts an argument, which is acontroller that can perform certain actions on the precommitted navigation, in particular redirecting. This updates the eventual destination, and potentially the state/info, of the"push" or"replace" navigation.An example usage is as follows:

navigation.addEventListener("navigate",e=>{e.intercept({asyncprecommitHandler(controller){if(awaitisLoginGuarded(e.destination)){controller.redirect("/login",{state:"login-redirect",info:"some-info"});}}asynchandler(){// apply committed navigation state to document}});});

This is simpler than the alternative of canceling the original navigation and starting a new one to the redirect location, because it avoids exposing the intermediate state. For example, only onenavigatesuccess ornavigateerror event fires, and if the navigation was triggered by a call tonavigation.navigate(), the promise only fulfills once the redirect destination is reached.

It's possible in the future we could contemplate allowing something similar for{ commit: "immediate" } navigations as well. There, we would not be able to hide the intermediate state perfectly, as code would still be able to observe the intermediatelocation.href values and such. But we could treat such post-commit redirects as special types of replace navigations, which "take over" any promises returned fromnavigation.navigate(), delaynavigatesuccess/navigateerror events, etc.

The controller can also be used to add a post-commit handler from the precommit handler:

navigation.addEventListener("navigate",e=>{e.intercept({asyncprecommitHandler(controller){if(awaitsome_original_operation_that_can_fail()){controller.addHandler(async()=>{do_some_post_processing();});}}});});

This allows a more dynamic control flow between precommit operations and post-commit operations.

Transitional time after navigation interception

As part of callingevent.intercept() tointercept a navigation and convert it into a single-page navigation, the handlers passed tointercept() can return promises that might not settle for a while. During this transitional time, before the promise settles and thenavigatesuccess ornavigateerror events fire, an additional API is available,navigation.transition. It has the following properties:

  • navigationType: either"reload","push","replace", or"traverse" indicating what type of navigation this is
  • from: theNavigationHistoryEntry that was the current one before the transition
  • to: theNavigationDestination of the transition. This could differ from the current URL if aprecommitHandler has calledprecommitController.redirect().
  • finished: a promise which fulfills with undefined when thenavigatesuccess event fires onnavigation, or rejects with the corresponding error when thenavigateerror event fires onnavigation

Example: handling failed navigations

To handle failed single-page navigations, i.e. navigations where a promise returned by a handler passed toevent.intercept() eventually rejects, you can listen to thenavigateerror event and perform application-specific interactions. This event will be anErrorEvent so you can retrieve the promise's rejection reason. For example, to display an error, you could do something like:

navigation.addEventListener("navigateerror",e=>{document.body.textContent=`Could not load${location.href}:${e.message}`;analyticsPackage.send("navigateerror",{stack:e.error.stack});});

This would give your users an experience most like a multi-page application, where server errors or broken links take them to a dedicated, server-generated error page.

Thenavigate() andreload() methods

In a single-page app usingwindow.history, the typical flow is:

  1. Application code triggers a router's navigation infrastructure, giving it a destination URL and possibly additional state or info.
  2. The router updates the URL displayed to the user and visible withlocation.href, by usinghistory.pushState() orhistory.replaceState().
  3. The router or its surrounding framework loads the data necessary to render the new URL, and does so.

(Sometimes steps (2) and (3) are switched.) Note in particular the extra care an application needs to take to ensure that all navigations go through the router. This means that they can't easily use traditional APIs like<a> orlocation.href.

In a single-page app using the new navigation API, instead the router listens to thenavigate event. This automatically takes care of step (2), and provides a centralized place for the router and framework to perform step (3). And now the application code can use traditional navigation mechanisms, like<a> orlocation.href, without any extra code; the browser takes care of sending all of those to thenavigate event!

There's one gap remaining, which is the ability to send additional state or info along with a navigation. We solve this by introducing new APIs,navigation.navigate() andnavigation.reload(), which can be thought of as an augmented and combined versions oflocation.assign(),location.replace(), andlocation.reload(). The non-replace usage ofnavigation.navigate() is as follows:

// Navigate to a new URL, resetting the state to undefined:// (equivalent to `location.assign(url)`)navigation.navigate(url);// Use a new URL and state.navigation.navigate(url,{ state});// You can also pass info for the navigate event handler to receive:navigation.navigate(url,{ state, info});

Note how unlikehistory.pushState(),navigation.navigate() will by default perform a full navigation, e.g. scrolling to a fragment or navigating across documents. Single-page apps will usually intercept these using thenavigate event, and convert them into same-document navigations by usingevent.intercept().

Regardless of whether the navigation gets converted or not, callingnavigation.navigate() in this form will clear any future entries in the joint session history. (This includes entries coming from frame navigations, or cross-origin entries: so, it can have an impact beyond just thenavigation.entries() list.)

navigation.navigate() also takes ahistory option, which controls whether the navigation will replace the current history entry in a similar manner tolocation.replace(). It is used as follows:

// Performs a navigation to the given URL, but replace the current history entry// instead of pushing a new one.// (equivalent to `location.replace(url)`)navigation.navigate(url,{history:"replace"});// Replace the URL and state at the same time.navigation.navigate(url,{history:"replace", state});// You can still pass along info:navigation.navigate(url,{history:"replace", state, info});

Again, unlikehistory.replaceState(),navigation.navigate(url, { history: "replace" }) will by default perform a full navigation. And again, single-page apps will usually intercept these usingnavigate.

There are two other values thehistory option can take:"auto", which will usually perform a push navigation but will perform a replace navigation under special circumstances (such as when on the initialabout:blank document or when navigating to the current URL); and"push", which will always either perform a push navigation or fail if called under those special circumstances. Most developers will be fine either omitting thehistory option (which has"auto" behavior) or using"replace".

Finally, we havenavigation.reload(). This can be used as a replacement forlocation.reload(), but it also allows passinginfo andstate, which are useful when a single-page app intercepts the reload using thenavigate event:

// Just like location.reload().navigation.reload();// Leave the state as-is, but pass some info.navigation.reload({ info});// Overwrite the state with a new value.navigation.reload({ state, info});

Note that all of these methods return{ committed, finished } promise pairs,as described above for the traversal methods. That is, in the event that the navigations get converted into same-document navigations viaevent.intercept() in anavigate handler,committed will fulfill immediately, andfinished will settle based on the promise returned by the handler (if there is one). This gives your navigation call site an indication of the navigation's success or failure. (If they are non-intercepted fragment navigations, or intercepted navigations with no handler, thenfinished will fulfill immediately. And if they are non-intercepted cross-document navigations, then the returned promises, along with the entire JavaScript global environment, will disappear as the current document gets unloaded.)

Example: usinginfo

Theinfo option tonavigation.navigate() gets passed to thenavigate event handler as theevent.info property. The intended use of this value is to convey transient information about this particular navigation, such as how it happened. In this way, it's different from the persisted value retrievable usingevent.destination.getState().

One example of how this might be used is to trigger different single-page navigation renderings depending on how a certain route was reached. For example, consider a photo gallery app, where you can reach the same photo URL and state via various routes:

  • Clicking on it in a gallery view
  • Clicking "next" or "previous" when viewing another photo in the album
  • Etc.

Each of these wants a different animation at navigate time. This information doesn't make sense to store in the persistent URL or history entry state, but it's still important to communicate from the rest of the application, into the router (i.e.navigate event handler). This could be done using code such as

document.addEventListener("keydown",e=>{if(e.key==="ArrowLeft"&&hasPreviousPhoto()){navigation.navigate(getPreviousPhotoURL(),{info:{via:"go-left"}});}if(e.key==="ArrowRight"&&hasNextPhoto()){navigation.navigate(getNextPhotoURL(),{info:{via:"go-right"}});}});photoGallery.addEventListener("click",e=>{if(e.target.closest(".photo-thumbnail")){navigation.navigate(getPhotoURL(e.target),{info:{via:"gallery",thumbnail:e.target}});}});navigation.addEventListener("navigate",e=>{if(isPhotoNavigation(e)){e.intercept({asynchandler(){switch(e.info.?via){case"go-left":{awaitanimateLeft();break;}case"go-right":{awaitanimateRight();break;}case"gallery":{awaitanimateZoomFromThumbnail(e.info.thumbnail);break;}}// TODO: actually load the photo.}});}});

Note that in addition tonavigation.navigate() andnavigation.reload(), the previously-discussednavigation.back(),navigation.forward(), andnavigation.traverseTo() methods can also take ainfo option.

Example: next/previous buttons

Consider trying to code next/previous buttons that perform single-page navigations, for example in a photo gallery application. This has some interesting properties:

  • If the user presses next five times quickly, you want to be sure to skip them ahead five photos, and not to waste work on the intermediate four.
  • If the user presses next five times and then previous twice, you want to load the third photo, not wasting work on any others.
  • If the user presses previous/next and the previous/next item in their history list is the previous/next photo, then you want to just navigate them through the history list, like their browser back and forward buttons.
  • You'll want to make sure any "permalink" or "share" UI is updated ASAP after such button presses, even if the photo is still loading.

All of this basically "just works" with thenavigate event and other parts of the new navigation API. A large part of this is becausenavigate-event created single-page navigationssynchronously update the current URL, andabort any ongoing navigations. The code to handle it would look like the following:

constappState={currentPhoto:0,totalPhotos:10};constnext=document.querySelector("button#next");constprevious=document.querySelector("button#previous");constpermalink=document.querySelector("span#permalink");next.onclick=()=>{constnextPhotoInHistory=photoNumberFromURL(navigation.entries()[navigation.currentEntry.index+1]?.url);if(nextPhotoInHistory===appState.currentPhoto+1){navigation.forward();}else{navigation.navigate(`/photos/${appState.currentPhoto+1}`);}};previous.onclick=()=>{constprevPhotoInHistory=photoNumberFromURL(navigation.entries()[navigation.currentEntry.index-1]?.url);if(nextPhotoInHistory===appState.currentPhoto-1){navigation.back();}else{navigation.navigate(`/photos/${appState.currentPhoto-1}`);}};navigation.addEventListener("navigate",e=>{constphotoNumber=photoNumberFromURL(e.destination.url);if(photoNumber&&e.canIntercept){e.intercept({asynchandler(){// Synchronously update app state and next/previous/permalink UI:appState.currentPhoto=photoNumber;previous.disabled=appState.currentPhoto===0;next.disabled=appState.currentPhoto===appState.totalPhotos-1;permalink.textContent=e.destination.url;// Asynchronously update the photo, passing along the signal so that// it all gets aborted if another navigation interrupts us:constblob=await(awaitfetch(`/raw-photos/${photoNumber}.jpg`,{signal:e.signal})).blob();consturl=URL.createObjectURL(blob);document.querySelector("#current-photo").src=url;}});}});functionphotoNumberFromURL(url){if(!url){returnnull;}constresult=/\/photos/(\d+)/.exec((newURL(url)).pathname);if(result){returnNumber(result[1]);}returnnull;}

Let's look at our scenarios again:

  • If the user presses next five times quickly, you want to be sure to skip them ahead five photos, and not to waste work on the intermediate four: this works as intended, as the first four fetches (for photos 1, 2, 3, 4) get aborted via theire.signal, while the last one (for photo 5) completes.
  • If the user presses next five times and then previous twice, you want to load the third photo, not wasting work on any others: this works as intended, as the first six fetches (for photos 1, 2, 3, 4, 5, 4 again) get aborted via theire.signal, while the last one (for photo 3 again) completes.
  • If the user presses previous/next and the previous/next item in their history is the previous/next photo, then you want to just navigate them through the history list, like their browser back and forward buttons: this works as intended, due to the if statements inside theclick handlers for the next and previous buttons.
  • You'll want to make sure any "permalink" or "share" UI is updated ASAP after such button presses, even if the photo is still loading: this works as intended, since we can do this work synchronously before loading the photo.

Setting the current entry's state without navigating

We believe that in the majority of cases, single-page apps will be best served by updating their state vianavigation.navigate({ state: newState }), which goes through thenavigate event. That is, coupling state updates with navigations, which are handled by centralized router code. This is generally superior to the classic history API's model, where state (and URL) updates are done in a way disconnected from navigation, usinghistory.replaceState().

However, there is one type of case where the navigation-centric model doesn't work well. This is when you need to update the current entry's state in response to an external event, often caused by user interaction.

For example, consider a page with expandable/collapsable<details> elements. You want to store the expanded/collapsed state of these<details> elements in your navigation API state, so that when the user traverses back and forward through history, or restarts their browser, your app can read the restored navigation API state and expand the<details> elements appropriately, showing the user what they saw previously.

Creating this experience withnavigation.navigate() and thenavigate event is awkward. You would need to listen for the<details> element'stoggle event, and then donavigation.reload({ state: newState }). And then you would need to have yournavigate handler doe.intercept(),and not actually do anything, because the<details> element is already open. This can be made to work, but is not pretty.

For cases like this, where the current history entry's state needs to be updated to capture something that has already happened, we havenavigation.updateCurrentEntry({ state: newState }). We would write our above example like so:

detailsEl.addEventListener("toggle",()=>{navigation.updateCurrentEntry({state:{ ...navigation.currentEntry.getState(),detailsOpen:detailsEl.open}});});

Another example of this sort of situation is shown in the following section.

Notifications on entry disposal

EachNavigationHistoryEntry has adispose event, which occurs when that history entry is permanently evicted and unreachable. The most common scenario where this occurs is when doing a push navigation that prunes the forward history, like so:

conststartingKey=navigation.currentEntry.key;constentry1=awaitnavigation.navigate("/1").committed;entry1.addEventListener("dispose",()=>console.log(1));constentry2=awaitnavigation.navigate("/2").committed;entry2.addEventListener("dispose",()=>console.log(2));constentry3=awaitnavigation.navigate("/3").committed;entry3.addEventListener("dispose",()=>console.log(3));awaitnavigation.traverseTo(startingKey).finished;awaitnavigation.navigate("/1-b").finished;// Logs 1, 2, 3 as that branch of the tree gets pruned.

This event can be useful for cleaning up any information in secondary stores, such assessionStorage or caches, when we're guaranteed to never reach those particular history entries again.

Current entry change monitoring

Thewindow.navigation object has an event,currententrychange, which allows the application to react to any updates to thenavigation.currentEntry property. This includes both navigations that change its value to a newNavigationHistoryEntry, and calls to APIs likehistory.replaceState(),navigation.updateCurrentEntry(), or interceptednavigation.navigate(url, { history: "replace", state: newState }) calls that change its state or URL.

Unlikenavigate, this event occursafter the navigation is committed. As such, it cannot be intercepted or canceled; it's just an after-the-fact notification.

This event is mainly used for code that want to watch for navigation commits, but are separated from the part of the codebase that actually perform the navigation (since the navigating code can just usenavigation.navigate().committed). This is especially useful when migrating frompopstate andhashchange, but it could also be used by e.g. analytics packages that don't care about the navigation finishing, just committing. It can also be used to set up relevantper-entry events:

navigation.addEventListener("currententrychange",()=>{navigation.currentEntry.addEventListener("dispose",genericDisposeHandler);});

It is bestnot to use this event as part of the main routing flow of the application, which updates the main content area in response to URL changes. For that, use thenavigate event, which providesinterception and cancelation support.

The event comes with a property,from, which is the previous value ofnavigation.currentEntry. It also has anavigationType property, which is either"reload","push","replace","traverse", ornull. There are a few interesting cases to consider:

  • The entry can be the same as before, i.e.navigation.currentEntry === event.from. This happens whennavigation.updateCurrentEntry() is called, or whennavigation.reload() is converted into a same-document reload. Note that in this case the old state cannot be retrieved, i.e.event.from.getState() will return the current navigation API state.

  • event.navigationType will benull whennavigation.updateCurrentEntry() is called, since that is not a navigation (despite updatingnavigation.currentEntry).

  • During traversals, i.e. whenevent.navigationType is"traverse", you can get the delta by usingnavigation.currentEntry.index - event.from.index. (Note that this can returnNaN in"replace" cases whereevent.from.index isnull.)

Complete event sequence

Between thedispose events, thewindow.navigation events, and various promises, there's a lot of events floating around. Here's how they all come together:

  1. navigate fires onwindow.navigation.
  2. If the event is canceled usingevent.preventDefault(), then:
    1. If the process was initiated by a call to anavigation API that returns a promise, then that promise gets rejected with an"AbortError"DOMException.
  3. Otherwise:
    1. navigation.transition is created.
    2. Ifevent.intercept() was not called, orevent.intercept() was called with nocommit option, orevent.intercept() was called with acommit option ofimmediate, run the commit steps (see below).
    3. Any loading spinner UI starts, ifevent.intercept() was called.
    4. Whenevent.commit() is called, ifevent.intercept() was called with acommit option of"after-transition", run the commit steps (see below).
    5. After all the promises returned by handlers passed toevent.intercept() fulfill, or after one microtask ifevent.intercept() was not called:
      1. If the commit steps (see below) have not run yet, run them now.
      2. navigatesuccess is fired onnavigation.
      3. Any loading spinner UI stops.
      4. If the process was initiated by a call to anavigation API that returns a promise, then that promise gets fulfilled.
      5. navigation.transition.finished fulfills with undefined.
      6. navigation.transition becomes null.
    6. Alternately, if any of these promises reject:
      1. navigateerror fires onwindow.navigation with the rejection reason as itserror property.
      2. Any loading spinner UI stops.
      3. If the process was initiated by a call to anavigation API that returns a promise, then that promise gets rejected with the same rejection reason.
      4. navigation.transition.finished rejects with the same rejection reason.
      5. navigation.transition becomes null.
    7. Alternately, if the navigation getsaborted before either of those two things occur:
      1. navigateerror fires onwindow.navigation with an"AbortError"DOMException as itserror property.
      2. Any loading spinner UI stops. (But potentially restarts, or maybe doesn't stop at all, if the navigation was aborted due to a second navigation starting.)
      3. If the process was initiated by a call to anavigation API that returns a promise, then that promise gets rejected with the same"AbortError"DOMException.
      4. navigation.transition.finished rejects with the same"AbortError"DOMException.
      5. navigation.transition becomes null.
    8. One task after firingcurrententrychange,hashchange and/orpopstate fire onwindow, if applicable. (Note: this can happenbefore steps (ix)–(xi) if the promises take longer than a single task to settle.)

The commit steps are:

  1. location.href updates.
  2. navigation.currentEntry updates.
  3. currententrychange is fired onnavigation.
  4. Any now-unreachableNavigationHistoryEntry instances firedispose.
  5. The URL bar updates.

Guide for migrating from the existing history API

For web developers using the API, here's a guide to explain how you would replace usage ofwindow.history withwindow.navigation.

Performing navigations

Instead of usinghistory.pushState(state, "", url), usenavigation.navigate(url, { state }) and combine it with anavigate handler to convert the default cross-document navigation into a same-document navigation.

Instead of usinghistory.replaceState(state, "", url), usenavigation.navigate(url, { history: "replace", state }), again combined with anavigate handler. Note that if you omit the state value, i.e. if you saynavigation.navigate(url, { history: "replace" }), then this will overwrite the entry's state withundefined.

Instead of usinghistory.back() andhistory.forward(), usenavigation.back() andnavigation.forward(). Note that unlike thehistory APIs, thenavigation APIs will ignore other frames when determining where to navigate to. This means it might move through multiple entries in the joint session history, skipping over any entries that were generated purely by other-frame navigations.

Also note that if the navigation doesn't have an effect, thenavigation traversal methods will return rejected promises, unlike thehistory traversal methods which silently do nothing. You can detect this as follows:

try{awaitnavigation.back().finished;}catch(e){if(e.name==="InvalidStateError"){console.log("We weren't able to go back, because there was nothing previous in the navigation API history entries list");}}

or you can avoid it using thecanGoBack property:

if(navigation.canGoBack){awaitnavigation.back().finished;}

Note that unlike thehistory APIs, thesenavigation APIs will not go to another origin. For example, trying to callnavigation.back() when the previous document in the joint session history is cross-origin will return a rejected promise, and trigger theconsole.log() call above.

Instead of usinghistory.go(offset), usenavigation.traverseTo(key) to navigate to a specific entry. As withback() andforward(),navigation.traverseTo() will ignore other frames, and will only control the navigation of your frame. If you specifically want to reproduce the pattern of navigating by an offset (not recommended), you can use code such as the following:

constentry=navigation.entries()[navigation.currentEntry.index+offset];if(entry){awaitnavigation.traverseTo(entry.key).finished;}

Warning: back/forward are not always opposites

As a consequence of how the new navigation API is focused on manipulating the current frame,navigation.back() followed bynavigation.forward() will not always take you back to the original situation, when all the different frames on a page are considered. This sometimes is the case withhistory.back() andhistory.forward() today, due to browser bugs. But for the navigation API, it's actually intentional and expected.

This is because of the ambiguity where there can be multiple joint session history entries (representing the history state of the entire frame tree) for a givenNavigationHistoryEntry (representing the history state of your particular frame). Consider the following example joint session history where an iframe is involved:

A. https://example.com/outer#1   ┗ https://example.com/inner-1B. https://example.com/outer#2   ┗ https://example.com/inner-1C. https://example.com/outer#2   ┗ https://example.com/inner-2D. https://example.com/outer#2   ┗ https://example.com/inner-3E. https://example.com/outer#2   ┗ https://example.com/inner-4

Let's say the user is looking at joint session history entry C. If code in the outer frame callsnavigation.back(), this is a request to navigate backward to the nearest joint session history entry where the outer frame differs, i.e. to navigate to A. Then, if the outer frame in state A callsnavigation.forward(), this is a request to navigate forward to the nearest joint session history entry where the outer frame differs, i.e. to navigate to B. So starting at C, a back/forward sequence took us to B.

For similar reasons, if you started at state C, a forward/back sequence in the outer frame would fail (since there is no joint session history entry past C that differs for the outer frame). But a back/forward sequence would succeed, and take you to B per the above.

Although this behavior can be a bit counterintuitive, we think it's worth the tradeoff of havingnavigation.back() andnavigation.forward() predictably navigate your own frame, instead of sometimes only navigating some subframe (likehistory.back() andhistory.forward() can do).

In the longer term, we think the best fix for this would be to introducea mode for iframes where they don't mess with the joint session history at all. If a page used that on all its iframes, then it would never have to worry about such strange behavior.

Usingnavigate handlers

Many cases which usehistory.pushState() today can just be deleted when usingnavigation. This is because if you have a listener for thenavigate event onnavigation, that listener can useevent.intercept() to transform navigations that would normally be new-document navigations into same-document navigations. So for example, instead of

<ahref="/about">About us</a><buttononclick="doStuff()">Do stuff</a><script>window.doStuff=async()=>{awaitdoTheStuff();document.querySelector("main").innerHTML=awaitloadContentFor("/success-page");history.pushState(undefined,undefined,"/success-page");};document.addEventListener("click",asynce=>{if(e.target.localName==="a"&&shouldBeSinglePageNav(e.target.href)){e.preventDefault();document.querySelector("main").innerHTML=awaitloadContentFor(e.target.href);history.pushState(undefined,undefined,e.target.href);}});</script>

you could instead use anavigate handler like so:

<ahref="/about">About us</a><buttononclick="doStuff()">Do stuff</a><script>window.doStuff=async()=>{awaitdoTheStuff();location.href="/success-page";// or navigation.navigate("/success-page")};navigation.addEventListener("navigate",e=>{if(shouldBeSinglePageNav(e.destination.url)){e.intercept({asynchandler(){document.querySelector("main").innerHTML=awaitloadContentFor(e.destination.url);}});}});</script>

Note how in this case we don't need to usenavigation.navigate(), even though the original code usedhistory.pushState().

Attaching and using history state

To update the current entry's state, instead of usinghistory.replaceState(newState), either:

  • Usenavigation.reload({ state: newState }), combined with anavigate handler to convert the cross-document navigation into a same-document one and update the document appropriately, if your state update is meant to drive a page update.

  • Usenavigation.updateCurrentEntry({ state: newState }), if your state update is meant to capture something that's already happened to the page.

To create a new entry with the same URL but a new state value, instead of usinghistory.pushState(newState), usenavigation.navigate(navigation.currentEntry.url, { state: newState }), again combined with anavigate handler.

To read the current entry's state, instead of usinghistory.state, usenavigation.currentEntry.getState(). Note that this will give a clone of the state, so you cannot set properties on it: to update state, see above.

In general, state in thewindow.navigation API is expected to be more useful than state in thewindow.history API, because:

  • It can be introspected even for the non-current entry, e.g. usingnavigation.entries()[i].getState().
  • It is not erased by navigations that are not under the developer's control, such as fragment navigations (for which the state is copied over) and iframe navigations (which don't affect the navigation API history entry list).

This means that the patterns that are often necessary to reliably store application and UI state withwindow.history, such as maintaining a side-table or usingsessionStorage, should not be necessary withwindow.navigation.

Introspecting the history list

To see how many history entries are in the navigation API history entry list, usenavigation.entries().length, instead ofhistory.length. However, note that the semantics are different: navigation API history entries only include same-origin contiguous entries for the current frame, and so that this doesn't reflect the history before the user arrived at the current origin, or the history of iframes. We believe this will be more useful for the patterns that people want in practice.

The navigation API allows introspecting all entries in its history entry list, usingnavigation.entries(). This should replace some of the workarounds people use today with thewindow.history API for getting a sense of the history list, e.g. as described inwhatwg/html#2710.

Finally, note thathistory.length is highly non-interoperable today, in part due to the complexity of the joint session history model, and in part due to historical baggage.navigation's less complex model, and the fact that it will be developed in the modern era when there's a high focus on ensuring interoperability through web platform tests, means that using it should allow developers to avoid cross-browser issues withhistory.length.

Watching for navigations

Today there are two events related to navigations,hashchange andpopstate, both onWindow. These events are quite problematic and hard to use; see, for example,whatwg/html#5562 or otheropen issues for some discussion. MDN's fourteen-step guide to"When popstate is sent", whichdoesn't even match any browsers, is also indicative of the problem.

The new navigation API provides several replacements that subsume these events:

  • To react to and potentially intercept navigations before they complete, use thenavigate event onnavigation. See theNavigation monitoring and interception section for more details, including how the event object provides useful information that can be used to distinguish different types of navigations.

  • To react to navigations that have finished, including any asynchronous work, use thenavigatesuccess ornavigateerror events onnavigation. Note that these will only be fired after any promises returned by handlers passed to thenavigate event'sevent.intercept() method have settled.

  • To react to navigations that have committed (but not necessarily yet finished), use thecurrententrychange event onnavigation. This is the most direct counterpart topopstate andhashchange, so might be easiest to use as part of an initial migration while your app is adapting to anavigate event-centric paradigm.

  • To watch a particular entry to see when it becomes unreachable, use thatNavigationHistoryEntry'sdispose event.

Integration with the existing history API and spec

At a high level, the new navigation API is meant to be a layer on top of the HTML Standard's existing concepts. It does not require a novel model for session history, either in implementations or specifications. (Although, it will only be possible to specify it rigorously once the existing specification gets cleaned up, per the work we're doing inwhatwg/html#5767.)

This is done through:

  • Ensuring that navigation APINavigationHistoryEntrys map directly to the specification's existing history entries. (See the next section.)

  • Ensuring that traversal through the history via the new navigation API always maps to a traversal through the joint session history, i.e. a traversal which is already possible today.

Correspondence with session history entries

ANavigationHistoryEntry corresponds directly to asession history entry from the existing HTML specification. However, not every session history entry would have a correspondingNavigationHistoryEntry in a givenWindow:NavigationHistoryEntry objects only exist for session history entries which are same-origin to the current one, and contiguous within that frame.

Example: if a browsing session contains session history entries with the URLs

1. https://example.com/foo2. https://example.com/bar3. https://other.example.com/whatever4. https://example.com/baz

then, if the current entry is 4, there would only be oneNavigationHistoryEntry innavigation.entries(), corresponding to 4 itself. If the current entry is 2, then there would be twoNavigationHistoryEntrys innavigation.entries(), corresponding to 1 and 2.

To make this correspondence work, every spec-level session history entry would gain three new fields:

  • key, containing a browser-generated UUID. This is what backshistoryEntry.key.
  • id, containing a browser-generated UUID. This is what backshistoryEntry.id.
  • navigation API state, containing a JavaScript value. This is what backshistoryEntry.getState().

Note that the "navigation API state" field has no interaction with the existing "serialized state" field, which is what backshistory.state. This route was chosen for a few reasons:

  • The desired semantics of navigation API state is that it be carried over on fragment navigations, whereashistory.state is not carried over. (This is a hard blocker.)
  • A clean separation can help when a page contains code that uses bothwindow.history andwindow.navigation. That is, it's convenient that existing code usingwindow.history does not inadvertently mess with new code that does state management usingwindow.navigation.
  • Today, the serialized state of a session history entry is only exposed when that entry is the current one. The navigation API exposeshistoryEntry.getState() for all entries innavigation.entries(). This is not a security issue since all navigation API history entries are same-origin contiguous, but if we exposed the serialized state value even for non-current entries, it might break some assumptions of existing code.
  • Switching to a separate field, accessible only via thegetState() method, avoids the mutability problems discussed in#36. If the object was shared withhistory.state, those problems would be carried over.

Apart from these new fields, the session history entries which correspond toNavigationHistoryEntry objects will continue to manage other fields like document, scroll restoration mode, scroll position data, and persisted user state behind the scenes, in the usual way. The serialized state and browsing context name fields would continue to work if they were set or accessed via the usual APIs, but they don't have any manifestation inside the navigation APIs, and will be left as null by applications that avoidwindow.history andwindow.name.

TODO: should we allow global control over the default scroll restoration mode, likehistory.scrollRestoration gives? That API has legitimate use cases, and we'd like to allow people to never touchwindow.history... Discuss in#67.

Correspondence with the joint session history

The view of history which the user sees, and which is traversable with existing APIs likehistory.go(), is the joint session history.

Unlike the view of history presented bywindow.history,window.navigation only gives a view onto session history entries for the current browsing session. This view does not present the joint session history, i.e. it is not impacted by frames. Notably, this meansnavigation.entries().length is likely to be quite different fromhistory.length.

Example: consider the following setup.

  1. https://example.com/start loads.
  2. The user navigates tohttps://example.com/outer by clicking a link. This page contains an iframe withhttps://example.com/inner-start.
  3. Code onhttps://example.com/outer callshistory.pushState(null, "", "/outer-pushed").
  4. The iframe navigates tohttps://example.com/inner-end.

The joint session session history contains four entries:

A. https://example.com/startB. https://example.com/outer   ┗ https://example.com/inner-startC. https://example.com/outer-pushed   ┗ https://example.com/inner-startD. https://example.com/outer-pushed   ┗ https://example.com/inner-end

The navigation API history entry list (which also matches the existing spec's frame-specific "session history") for the outer frame looks like:

O1. https://example.com/start        (associated to A)O2. https://example.com/outer        (associated to B)O3. https://example.com/outer-pushed (associated to C and D)

The navigation API history entry list for the inner frame looks like:

I1. https://example.com/inner-start  (associated to B and C)I2. https://example.com/inner-end    (associated to D)

Traversal operates on the joint session history, which means that it's possible to impact other frames. Continuing with our previous setup, and assuming the current entry in the joint session history is D, then:

  • If code in the outer frame callsnavigation.back(), this will take us back to O2, and thus take the joint session history back to B. This means the inner frame will be navigated from/inner-end to/inner-start, changing its current navigation APINavigationHistoryEntry from I2 to I1.

  • If code in the inner frame callsnavigation.back(), this will take us back to I1, and take the joint session history back to C. (This does not impact the outer frame.) The rule here for choosing C, instead of B, is that it moves the joint session history the fewest number of steps necessary to make I1 active.

  • If code in either the inner frame or the outer frame callshistory.back(), this will take the joint session history back to C, and thus update the inner frame's current navigation APINavigationHistoryEntry from I2 to I1. (There is no impact on the outer frame.)

Integration with navigation

To understand when navigation interception interacts with the existing navigation spec, seethe navigation types appendix. In cases where interception is allowed and takes place, it is essentially equivalent to preventing the normal navigation and instead synchronously performing theURL and history update steps.

The way in which navigation interacts with session history entries generally is not meant to change; the correspondence of a session history entry to aNavigationHistoryEntry does not introduce anything novel there.

Impact on the back button and user agent UI

The navigation API doesn't change anything about how user agents implement their UI: it's really about developer-facing affordances. Users still care about the joint session history, and so that will continue to be presented in UI surfaces like holding down the back button. Similarly, pressing the back button will continue to navigate through the joint session history, potentially across origins and out of the current navigation API history list (into a new navigation API history list, on the new origin). The design discussed inthe previous section ensures that the navigation API cannot get the browser into a strange novel state that has not previously been seen in the joint session history.

One consequence of this is that when iframes are involved, the back button may navigate through the joint session history, without changing the current navigation APINavigationHistoryEntry. This is because, for the most part, the behavior of the back button is the same as that ofhistory.back(), which as the previous section showed, only impacts one frame (and thus one navigation API history entry list) at a time.

Finally, note that user agents can continue to refine their mapping of UI to joint session history to give a better experience. For example, in some cases user agents today have the back button skip joint session history entries which were created without user interaction. We expect this heuristic would continue to be applied for same-document entries generated by intercepting thenavigate event, just like it is for today'shistory.pushState().

Security and privacy considerations

Privacy-wise, this feature is neutral, due to its strict same-origin contiguous entry scoping. That is, it only exposes information which the application already has access to, just in a more convenient form. The storage of navigation API state in theNavigationHistoryEntrys is a convenience with no new privacy concerns, since that state is only accessible same-origin; that is, it provides the same power as something likesessionStorage orhistory.state.

One particular point of interest is the user-agent generatedhistoryEntry.key andhistoryEntry.id fields, which are a user-agent-generated random UUIDs. Here again the strict same-origin contiguous entry scoping prevents them from being used for cross-site tracking or similar. Specifically:

  • These UUIDs lives only for the duration of that navigation API history entry, which is at most for the lifetime of the browsing session. For example, opening a new tab (or iframe) to the same URL will generate differentkey andid values. So it is not a stable user-specific identifier.

  • This information is not accessible across sites, as a given navigation API history entry is specific to a frame and origin. That is, cross-site pages will always have differentkey andid values for allNavigationHistoryEntrys they can examine; there is no way to use history entry keys and IDs to correlate users.

(Collaborating cross-origin same-site pages can inspect each other'sNavigationHistoryEntrys usingdocument.domain, but they can also inspect every other aspect of each others' global objects.)

Security-wise, this feature has been carefully designed to give no new abilities that might be disruptive to the user or to delicate parts of browser code. See, for example, the restrictions onnavigation monitoring and interception to ensure that it does not allow trapping the user, or the discussion of how this proposaldoes not impact how browser UI presents session history.

In particular, note that navigation interception can only update the URL bar to perform single-page app navigations to the same extent ashistory.pushState() does: the destination URL must only differ from the page's current URL in path, query, or fragment components. Thus, thenavigate event does not allow URL spoofing by updating the URL bar to a cross-origin destination while providing your own origin's content.

See also theW3C TAG security and privacy questionnaire answers. We also have acorresponding specification section, which largely restates the points here but with links to specification concepts instead of explainer sections.

Future extensions

More per-entry events

We've heard some use cases for additional events onNavigationHistoryEntry objects, in addition to thedispose event. Currently we're thinking of addingnavigateto andnavigatefrom events.

We expect these would mostly be used by decentralized parts of the application's codebase, such as components, to synchronize their state with the history list. Unlike thenavigate event, these events are not cancelable. They are used only for reacting to changes, not intercepting or preventing navigations.

For example, consider a photo gallery application. One way of implementing this would be to store metadata about the photo in the correspondingNavigationHistoryEntry's state. This might look something like this:

asyncfunctionshowPhoto(photoId){// In our app, the `navigate` handler will take care of actually showing the photo and updating the content area.constentry=awaitnavigation.navigate(`/photos/${photoId}`,{state:{dateTaken:null,caption:null}}).committed;// When we navigate away from this photo, save any changes the user made.entry.addEventListener("navigatefrom",e=>{navigation.updateCurrentEntry({state:{dateTaken:document.querySelector("#photo-container > .date-taken").value,caption:document.querySelector("#photo-container > .caption").value}});});// If we ever navigate back to this photo, e.g. using the browser back button or// navigation.traverseTo(), restore the input values.entry.addEventListener("navigateto",e=>{const{ dateTaken, caption}=entry.getState();document.querySelector("#photo-container > .date-taken").value=dateTaken;document.querySelector("#photo-container > .caption").value=caption;});}

Note how we use the fulfillment value of thecommitted promise to get a handle to the entry. This is more robust than assumingnavigation.currentEntry is correct, in edge cases where one navigation can interrupt another.

Performance timeline API integration

Theperformance timeline API provides a generic framework for the browser to signal about interesting events, their durations, and their associated data viaPerformanceEntry objects. For example, cross-document navigations are done with thenavigation timing API, which uses a subclass ofPerformanceEntry calledPerformanceNavigationTiming.

It is not currently possible to measure such data for same-document navigations. This is somewhat understandable, as such navigations have always been "zero duration": they occur instantaneously when the application callshistory.pushState() orhistory.replaceState(). So measuring them isn't that interesting. But with the new navigation API,browsers know about the start time, end time, and duration of the navigation, so we can give useful performance entries.

We propose adding newPerformanceEntry instances for such same-document navigations. They would be instances of a new subclass,SameDocumentNavigationEntry, with the following properties:

  • name: the URL being navigated to. (The use ofname instead ofurl is strange, but matches all the otherPerformanceEntrys on the platform.)

  • entryType: always"same-document-navigation".

  • startTime: the time at which the navigation was initiated, i.e. when the corresponding API was called (likelocation.href ornavigation.navigate()), or when the user activated the corresponding<a> element, or submitted the corresponding<form>.

  • duration: the duration of the navigation, which is either0 forhistory.pushState()/history.replaceState(), or is the duration it takes the promises returned by handlers passed toevent.intercept() to settle, for navigations intercepted by anavigate event handler.

  • success:false if any of those promises rejected;true otherwise (including forhistory.pushState()/history.replaceState()).

To record single-page navigations usingPerformanceObserver, web developers could then use code such as the following:

constobserver=newPerformanceObserver(list=>{for(constentryoflist.getEntries()){analyticsPackage.send("same-document-navigation",entry.toJSON());}});observer.observe({type:"same-document-navigation"});

More

Check out theaddition label on our issue tracker to see more proposals we've thought about!

Stakeholder feedback

Acknowledgments

This proposal is based onan earlier revision by@tbondwilkinson, which outlined all the same capabilities in a different form. It also incorporates the ideas fromphilipwalton@'snavigation event proposal.

Thanks also to@annevk,@atscott,@chrishtr,@csreis,@dvoytenko,@esprehn,@fabiancook,@frehner,@housseindjirdeh,@jakearchibald,@matt-buland-sfdc,@MelSumner,@mmocny,@natechapin,@posva,@pshrmn,@SetTrend,@slightlyoff,@smaug----,@torgo, and@Yay295for their help in exploring this space and providing feedback.

Appendix: types of navigations

The web platform has many ways of initiating a navigation. For the purposes of the new navigation API, the following is intended to be a comprehensive list:

  • Users can trigger navigations via browser UI, including (but not necessarily limited to):
    • The URL bar
    • The back and forward buttons
    • The reload button
    • Bookmarks
  • <a> and<area> elements (both directly by users, and programmatically viaelement.click() etc.)
  • <form> elements (both directly by users, and programmatically viaelement.submit() etc.)
  • As a special case of the above, thetarget="nameOfSomeWindow" attribute on<a>,<area>, and<form> will navigate a window whosewindow.name isnameOfSomeWindow
  • <meta http-equiv="refresh">
  • TheRefresh HTTP response header
  • Thewindow.location setter, the variouslocation.* setters, and thelocation.replace(),location.assign(), andlocation.reload() methods. Note that these can be called from other frames, including cross-origin ones.
  • Callingwindow.open(url, nameOfSomeWindow) will navigate a window whosewindow.name isnameOfSomeWindow
  • history.back(),history.forward(), andhistory.go()
  • history.pushState() andhistory.replaceState()
  • navigation.back(),navigation.forward(),navigation.traverseTo()
  • navigation.navigate(),navigation.reload()
  • document.open()

Cross-document navigations are navigations where, after the navigation completes, you end up in a differentDocument object than the one you are curently on. Notably, these unload the old document, and stop running any JavaScript code from there.

Same-document navigations are ones where, after the navigation completes, you stay on the sameDocument, with the same JavaScript environment.

Most navigations are cross-document navigations. Same-document navigations can happen due to:

  • Any of the above navigation mechanisms only updating the URL's fragment, e.g.location.hash = "foo" or clicking on<a href="#bar"> or callinghistory.back() after either of those two actions
  • history.pushState() andhistory.replaceState()
  • document.open()
  • Intercepting a cross-document navigation using thenavigation object'snavigate event, and callingevent.intercept()

Here's a summary table:

TriggerCross- vs. same-documentFiresnavigate?e.userInitiatede.cancelablee.canIntercept
Browser UI (back/forward)EitherYesYesYes ❖Yes †*
Browser UI (non-back/forward
fragment change only)
SameYesYesYesYes
Browser UI (non-back/forward
other)
CrossNo
<a>/<area>/<form> (target="_self" or notarget="")EitherYesYes ‡YesYes *
<a>/<area>/<form>
(non-_selftarget="")
EitherYes ΔYes ‡YesYes *
<meta http-equiv="refresh">Either ◊YesNoYesYes *
Refresh headerEither ◊YesNoYesYes *
window.locationEitherYes ΔNoYesYes *
history.{back,forward,go}()EitherYesNoYes ❖Yes †*
history.{pushState,replaceState}()SameYesNoYesYes
navigation.{back,forward,traverseTo}()EitherYesNoYes ❖Yes †*
navigation.navigate()EitherYesNoYesYes *
navigation.reload()CrossYesNoYesYes
window.open(url, "_self")EitherYesNoYesYes *
window.open(url, name)EitherYes ΔNoYesYes *
document.open()SameNo
  • † = No if cross-document
  • ‡ = No if triggered via, e.g.,element.click()
  • * = No if the URL differs from the page's current one in components besides path/query/fragment, or is cross-origin from the current page and differs in any component besides fragment.
  • Δ = No if cross-document and initiated from across origin-domain window, e.g.frames['cross-origin-frame'].location.href = ... or<a>
  • ◊ = fragment navigations initiated by<meta http-equiv="refresh"> or theRefresh header are only same-document in some browsers:whatwg/html#6451
  • ❖ = Only in the top window, if the traversal is same-origin, and either the traversal is not user-initiated, or there is a consumable user activation in the current window.

See the discussion onrestrictions to understand the reasons why the last few columns are filled out in the way they are.

As a final note, we only fire thenavigate event when navigating to URLs that have afetch scheme. Notably, this excludes navigations to#"auto">Spec details: the above comprehensive list does not fully match when the HTML Standard'snavigate algorithm is called. In particular, HTML does not handle non-fragment-related same-document navigations through the navigate algorithm; instead it uses theURL and history update steps for those. Also, HTML calls the navigate algorithm for the initial loads of new browsing contexts as they transition from the initialabout:blank; we avoid firingnavigate for those since the initialabout:blank is such a weird case in general.

About

The new navigation API provides a new interface for navigations and session history, with a focus on single-page application navigations.

Resources

Contributing

Stars

Watchers

Forks


[8]ページ先頭

©2009-2026 Movatter.jp