diff --git a/README.md b/README.md index 7117e8c..f2eaae9 100644 --- a/README.md +++ b/README.md @@ -48,14 +48,12 @@ backButtonEl.addEventListener("click", () => { }); ``` -The new `currentchange` event fires whenever the current history entry changes, and includes the time it took for a single-page application nav to settle: +A new extension to the [performance timeline API](https://developer.mozilla.org/en-US/docs/Web/API/Performance_Timeline) allows you to calculate the duration of single-page navigations, including any asynchronous work performed by the `navigate` handler: ```js -appHistory.addEventListener("currentchange", e => { - if (e.startTime) { - analyticsPackage.sendEvent("single-page-app-nav", { loadTime: e.timeStamp - e.startTime }); - } -}); +for (const entry of performance.getEntriesByType("same-document-navigation")) { + console.log(`It took ${entry.duration} ms to navigate to the URL ${entry.name}`); +} ``` @@ -86,6 +84,7 @@ appHistory.addEventListener("currentchange", e => { - [Example: next/previous buttons](#example-nextprevious-buttons) - [Per-entry events](#per-entry-events) - [Current entry change monitoring](#current-entry-change-monitoring) + - [Performance timeline API integration](#performance-timeline-api-integration) - [Complete event sequence](#complete-event-sequence) - [Guide for migrating from the existing history API](#guide-for-migrating-from-the-existing-history-api) - [Performing navigations](#performing-navigations) @@ -225,15 +224,6 @@ The current entry can be replaced with a new entry, with a new `AppHistoryEntry` - When using the `navigate` event to [convert a cross-document replace navigation into a same-document navigation](#navigation-monitoring-and-interception). -In both cases, for same-document navigations a `currentchange` event will fire on `appHistory`: - -```js -appHistory.addEventListener("currentchange", () => { - // appHistory.current has changed: either to a completely new entry (with new key), - // or it has been replaced (keeping the same key). -}); -``` - ### Inspection of the app history list In addition to the current entry, the entire list of app history entries can be inspected, using `appHistory.entries()`, which returns an array of `AppHistoryEntry` instances. (Recall that all app history entries are same-origin contiguous entries for the current frame, so this is not a security issue.) @@ -500,7 +490,7 @@ This does not yet solve all accessibility problems with single-page navigations. Continuing with the theme of `respondWith()` 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 app history API. See the example in the [Current entry change monitoring](#current-entry-change-monitoring) section for one way this could look; other possibilities include integrating into the existing [performance APIs](https://w3c.github.io/performance-timeline/). +In particular, analytics frameworks would be able to consume this information from the browser in a way that works across all applications using the app history API. See the discussion on [performance timeline API integration](#performance-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 the `navigate` event is fired. Or, you could imagine vendor-specific or application-specific measurements like [Cumulative Layout Shift](https://web.dev/cls/) or React hydration time being reset after such navigations begin. @@ -875,34 +865,34 @@ await appHistory.navigate("/1-b"); This can be useful for cleaning up any information in secondary stores, such as `sessionStorage` or caches, when we're guaranteed to never reach those particular history entries again. -### Current entry change monitoring +### Performance timeline API integration -**Although the basic idea of an event for when `appHistory.current` changes will probably survive, much of this section needs revamping. See the several discussions linked below.** +The [performance timeline API](https://w3c.github.io/performance-timeline/) provides a generic framework for the browser to signal about interesting events, their durations, and their associated data via `PerformanceEntry` objects. For example, cross-document navigations are done with the [navigation timing API](https://w3c.github.io/navigation-timing/), which uses a subclass of `PerformanceEntry` called `PerformanceNavigationTiming`. -The `window.appHistory` object has an event, `currentchange`, which allows the application to react to any updates to the `appHistory.current` property. This includes both navigations that change its value, and calls to `appHistory.navigate()` that change its state or URL. This cannot be intercepted or canceled, as it occurs after the navigation has already happened; it's just an after-the-fact notification. +Until now, it has not been 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 calls `history.pushState()` or `history.replaceState()`. So measuring them isn't that interesting. But with the app history API, [browsers know about the start time, end time, and duration of the navigation](#measuring-standardized-single-page-navigations), so we can give useful performance entries. -This event has one special property, `event.startTime`, which for [same-document](#appendix-types-of-navigations) navigations gives the value of `performance.now()` when the navigation was initiated. This includes for navigations that were originally [cross-document](#appendix-types-of-navigations), like the user clicking on ``, but were transformed into same-document navigations by [navigation interception](#navigation-monitoring-and-interception). For completely cross-document navigations, `startTime` will be `null`. +The `PerformanceEntry` instances for such same-document navigations are instances of a new subclass, `SameDocumentNavigationEntry`, with the following properties: -"Initiated" means either when the corresponding API was called (like `location.href` or `appHistory.navigate()`), or when the user activated the corresponding `` element, or submitted the corresponding `
`. This allows it to be used for determining the overall time from navigation initiation to navigation completion, including the time it took for a promise passed to `e.respondWith()` to settle: +- `name`: the URL being navigated to. (The use of `name` instead of `url` is strange, but matches all the other `PerformanceEntry`s on the platform.) -```js -appHistory.addEventListener("currentchange", e => { - if (e.startTime) { - const loadTime = e.timeStamp - e.startTime; +- `entryType`: always `"same-document-navigation"`. - document.querySelector("#status-bar").textContent = `Loaded in ${loadTime} ms!`; - analyticsPackage.sendEvent("single-page-app-nav", { loadTime }); - } else { - document.querySelector("#status-bar").textContent = `Welcome to this document!`; - } -}); -``` +- `startTime`: the time at which the navigation was initiated, i.e. when the corresponding API was called (like `location.href` or `appHistory.navigate()`), or when the user activated the corresponding `` element, or submitted the corresponding ``. + +- `duration`: the duration of the navigation, which is either `0` for `history.pushState()`/`history.replaceState()`, or is the duration it takes the promise passed to `event.respondWith()` to settle, for navigations intercepted by a `navigate` event handler. -_TODO: reconsider cross-document navigations. There will only be one (the initial load of the page); should we even fire this event in that case? (That's [#31](https://github.com/WICG/app-history/issues/31).) Could we give `startTime` a useful value there, if we do?_ +- `success`: `false` if the promise passed to `event.respondWith()` rejected; `true` otherwise (including for `history.pushState()`/`history.replaceState()`). -_TODO: this property-on-the-event design is not good and does not work, per [#59](https://github.com/WICG/app-history/issues/59). We should probably integrate with the performance timeline APIs instead? Discuss in [#33](https://github.com/WICG/app-history/issues/33)._ +To record single-page navigations using [`PerformanceObserver`](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver), web developers could then use code such as the following: -_TODO: Add a non-analytics examples, similar to how people use `popstate` today. [#14](https://github.com/WICG/app-history/issues/14)_ +```js +const observer = new PerformanceObserver(list => { + for (const entry of list.getEntries()) { + analyticsPackage.send("same-document-navigation", entry.toJSON()); + } +}); +observer.observe({ type: "same-document-navigation" }); +``` ### Complete event sequence @@ -915,7 +905,6 @@ Between the per-`AppHistoryEntry` events and the `window.appHistory` events, as 1. `appHistory.current` fires `navigatefrom`. 1. `location.href` updates. 1. `appHistory.current` updates. `appHistory.transition` is created. - 1. `currentchange` fires on `window.appHistory`. 1. `appHistory.current` fires `navigateto`. 1. Any now-unreachable `AppHistoryEntry` instances fire `dispose`. 1. The URL bar updates. @@ -927,6 +916,7 @@ Between the per-`AppHistoryEntry` events and the `window.appHistory` events, as 1. If the process was initiated by a call to an `appHistory` API that returns a promise, then that promise gets fulfilled. 1. `appHistory.transition.finished` fulfills with undefined. 1. `appHistory.transition` becomes null. + 1. Queue a new `SameDocumentNavigationEntry` indicating success. 1. Alternately, if the promise passed to `event.respondWith()` rejects: 1. `appHistory.current` fires `finish`. 1. `navigateerror` fires on `window.appHistory` with the rejection reason as its `error` property. @@ -934,6 +924,7 @@ Between the per-`AppHistoryEntry` events and the `window.appHistory` events, as 1. If the process was initiated by a call to an `appHistory` API that returns a promise, then that promise gets rejected with the same rejection reason. 1. `appHistory.transition.finished` rejects with the same rejection reason. 1. `appHistory.transition` becomes null. + 1. Queue a new `SameDocumentNavigationEntry` indicating failure. 1. Alternately, if the navigation gets [aborted](#aborted-navigations) before either of those two things occur: 1. (`appHistory.current` never fires the `finish` event.) 1. `navigateerror` fires on `window.appHistory` with an `"AbortError"` `DOMException` as its `error` property. @@ -941,6 +932,7 @@ Between the per-`AppHistoryEntry` events and the `window.appHistory` events, as 1. If the process was initiated by a call to an `appHistory` API that returns a promise, then that promise gets rejected with the same `"AbortError"` `DOMException`. 1. `appHistory.transition.finished` rejects with the same `"AbortError"` `DOMException`. 1. `appHistory.transition` becomes null. + 1. Queue a new `SameDocumentNavigationEntry` indicating failure. For more detailed analysis, including specific code examples, see [this dedicated document](./interception-details.md). @@ -1094,7 +1086,7 @@ The app history API provides several replacements that subsume these events: - To react to and potentially intercept navigations before they complete, use the `navigate` event on `appHistory`. See the [Navigation monitoring and interception](#navigation-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 completed, use the `currentchange` event on `appHistory`. See the [Current entry change monitoring](#current-entry-change-monitoring) section for more details, including an example of how to use it to determine how long a same-document navigation took. +- To react to navigations that have completed, use the `navigatesuccess` or `navigateerror` events on `appHistory`. Note that these will only be fired asynchronously, after any handlers passed to the `navigate` event's `event.respondWith()` method have completed. - To watch a particular entry to see when it's navigated to, navigated from, or becomes unreachable, use that `AppHistoryEntry`'s `navigateto`, `navigatefrom`, and `dispose` events. See the [Per-entry events](#per-entry-events) section for more details. @@ -1350,7 +1342,6 @@ interface AppHistory : EventTarget { attribute EventHandler onnavigate; attribute EventHandler onnavigatesuccess; attribute EventHandler onnavigateerror; - attribute EventHandler oncurrentchange; }; [Exposed=Window] @@ -1434,13 +1425,8 @@ interface AppHistoryDestination { }; [Exposed=Window] -interface AppHistoryCurrentChangeEvent : Event { - constructor(DOMString type, optional AppHistorycurrentChangeEventInit eventInit = {}); - - readonly attribute DOMHighResTimeStamp? startTime; -}; - -dictionary AppHistoryCurrentChangeEventInit : EventInit { - DOMHighResTimeStamp? startTime = null; +interface SameDocumentNavigationEntry : PerformanceEntry { + readonly attribute boolean success; + [Default] object toJSON(); }; ``` diff --git a/interception-details.md b/interception-details.md index 175a377..080d29b 100644 --- a/interception-details.md +++ b/interception-details.md @@ -23,7 +23,6 @@ Synchronously: 1. `appHistory.current` fires `navigatefrom`. 1. `location.href` updates. 1. `appHistory.current` and `appHistory.transition` update. -1. `currentchange` fires on `window.appHistory`. 1. `appHistory.current` fires `navigateto`. 1. Any now-unreachable `AppHistoryEntry` instances fire `dispose`. 1. The `console.log()` outputs `"#foo"` @@ -38,6 +37,7 @@ After the promise settles in one microtask: 1. `navigatesuccess` is fired on `appHistory`. 1. `appHistory.transition.finished` fulfills. 1. `appHistory.transition` updates back to null. +1. A new `SameDocumentNavigationEntry` indicating success is [queued](https://w3c.github.io/performance-timeline/#queue-a-performanceentry), with a short (but nonzero) duration. ### No interception @@ -50,7 +50,7 @@ location.hash = "#foo"; console.log(location.hash); ``` -This is the same as the previous case. +This is the same as the previous case, except that the `SameDocumentNavigationEntry` has zero duration. ### Cancelation @@ -89,7 +89,6 @@ Synchronously: 1. `appHistory.current` fires `navigatefrom`. 1. `location.href` updates. 1. `appHistory.current` and `appHistory.transition` update. -1. `currentchange` fires on `window.appHistory`. 1. `appHistory.current` fires `navigateto`. 1. Any now-unreachable `AppHistoryEntry` instances fire `dispose`. 1. The `console.log()` outputs `"/foo"`. @@ -106,6 +105,7 @@ After the promise fulfills in ten seconds: 1. `appHistory.transition.finished` fulfills. 1. `appHistory.transition` updates back to null. 1. Any loading spinner UI stops. +1. A new `SameDocumentNavigationEntry` indicating success is [queued](https://w3c.github.io/performance-timeline/#queue-a-performanceentry), with a ten-second duration. ### Delayed failure @@ -124,7 +124,6 @@ Synchronously: 1. `appHistory.current` fires `navigatefrom`. 1. `location.href` updates. 1. `appHistory.current` and `appHistory.transition` update. -1. `currentchange` fires on `window.appHistory`. 1. `appHistory.current` fires `navigateto`. 1. Any now-unreachable `AppHistoryEntry` instances fire `dispose`. 1. The `console.log()` outputs `"/foo"`. @@ -141,6 +140,7 @@ After the promise rejects in ten seconds: 1. `appHistory.transition.finished` rejects, with the `new Error("bad")` exception. 1. `appHistory.transition` updates back to null. 1. Any loading spinner UI stops. +1. A new `SameDocumentNavigationEntry` indicating failure is [queued](https://w3c.github.io/performance-timeline/#queue-a-performanceentry), with a ten-second duration. Note: any unreachable `AppHistoryEntry`s disposed as part of the synchronous block do not get resurrected. @@ -174,7 +174,6 @@ Synchronously: 1. `appHistory.current` fires `navigatefrom`. 1. `location.href` updates to `"/foo"`. 1. `appHistory.current` updates to a new `AppHistoryEntry` representing `/foo`, and `appHistory.transition` updates to represent the transition from the starting URL to `/foo`. -1. `currentchange` fires on `window.appHistory`. 1. `appHistory.current` fires `navigateto`. 1. Any now-unreachable `AppHistoryEntry` instances fire `dispose`. 1. The `console.log()` outputs `"/foo"`. @@ -188,12 +187,12 @@ After one second: 1. `navigateerror` fires on `window.appHistory`, with an `"AbortError"` `DOMException`. 1. `appHistory.transition.finished` rejects with that `"AbortError"` `DOMException`. +1. The `event.signal` for the navigation to `/foo` fires an `"abort"` event. +1. A new `SameDocumentNavigationEntry` indicating failure is [queued](https://w3c.github.io/performance-timeline/#queue-a-performanceentry), with a one-second duration. 1. `appHistory.current` fires `navigatefrom`. 1. `location.href` updates to `"/bar"`. 1. `appHistory.current` changes to a new `AppHistoryEntry` representing `/bar`, and `appHistory.transition` updates to represent the transition from `/foo` to `/bar` (_not_ from the starting URL to `/bar`). -1. `currentchange` fires on `window.appHistory`. 1. `appHistory.current` fires `navigateto`. -1. The `event.signal` for the navigation to `/foo` fires an `"abort"` event. 1. Any loading spinner UI stops, but then restarts. (Or maybe it never stops.) 1. The second `console.log()` outputs `"/bar"`. @@ -211,6 +210,7 @@ After eleven seconds: 1. `appHistory.transition.finished` fulfills. 1. `appHistory.transition` updates back to null. 1. Any loading spinner UI stops. +1. A new `SameDocumentNavigationEntry` indicating success is [queued](https://w3c.github.io/performance-timeline/#queue-a-performanceentry), with a ten-second duration. ### Trying to interrupt a slow navigation, but the `navigate` handler doesn't care @@ -239,7 +239,6 @@ Synchronously: 1. `appHistory.current` fires `navigatefrom`. 1. `location.href` updates to `"/foo"`. 1. `appHistory.current` updates to a new `AppHistoryEntry` representing `/foo`, and `appHistory.transition` updates to represent the transition from the starting URL to `/foo`. -1. `currentchange` fires on `window.appHistory`. 1. `appHistory.current` fires `navigateto`. 1. Any now-unreachable `AppHistoryEntry` instances fire `dispose`. 1. The `console.log()` outputs `"/foo"`. @@ -252,12 +251,13 @@ Asynchronously but basically immediately: After one second: 1. `navigateerror` fires on `window.appHistory`, with an `"AbortError"` `DOMException`. +1. `appHistory.transition.finished` rejects with that `"AbortError"` `DOMException`. +1. The `event.signal` for the navigation to `/foo` fires an `"abort"` event. (But nobody is listening to it.) +1. A new `SameDocumentNavigationEntry` indicating failure is [queued](https://w3c.github.io/performance-timeline/#queue-a-performanceentry), with a one-second duration. 1. `appHistory.current` fires `navigatefrom`. 1. `location.href` updates to `"/bar"`. 1. `appHistory.current` changes to a new `AppHistoryEntry` representing `/bar`, and `appHistory.transition` updates to represent the transition from `/foo` to `/bar` (_not_ from the starting URL to `/bar`). -1. `currentchange` fires on `window.appHistory`. 1. `appHistory.current` fires `navigateto`. -1. The `event.signal` for the navigation to `/foo` fires an `"abort"` event. (But nobody is listening to it.) 1. Any loading spinner UI stops, but then restarts. (Or maybe it never stops.) 1. The second `console.log()` outputs `"/bar"`. @@ -275,3 +275,4 @@ After eleven seconds: 1. `appHistory.transition.finished` fulfills. 1. `appHistory.transition` updates back to null. 1. Any loading spinner UI stops. +1. A new `SameDocumentNavigationEntry` indicating success is [queued](https://w3c.github.io/performance-timeline/#queue-a-performanceentry), with a ten-second duration.