From 35108eb14cf212715fa0f7dacea71354973f7993 Mon Sep 17 00:00:00 2001 From: Domenic Denicola Date: Wed, 3 Mar 2021 14:05:40 -0500 Subject: [PATCH 1/6] Revamp when navigate fires, is cancelable, and is respond-able * Closes #53 by allowing respondWith() for same-document back/forward navigations (but not yet allowing preventDefault(); that's #32). * Closes #51 by transitioning from event.sameOrigin to event.canRespond, which has updated semantics. * Does not fire navigate events for document.open(), as per some initial implementation investigations that's more trouble than it's worth. * Adds an example for special back/forward handling in the navigate event, per https://github.com/WICG/app-history/issues/53#issuecomment-789931864. * Makes the appendix table a bit more precise about userInitiated, and expands it to cover cancelable and canRespond. --- README.md | 140 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 92 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 90cf2b8..731a263 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ An application or framework's centralized router can use the `navigate` event to ```js appHistory.addEventListener("navigate", e => { - if (!e.sameOrigin || e.hashChange) { + if (!e.canRespond || e.hashChange) { return; } @@ -70,6 +70,8 @@ appHistory.addEventListener("currentchange", e => { - [Navigation through the app history list](#navigation-through-the-app-history-list) - [Navigation monitoring and interception](#navigation-monitoring-and-interception) - [Example: replacing navigations with single-page app navigations](#example-replacing-navigations-with-single-page-app-navigations) + - [Example: async transitions with special back/forward handling](#example-async-transitions-with-special-backforward-handling) + - [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) - [Example: single-page app "redirects"](#example-single-page-app-redirects) @@ -243,12 +245,14 @@ The most interesting event on `window.appHistory` is the one which allows monito The event object has several useful properties: +- `cancelable` (inherited from `Event`): indicates whether `preventDefault()` is allowed to cancel this navigation. + +- `canRespond`: indicates whether `respondWith()`, discussed below, is allowed for this navigation. + - `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.push(...)`, etc.). Note that this will _not_ be `true` when you use mechanisms such as `button.onclick = () => appHistory.push(...)`; 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`; if it is not, then its `state` will be `null`. -- `sameOrigin`: a convenience boolean indicating whether the navigation is same-origin, and thus will stay in the same app history or not. (I.e., this is `(new URL(e.destination.url)).origin === self.origin`.) - - `hashChange`: a boolean, indicating whether or not this is a same-document [fragment navigation](https://html.spec.whatwg.org/#scroll-to-fragid). - `formData`: a [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) object containing form submission data, or `null` if the navigation is not a form submission. @@ -257,26 +261,9 @@ The event object has several useful properties: - `signal`: an [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) which can be monitored for when the navigation gets aborted. -Note that you can check if the navigation will be [same-document or cross-document](#appendix-types-of-navigations) via `event.destination.sameDocument`. - -The event is not fired in the following cases: - -- User-initiated cross-document navigations via browser UI, such as the URL bar, back/forward button, or bookmarks. -- User-initiated same-document navigations via the browser back/forward buttons. (See discussion in [#32](https://github.com/WICG/app-history/issues/32).) - -Whenever it is fired, the event is cancelable via `event.preventDefault()`, which prevents the navigation from going through. To name a few notable examples of when the event is fired, i.e. when you can intercept the navigation: - -- User-initiated navigations via `` and `
` elements, including both same-document fragment navigations and cross-document navigations. -- Programmatically-initiated navigations, via mechanisms such as `location.href = ...` or `aElement.click()`, including both same-document fragment navigations and cross-document navigations. -- Programmatically-initiated same-document navigations initiated via `appHistory.push()`, `appHistory.update()`, or their old counterparts `history.pushState()` and `history.replaceState()`. - -(This list is not comprehensive; for the complete list of possible navigations on the web platform, see [this appendix](#appendix-types-of-navigations).) - -Although the ability to intercept cross-document navigations, especially cross-origin ones, might be surprising, in general it doesn't grant additional power. That is, web developers can already intercept `` `click` events, or modify their code that would set `location.href`, even if the destination URL is cross-origin. - -On the other hand, cases that are not interceptable today, where the user is the instigator of the navigation through browser UI, are not interceptable. This ensures that web applications cannot trap the user on a given document by intercepting cross-document URL bar navigations, or disable the user's back/forward buttons. Note that, in the case of the back/forward buttons, even same-document interception isn't allowed. This is because it's easy to generate same-document app history entries (e.g., using `appHistory.push()` or `history.pushState()`); if we allowed intercepting traversal to them, this would allow sites to disable the back/forward buttons. We realize that this limits the utility of the `navigate` event in some cases, and are open to exploring other ways of combating abuse: see the discussion in [#32](https://github.com/WICG/app-history/issues/32). +Note that you can check if the navigation will be [same-document or cross-document](#appendix-types-of-navigations) via `event.destination.sameDocument`, and you can check whether the navigation is to an already-existing app history entry (i.e. is a back/forward navigation) via `appHistory.entries.includes(event.destination)`. -Additionally, the event has a special method `event.respondWith(promise)`. If called for a same-origin navigation, this will: +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. @@ -295,8 +282,8 @@ The following is the kind of code you might see in an application or framework's ```js appHistory.addEventListener("navigate", e => { - // Don't intercept cross-origin navigations; let the browser handle those normally. - if (!e.sameOrigin) { + // Some navigations, e.g. cross-origin navigations, we cannot intercept. Let the browser handle those normally. + if (!e.canRespond) { return; } @@ -340,6 +327,61 @@ Note how this example responds to various types of navigations: Notice also how by passing through the `AbortSignal` found in `e.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: + +```js +appHistory.addEventListener("navigate", e => { + // As before. + if (!e.canRespond || e.hashChange) { + return; + } + + if (myFramework.currentPage) { + await myFramework.currentPage.transitionOut(); + } + + const isBackForward = appHistory.entries.includes(event.destination); + let { key } = event.destination; + + if (isBackForward && myFramework.previousPages.has(key)) { + await myFramework.previousPages.get(key).transitionIn(); + } else { + // This will probably result in myFramework storing the rendered page in myFramework.previousPages. + await myFramework.renderPage(event.destination); + } +}); +``` + +#### Restrictions on firing, canceling, and responding + +There are many types of navigations a given page can experience; see [this appendix](#appendix-types-of-navigations) for a full breakdown. Some of these need to be treated specially for the purposes of the navigate event. + +First, the following navigations **will not fire `navigate`** at all: + +- User-initiated [cross-document](#appendix-types-of-navigations) navigations via browser UI, such as the URL bar, back/forward button, or bookmarks. +- [`document.open()`](https://developer.mozilla.org/en-US/docs/Web/API/Document/open), which can strip off the fragment from the current document's URL. + +User-initiated navigations of this sort are outside the scope of the webpage, and can never be intercepted or prevented, even if they are to same-origin documents. On the other hand, we do allow the page to intercept user-initiated _same_-document navigations via browser UI, e.g. if the user changes the fragment component in the URL bar. + +As for `document.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 app history API should never be using `document.open()`. + +Second, the following navigations **cannot be canceled** using `event.preventDefault()`, and as such will have `event.cancelable` equal to false: + +- User-initiated navigations (cross- or same-document) via the browser's back/forward buttons. + +This is important to avoid abusive pages trapping the user by disabling their back button. Note that adding a same-origin restriction would not help here: imagine a user which navigates to `https://evil-in-disguise.example/`, and then clicks a link to `https://evil-in-disguise.example/2`. If `https://evil-in-disguise.example/2` were allowed to cancel same-origin browser back button navigations, they have effectively disabled the user's back button. + +We're discussing this restriction in [#32](https://github.com/WICG/app-history/issues/32), as it does hurt some use cases, and we'd like to soften it in some way. + +Finally, the following navigations **cannot be replaced with same-document navigations** by using `event.respondWith()`, and as such will have `event.canRespond` 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.) +- Any [cross-document](#appendix-types-of-navigations) 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 still 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 `` `click` events, or modify their code that would set `location.href`, even if the destination URL is cross-origin. + #### Accessibility benefits of standardized single-page navigations The `navigate` event's `event.respondWith()` method provides a helpful convenience for implementing single-page navigations, as discussed above. But beyond that, providing a direct signal to the browser as to the duration and outcome of a single-page navigation has benefits for accessibility technology users. @@ -1035,28 +1077,30 @@ Most navigations are cross-document navigations. Same-document navigations can h Here's a summary table: -|Trigger|Cross- vs. same-document|Fires `navigate`?|`event.userInitiated`| -|-------|------------------------|-----------------|---------------------| -|Browser UI (back/forward)|Either|No|—| -|Browser UI (non-back/forward fragment change only)|Always same|Yes|Yes| -|Browser UI (non-back/forward other)|Always cross|No|—| -|``/``|Either|Yes|Yes| -|``|Either|Yes|Yes| -|``|Either|Yes|No| -|`Refresh` header|Either|Yes|No| -|`window.location`|Either|Yes|No| -|`window.open(url, name)`|Either|Yes|No| -|`history.{back,forward,go}()`|Either|Yes|No| -|`history.{pushState,replaceState}()`|Always same|Yes|No| -|`appHistory.{back,forward,navigateTo}()`|Either|Yes|No| -|`appHistory.{push,update}()`|Either|Yes|No| -|`document.open()`|Always same|Yes|No| - -Some notes: - -- Regarding the "No" values for the "Fires `navigate`?" column: recall that we need to disallow abusive pages from trapping the user by intercepting the back button. To get notified of such non-interceptable cases after the fact, you can use `currentchange`. - -- Today it is not possible to intercept cases where other frames or windows programatically navigate your frame, e.g. via `window.open(url, name)`, or `history.back()` happening in a subframe. So, firing the `navigate` event and allowing interception in such cases represents a new capability. We believe this is OK, but will report back after some implementation experience. See also [#32](https://github.com/WICG/app-history/issues/32). +|Trigger|Cross- vs. same-document|Fires `navigate`?|`e.userInitiated`|`e.cancelable`|`e.canRespond`| +|-------|------------------------|-----------------|-----------------|--------------|--------------| +|Browser UI (back/forward)|Either|Yes|Yes|No|Yes †*| +|Browser UI (non-back/forward fragment change only)|Always same|Yes|Yes|Yes|Yes *| +|Browser UI (non-back/forward other)|Always cross|No|—|—|—| +|``/``|Either|Yes|Yes ‡|Yes|Yes *| +|``|Either|Yes|Yes ‡|Yes|Yes *| +|``|Either|Yes|No|Yes|Yes *| +|`Refresh` header|Either|Yes|No|Yes|Yes *| +|`window.location`|Either|Yes|No|Yes|Yes *| +|`window.open(url, name)`|Either|Yes|No|Yes|Yes *| +|`history.{back,forward,go}()`|Either|Yes|No|Yes|Yes †*| +|`history.{pushState,replaceState}()`|Always same|Yes|No|Yes|Yes *| +|`appHistory.{back,forward,navigateTo}()`|Either|Yes|No|Yes|Yes †*| +|`appHistory.{push,update}()`|Either|Yes|No|Yes|Yes *| +|`document.open()`|Always same|No|—|—|—| + +- † = No if cross-document +- ‡ = No if triggered via, e.g., `element.click()` +- \* = No if the URL differs in components besides path/fragment/query + +See the discussion on [restrictions](#restrictions-on-firing-canceling-and-responding) to understand the reasons why the last few columns are filled out in the way they are. + +Note that today it is not possible to intercept cases where other frames or windows programatically navigate your frame, e.g. via `window.open(url, name)`, or `history.back()` happening in a subframe. So, firing the `navigate` event and allowing interception in such cases represents a new capability. We believe this is OK, but will report back after some implementation experience. _Spec details: the above comprehensive list does not fully match when the HTML Standard's [navigate](https://html.spec.whatwg.org/#navigate) algorithm is called. In particular, HTML does not handle non-fragment-related same-document navigations through the navigate algorithm; instead it uses the [URL and history update steps](https://html.spec.whatwg.org/#url-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 initial `about:blank`; our current thinking is that `appHistory` should just not work on the initial `about:blank` so we can avoid that edge case._ @@ -1122,8 +1166,8 @@ callback AppHistoryPushCallback = AppHistoryPushCallbackOptions (); interface AppHistoryNavigateEvent : Event { constructor(DOMString type, optional AppHistoryNavigateEventInit eventInit = {}); + readonly attribute boolean canRespond; readonly attribute boolean userInitiated; - readonly attribute boolean sameOrigin; readonly attribute boolean hashChange; readonly attribute AppHistoryEntry destination; readonly attribute AbortSignal signal; @@ -1134,8 +1178,8 @@ interface AppHistoryNavigateEvent : Event { }; dictionary AppHistoryNavigateEventInit : EventInit { + boolean canRespond = false; boolean userInitiated = false; - boolean sameOrigin = false; boolean hashChange = false; required AppHistoryEntry destination; required AbortSignal signal; From fb31045992d6f8bb67c88739ab3ec63f2871b8eb Mon Sep 17 00:00:00 2001 From: Domenic Denicola Date: Wed, 3 Mar 2021 15:42:59 -0500 Subject: [PATCH 2/6] Try to make confusing paragraph less confusing --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 731a263..2d82736 100644 --- a/README.md +++ b/README.md @@ -363,7 +363,7 @@ First, the following navigations **will not fire `navigate`** at all: - User-initiated [cross-document](#appendix-types-of-navigations) navigations via browser UI, such as the URL bar, back/forward button, or bookmarks. - [`document.open()`](https://developer.mozilla.org/en-US/docs/Web/API/Document/open), which can strip off the fragment from the current document's URL. -User-initiated navigations of this sort are outside the scope of the webpage, and can never be intercepted or prevented, even if they are to same-origin documents. On the other hand, we do allow the page to intercept user-initiated _same_-document navigations via browser UI, e.g. if the user changes the fragment component in the URL bar. +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 displaying `https://example.com/foo` and the user edits the URL bar to read `https://example.com/bar` and presses enter. On the other hand, we do allow the page to intercept user-initiated _same_-document navigations via browser UI, e.g. if the the browser is currently displaying `https://example.com/foo` and the user edits the URL bar to read `https://example.com/foo#fragment` and presses enter. As for `document.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 app history API should never be using `document.open()`. From 657457502a915797494bb6d6b46dfabe5e930745 Mon Sep 17 00:00:00 2001 From: Domenic Denicola Date: Wed, 3 Mar 2021 15:44:59 -0500 Subject: [PATCH 3/6] Clarify the "how this example responds" --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2d82736..965c841 100644 --- a/README.md +++ b/README.md @@ -313,10 +313,10 @@ 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/state updates (via `history.pushState()`, `appHistory.update()`, etc.): +- Same-document URL or state updates (via `history.pushState()` or `history.replaceState()`): 1. Send the information about the URL/state update to `doSinglePageAppNav()`, which will use it to modify the current document. 1. 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: +- Cross-document normal navigations (including those via `appHistory.push()` or `appHistory.update()`): 1. Prevent the browser handling, which would unload the document and create a new one from the network. 1. Instead, send the information about the navigation to `doSinglePageAppNav()`, which will use it to modify the current document. 1. After that UI update is done, potentially asynchronously, notify the app and the browser about the navigation's success or failure. From 2294c3621d1cd45f3abadf8f6250557f65a15e11 Mon Sep 17 00:00:00 2001 From: Domenic Denicola Date: Wed, 3 Mar 2021 17:10:03 -0500 Subject: [PATCH 4/6] Fix cannot be canceled condition --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 965c841..2998b8a 100644 --- a/README.md +++ b/README.md @@ -369,7 +369,7 @@ As for `document.open()`, it is a terrible legacy API with lots of strange side Second, the following navigations **cannot be canceled** using `event.preventDefault()`, and as such will have `event.cancelable` equal to false: -- User-initiated navigations (cross- or same-document) via the browser's back/forward buttons. +- User-initiated same-document navigations via the browser's back/forward buttons. This is important to avoid abusive pages trapping the user by disabling their back button. Note that adding a same-origin restriction would not help here: imagine a user which navigates to `https://evil-in-disguise.example/`, and then clicks a link to `https://evil-in-disguise.example/2`. If `https://evil-in-disguise.example/2` were allowed to cancel same-origin browser back button navigations, they have effectively disabled the user's back button. From 56d3a1b71000f91da4a5af716ac8e62ff603c20d Mon Sep 17 00:00:00 2001 From: Domenic Denicola Date: Thu, 4 Mar 2021 15:27:35 -0500 Subject: [PATCH 5/6] Wrap in respondWith --- README.md | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 2998b8a..14651d2 100644 --- a/README.md +++ b/README.md @@ -338,19 +338,21 @@ appHistory.addEventListener("navigate", e => { return; } - if (myFramework.currentPage) { - await myFramework.currentPage.transitionOut(); - } - - const isBackForward = appHistory.entries.includes(event.destination); - let { key } = event.destination; - - if (isBackForward && myFramework.previousPages.has(key)) { - await myFramework.previousPages.get(key).transitionIn(); - } else { - // This will probably result in myFramework storing the rendered page in myFramework.previousPages. - await myFramework.renderPage(event.destination); - } + e.respondWith((async () => { + if (myFramework.currentPage) { + await myFramework.currentPage.transitionOut(); + } + + const isBackForward = appHistory.entries.includes(event.destination); + let { key } = event.destination; + + if (isBackForward && myFramework.previousPages.has(key)) { + await myFramework.previousPages.get(key).transitionIn(); + } else { + // This will probably result in myFramework storing the rendered page in myFramework.previousPages. + await myFramework.renderPage(event.destination); + } + })()); }); ``` From 39a26b751e11d078f4676131958ce37129a92055 Mon Sep 17 00:00:00 2001 From: Domenic Denicola Date: Thu, 4 Mar 2021 15:42:22 -0500 Subject: [PATCH 6/6] Note non-interop --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 14651d2..68b689b 100644 --- a/README.md +++ b/README.md @@ -1086,8 +1086,8 @@ Here's a summary table: |Browser UI (non-back/forward other)|Always cross|No|—|—|—| |``/``|Either|Yes|Yes ‡|Yes|Yes *| |``|Either|Yes|Yes ‡|Yes|Yes *| -|``|Either|Yes|No|Yes|Yes *| -|`Refresh` header|Either|Yes|No|Yes|Yes *| +|``|Either ◊|Yes|No|Yes|Yes *| +|`Refresh` header|Either ◊|Yes|No|Yes|Yes *| |`window.location`|Either|Yes|No|Yes|Yes *| |`window.open(url, name)`|Either|Yes|No|Yes|Yes *| |`history.{back,forward,go}()`|Either|Yes|No|Yes|Yes †*| @@ -1099,6 +1099,7 @@ Here's a summary table: - † = No if cross-document - ‡ = No if triggered via, e.g., `element.click()` - \* = No if the URL differs in components besides path/fragment/query +- ◊ = fragment navigations initiated by `` or the `Refresh` header are only same-document in some browsers: [whatwg/html#6451](https://github.com/whatwg/html/issues/6451) See the discussion on [restrictions](#restrictions-on-firing-canceling-and-responding) to understand the reasons why the last few columns are filled out in the way they are.