diff --git a/README.md b/README.md index 43a1dd7..d79cfdb 100644 --- a/README.md +++ b/README.md @@ -74,11 +74,12 @@ appHistory.addEventListener("currentchange", e => { - [Restrictions on firing, canceling, and responding](#restrictions-on-firing-canceling-and-responding) - [Accessibility benefits of standardized single-page navigations](#accessibility-benefits-of-standardized-single-page-navigations) - [Measuring standardized single-page navigations](#measuring-standardized-single-page-navigations) + - [Aborted navigations](#aborted-navigations) + - [Transitional time after navigation interception](#transitional-time-after-navigation-interception) - [Example: handling failed navigations](#example-handling-failed-navigations) - - [Example: single-page app "redirects"](#example-single-page-app-redirects) + - [Example: single-page app redirects and guards](#example-single-page-app-redirects-and-guards) - [Example: cross-origin affiliate links](#example-cross-origin-affiliate-links) - - [Aborted navigations](#aborted-navigations) - - [New navigation APIs](#new-navigation-apis) + - [New navigation API](#new-navigation-api) - [Example: using `navigateInfo`](#example-using-navigateinfo) - [Example: next/previous buttons](#example-nextprevious-buttons) - [Per-entry events](#per-entry-events) @@ -251,6 +252,8 @@ The event object has several useful properties: - `canRespond`: indicates whether `respondWith()`, discussed below, is allowed for this navigation. +- `type`: either `"push"`, `"replace"`, or `"traversal"`. + - `userInitiated`: a boolean indicating whether the navigation is user-initiated (i.e., a click on an ``, or a form submission) or application-initiated (e.g. `location.href = ...`, `appHistory.navigate(...)`, etc.). Note that this will _not_ be `true` when you use mechanisms such as `button.onclick = () => appHistory.navigate(...)`; the user interaction needs to be with a real link or form. See the table in the [appendix](#appendix-types-of-navigations) for more details. - `destination`: an `AppHistoryEntry` containing the information about the destination of the navigation. Note that this entry might or might not yet be in `window.appHistory.entries`. @@ -268,11 +271,13 @@ Note that you can check if the navigation will be [same-document or cross-docume The event object has a special method `event.respondWith(promise)`. This works only under certain circumstances, e.g. it cannot be used on cross-origin navigations. ([See below](#restrictions-on-firing-canceling-and-responding) for full details.) It will: - Cancel any fragment navigation or cross-document navigation. -- Immediately update the URL bar, `location.href`, and `appHistory.current`, but with `appHistory.current.finished` set to false. +- Immediately update the URL bar, `location.href`, and `appHistory.current`. +- Create the [`appHistory.transition`](#transitional-time-after-navigation-interception) object. - Wait for the promise to settle. Once it does: - - Update `appHistory.current.finished` to true and fire `finish` on `appHistory.current`. - - If it rejects, fire `navigateerror` on `appHistory`. - - If it fulfills, fire `navigatesuccess` on `appHistory`. + - Fire `finish` on `appHistory.current`. + - If it rejects, fire `navigateerror` on `appHistory` and reject `appHistory.transition.finished`. + - If it fulfills, fire `navigatesuccess` on `appHistory` and fulfill `appHistory.transition.finished`. + - Set `appHistory.transition` to null. - For the duration of the 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 the promise to settle in order to update its URL/history-displaying UI (such as URL bar or back button), or to update `location.href` and `appHistory.current`. @@ -346,10 +351,9 @@ appHistory.addEventListener("navigate", e => { await myFramework.currentPage.transitionOut(); } - const isBackForward = appHistory.entries.includes(e.destination); let { key } = e.destination; - if (isBackForward && myFramework.previousPages.has(key)) { + if (e.type === "traversal" && myFramework.previousPages.has(key)) { await myFramework.previousPages.get(key).transitionIn(); } else { // This will probably result in myFramework storing the rendered page in myFramework.previousPages. @@ -418,9 +422,47 @@ This isn't a complete panacea: in particular, such metrics are gameable by bad a - We hope that most analytics vendors will come to automatically track `navigate` 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 in [the example above](#example-replacing-navigations-with-single-page-app-navigations), the `navigate` event come with an `event.signal` property that is an [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). This signal will transition to the aborted state if any of the following occur before the promise passed to `respondWith()` settles: + +- The user presses their browser's stop button (or similar UI, such as the Esc 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 if `respondWith()` is not called. This means it cannot be used to observe the interruption of a [cross-document](#appendix-types-of-navigations) navigation, if that cross-document navigation was left alone and not converted into a same-document navigation by using `respondWith()`. Similarly, `window.stop()` will not impact `respondWith()`-derived same-document navigations. + +Whether and how the application responds to this abort is up to the web developer. In many cases, such as in [the example above](#example-replacing-navigations-with-single-page-app-navigations), this will automatically work: by passing the `event.signal` through to any `AbortSignal`-consuming APIs like `fetch()`, those APIs will get aborted, and the resulting `"AbortError"` `DOMException` propagated to be the rejection reason for the promise passed to `respondWith()`. But it's possible to ignore it completely, as in the following example: + +```js +appHistory.addEventListener("navigate", event => { + event.respondWith((async () => { + await new Promise(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 seconds `document.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 seconds `document.body.innerHTML` will be updated to show the original destination URL. + +See [the companion document](./interception-details.md#trying-to-interrupt-a-slow-navigation-but-the-navigate-handler-doesnt-care) for full details on exactly what happens in such scenarios. + +### Transitional time after navigation interception + +Although calling `event.respondWith()` to [intercept a navigation](#navigation-monitoring-and-interception) and convert it into a single-page navigation immediately and synchronously updates `location.href`, `appHistory.current`, and the URL bar, the promise passed to `respondWith()` might not settle for a while. During this transitional time, before the promise settles and the `navigatesuccess` or `navigateerror` events fire, an additional API is available, `appHistory.transition`. It has the following properties: + +- `type`: either `"replace"`, `"push"`, or `"traversal"` indicating what type of navigation this is +- `previous`: the `AppHistoryEntry` that was the current one before the transition +- `finished`: a promise which fulfills with undefined when the `navigatesuccess` event fires on `appHistory`, or rejects with the corresponding error when the `navigateerror` event fires on `appHistory` +- `rollback()`: a promise-returning method which allows easy rollback to the `previous` entry + +Note that `appHistory.transition.rollback()` is not the same as `appHistory.back()`: for example, if the user goes back two steps, then `appHistory.rollback()` will actually go forward to steps. Similarly, it handles rolling back replace navigations by doing another replace back to the previous URL and app history state, and it rolls back push navigations by actually removing the entry that was previously pushed instead of leaving it there for the user to reach by pressing their forward button. + #### Example: handling failed navigations -To handle failed navigations, you can listen to the `navigateerror` event and perform application-specific interactions. This event will be an [`ErrorEvent`](https://developer.mozilla.org/en-US/docs/Web/API/ErrorEvent) so you can retrieve the promise's rejection reason. For example, to display an error, you could do something like: +To handle failed single-page navigations, i.e. navigations where the promise passed to `event.respondWith()` eventually rejects, you can listen to the `navigateerror` event and perform application-specific interactions. This event will be an [`ErrorEvent`](https://developer.mozilla.org/en-US/docs/Web/API/ErrorEvent) so you can retrieve the promise's rejection reason. For example, to display an error, you could do something like: ```js appHistory.addEventListener("navigateerror", e => { @@ -429,41 +471,43 @@ appHistory.addEventListener("navigateerror", e => { }); ``` +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. + To perform a rollback to where the user was previously, with a toast notification, you could do something like: ```js appHistory.addEventListener("navigateerror", e => { - // Our `navigate` handler will convert this into a same-document navigation. - appHistory.back(); + const attemptedURL = location.href; - showErrorToast(`Could not load ${location.href}: ${e.message}`); + await appHistory.transition.rollback(); + showErrorToast(`Could not load ${attemptedURL}: ${e.message}`); }); ``` -#### Example: single-page app "redirects" +#### Example: single-page app redirects and guards -**This example is likely to get updated per discussions in [#5](https://github.com/WICG/app-history/issues/5).** - -A common scenario in web applications with a client-side router is to perform a "redirect" to a login page if you try to access login-guarded information. The following is an example of how one could implement this using the `navigate` event: +A common scenario in web applications with a client-side router is to perform a "redirect" to a login page if you try to access login-guarded information. Similarly, there's often a desire for some routes to be off-limits. The following is an example of how one could implement these scenarios using the `navigate` event, including determining asynchronously which action is needed: ```js appHistory.addEventListener("navigate", e => { - const url = new URL(e.destination.url); - if (url.pathname === "/user-profile") { - // Cancel the navigation: - e.preventDefault(); + e.respondWith((async () => { + const result = await determineAction(e.destination); - // Do another navigation to /login, which will fire a new `navigate` event: - location.href = "/login"; - } + if (result.type === "redirect") { + await appHistory.transition.rollback(); + await appHistory.navigate(result.destinationURL, { state: result.destinationState }); + } else if (result.type === "disallow") { + throw new Error(result.disallowReason); + } else { + // ... + } + })()); }); ``` -_TODO: should these be combined into a helper method like `e.redirect("/login")`?_ - In practice, this might be hidden behind a full router framework, e.g. the Angular framework has a notion of [route guards](https://angular.io/guide/router#preventing-unauthorized-access). Then, the framework would be the one listening to the `navigate` event, looping through its list of registered route guards to figure out the appropriate reaction. -NOTE: if you combine this example with the previous one, it's important that this route guard event handler be installed before the general single-page navigation event handler. Additionally, you'd want to either insert a call to `e.stopImmediatePropagation()` in this example, or a check of `e.defaultPrevented` in that example, to stop the other `navigate` event handler from proceeding with the canceled navigation. In practice, we expect there to be one large application- or framework-level `navigate` event handler, which would take care of ensuring that route guards happen before the other parts of the router logic, and preventing that logic from executing. +Note how this kind of setup composes well with `navigateerror` handlers from the previous section. For example, consider a situation where the page starts at `/a`, tries to navigate to `/b`, which the `navigate` handler redirects to `/c`, but then when that redirection reaches the `navigate` handler, it says that `/c` is disallowed. In this scenario, a display-an-error `navigateerror` handler would show an error while the URL bar reads `/c`. And a rollback-and-show-toast error handler would roll back from `/c` to `/a` (since the redirect process itself already removed `/b` from consideration). #### Example: cross-origin affiliate links @@ -483,35 +527,6 @@ appHistory.addEventListener("navigate", e => { }); ``` -_TODO: it feels like this should be less disruptive than a cancel-and-perform-new-navigation; it's just a tweak to the outgoing navigation. Using the same code as the previous example feels wrong. See discussion in [#5](https://github.com/WICG/app-history/issues/5)._ - -#### Aborted navigations - -As shown in [the example above](#example-replacing-navigations-with-single-page-app-navigations), the `navigate` event come with an `event.signal` property that is an [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). This signal will transition to the aborted state if any of the following occur before the promise passed to `respondWith()` settles: - -- The user presses their browser's stop button (or similar UI, such as the Esc 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 if `respondWith()` is not called. This means it cannot be used to observe the interruption of a [cross-document](#appendix-types-of-navigations) navigation, if that cross-document navigation was left alone and not converted into a same-document navigation by using `respondWith()`. Similarly, `window.stop()` will not impact `respondWith()`-derived same-document navigations. - -Whether and how the application responds to this abort is up to the web developer. In many cases, such as in [the example above](#example-replacing-navigations-with-single-page-app-navigations), this will automatically work: by passing the `event.signal` through to any `AbortSignal`-consuming APIs like `fetch()`, those APIs will get aborted, and the resulting `"AbortError"` `DOMException` propagated to be the rejection reason for the promise passed to `respondWith()`. But it's possible to ignore it completely, as in the following example: - -```js -appHistory.addEventListener("navigate", event => { - event.respondWith((async () => { - await new Promise(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 seconds `document.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 seconds `document.body.innerHTML` will be updated to show the original destination URL. - -See [the companion document](./interception-details.md#trying-to-interrupt-a-slow-navigation-but-the-navigate-handler-doesnt-care) for full details on exactly what happens in such scenarios. - ### New navigation API In a single-page app using `window.history`, the typical flow is: @@ -620,7 +635,7 @@ appHistory.addEventListener("navigate", e => { }); ``` -Note that in addition to `appHistory.navigate()`, the [previously-discussed](#navigation-through-the-app-history-list) `appHistory.back()`, `appHistory.forward()`, and `appHistory.goTo()` methods can also take a `navigateInfo` option. +Note that in addition to `appHistory.navigate()`, the [previously-discussed](#navigation-through-the-app-history-list) `appHistory.back()`, `appHistory.forward()`, `appHistory.goTo()`, and `appHistory.transition.rollback()` methods can also take a `navigateInfo` option. #### Example: next/previous buttons @@ -799,29 +814,33 @@ Between the per-`AppHistoryEntry` events and the `window.appHistory` events, as 1. Otherwise: 1. `appHistory.current` fires `navigatefrom`. 1. `location.href` updates. - 1. `appHistory.current` updates. `appHistory.current.finished` is `false`. + 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. 1. Any loading spinner UI starts, if a promise was passed to the `navigate` handler's `event.respondWith()`. 1. After the promise passed to `event.respondWith()` fulfills, or after one microtask if `event.respondWith()` was not called: - 1. `appHistory.current.finished` changes to `true`. 1. `appHistory.current` fires `finish`. 1. `navigatesuccess` is fired on `appHistory`. 1. Any loading spinner UI stops. 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. Alternately, if the promise passed to `event.respondWith()` rejects: - 1. `appHistory.current.finished` changes to `true`. 1. `appHistory.current` fires `finish`. 1. `navigateerror` fires on `window.appHistory` with the rejection reason as its `error` property. 1. Any loading spinner UI stops. 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. Alternately, if the navigation gets [aborted](#aborted-navigations) before either of those two things occur: - 1. `appHistory.current.finished` stays `false`, and `appHistory.current` never fires the `finish` event. + 1. (`appHistory.current` never fires the `finish` event.) 1. `navigateerror` fires on `window.appHistory` with an `"AbortError"` `DOMException` as its `error` property. 1. 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.) 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. For more detailed analysis, including specific code examples, see [this dedicated document](./interception-details.md). @@ -1091,6 +1110,7 @@ This proposal is based on [an earlier revision](https://github.com/slightlyoff/h Thanks also to [@annevk](https://github.com/annevk), +[@atscott](https://github.com/atscott), [@chrishtr](https://github.com/chrishtr), [@csreis](https://github.com/csreis), [@dvoytenko](https://github.com/dvoytenko), @@ -1186,6 +1206,7 @@ partial interface Window { [Exposed=Window] interface AppHistory : EventTarget { readonly attribute AppHistoryEntry current; + readonly attribute AppHistoryTransition? transition; readonly attribute FrozenArray entries; readonly attribute boolean canGoBack; readonly attribute boolean canGoForward; @@ -1203,12 +1224,20 @@ interface AppHistory : EventTarget { attribute EventHandler oncurrentchange; }; +[Exposed=Window] +interface AppHistoryTransition { + readonly attribute AppHistoryNavigationType type; + readonly attribute AppHistoryEntry previous; + readonly attribute Promise finished; + + Promise rollback(optional AppHistoryNavigationOptions = {}); +}; + [Exposed=Window] interface AppHistoryEntry : EventTarget { readonly attribute DOMString key; readonly attribute USVString url; readonly attribute long long index; - readonly attribute boolean finished; readonly attribute boolean sameDocument; any getState(); @@ -1219,6 +1248,12 @@ interface AppHistoryEntry : EventTarget { attribute EventHandler ondispose; }; +enum AppHistoryNavigationType { + "push", + "replace", + "traversal" +}; + dictionary AppHistoryNavigationOptions { any navigateInfo; }; @@ -1232,6 +1267,7 @@ dictionary AppHistoryNavigateOptions : AppHistoryNavigationOptions { interface AppHistoryNavigateEvent : Event { constructor(DOMString type, optional AppHistoryNavigateEventInit eventInit = {}); + readonly attribute AppHistoryNavigationType type; readonly attribute boolean canRespond; readonly attribute boolean userInitiated; readonly attribute boolean hashChange; diff --git a/interception-details.md b/interception-details.md index 53a8439..175a377 100644 --- a/interception-details.md +++ b/interception-details.md @@ -22,7 +22,7 @@ Synchronously: 1. `navigate` fires on `window.appHistory`. 1. `appHistory.current` fires `navigatefrom`. 1. `location.href` updates. -1. `appHistory.current` updates. `appHistory.current.finished` is `false`. +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`. @@ -34,9 +34,10 @@ Asynchronously but basically immediately: After the promise settles in one microtask: -1. `appHistory.current.finished` changes to `true`. 1. `appHistory.current` fires `finish`. 1. `navigatesuccess` is fired on `appHistory`. +1. `appHistory.transition.finished` fulfills. +1. `appHistory.transition` updates back to null. ### No interception @@ -87,7 +88,7 @@ Synchronously: 1. `navigate` fires on `window.appHistory`. 1. `appHistory.current` fires `navigatefrom`. 1. `location.href` updates. -1. `appHistory.current` updates. `appHistory.current.finished` is `false`. +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`. @@ -100,9 +101,10 @@ Asynchronously but basically immediately: After the promise fulfills in ten seconds: -1. `appHistory.current.finished` changes to `true`. 1. `appHistory.current` fires `finish`. 1. `navigatesuccess` is fired on `appHistory`. +1. `appHistory.transition.finished` fulfills. +1. `appHistory.transition` updates back to null. 1. Any loading spinner UI stops. ### Delayed failure @@ -121,7 +123,7 @@ Synchronously: 1. `navigate` fires on `window.appHistory`. 1. `appHistory.current` fires `navigatefrom`. 1. `location.href` updates. -1. `appHistory.current` updates. `appHistory.current.finished` is `false`. +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`. @@ -134,9 +136,10 @@ Asynchronously but basically immediately: After the promise rejects in ten seconds: -1. `appHistory.current.finished` changes to `true`. 1. `appHistory.current` fires `finish`. 1. `navigateerror` is fired on `window.appHistory`, with the `new Error("bad")` exception. +1. `appHistory.transition.finished` rejects, with the `new Error("bad")` exception. +1. `appHistory.transition` updates back to null. 1. Any loading spinner UI stops. Note: any unreachable `AppHistoryEntry`s disposed as part of the synchronous block do not get resurrected. @@ -170,7 +173,7 @@ Synchronously: 1. `navigate` fires on `window.appHistory`. 1. `appHistory.current` fires `navigatefrom`. 1. `location.href` updates to `"/foo"`. -1. `appHistory.current` updates to a new `AppHistoryEntry` representing `/foo`. `appHistory.current.finished` is `false`. +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`. @@ -184,9 +187,10 @@ 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. `appHistory.current` fires `navigatefrom`. 1. `location.href` updates to `"/bar"`. -1. `appHistory.current` changes to a new `AppHistoryEntry` representing `/bar`. `appHistory.current.finished` is `false`. +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. @@ -202,9 +206,10 @@ After eleven seconds: 1. The `setTimeout()` promise inside the navigation to `/bar` fulfills. 1. Since `e.signal.aborted` is `false`, the code inside the `navigate` handler updates `document.body.innerHTML`. -1. `appHistory.current.finished` changes to `true`. 1. `appHistory.current` fires `finish`. 1. `navigatesuccess` is fired on `appHistory`. +1. `appHistory.transition.finished` fulfills. +1. `appHistory.transition` updates back to null. 1. Any loading spinner UI stops. ### Trying to interrupt a slow navigation, but the `navigate` handler doesn't care @@ -233,7 +238,7 @@ Synchronously: 1. `navigate` fires on `window.appHistory`. 1. `appHistory.current` fires `navigatefrom`. 1. `location.href` updates to `"/foo"`. -1. `appHistory.current` updates to a new `AppHistoryEntry` representing `/foo`. `appHistory.current.finished` is `false`. +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`. @@ -249,7 +254,7 @@ After one second: 1. `navigateerror` fires on `window.appHistory`, with an `"AbortError"` `DOMException`. 1. `appHistory.current` fires `navigatefrom`. 1. `location.href` updates to `"/bar"`. -1. `appHistory.current` changes to a new `AppHistoryEntry` representing `/bar`. `appHistory.current.finished` is `false`. +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.) @@ -265,7 +270,8 @@ After eleven seconds: 1. The `setTimeout()` promise inside the navigation to `/bar` fulfills. 1. The code inside the `navigate` handler updates the body to `"navigated to /bar"`. (Now it matches `location.href` again.) -1. `appHistory.current.finished` changes to `true`. 1. `appHistory.current` fires `finish`. 1. `navigatesuccess` is fired on `appHistory`. +1. `appHistory.transition.finished` fulfills. +1. `appHistory.transition` updates back to null. 1. Any loading spinner UI stops.