-
Notifications
You must be signed in to change notification settings - Fork 26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Revamp when navigate fires, is cancelable, and is respond-able #56
Changes from 1 commit
35108eb
fb31045
6574575
2294c36
56d3a1b
39a26b7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -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 `<a>`, 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 `<a>` and `<form>` 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 `<a>` `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 => { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think you need async here?
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh, the problem here is I need to wrap this all in respondWith. |
||||||
// 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; | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hypernit, |
||||||
|
||||||
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. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I may be reading this wrong, but you list yet below on line 372 it says also the table appears to list browser back/forward as firing am I confused or are these at odds? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch. Listing cross-document browser back/forward navigations as non-cancelable is redundant since they won't fire navigate at all. Fixed. As for the table, it says "Yes †*" where - † = No if cross-document (and * = No if the URL differs in components besides path/fragment/query). I struggled with these "Yes" table entries; most of them are "Yes except in specific rarer circumstances", but that cell has more serious exceptions. Any suggestions are welcome. |
||||||
- [`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. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This "even if they are to same-origin documents" only refers to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, this is meant to cover cases like:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ah okay I got confused by same-origin same-document again :) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What about clicking a cross-document link? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, that's fine and interceptable. I guess I messed up by abbreviating "User-initiated cross-document navigations via browser UI" to "User-initiated navigations of this sort". This is meant to be only about the first bullet. |
||||||
|
||||||
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 `<a>` `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|—| | ||||||
|`<a>`/`<area>`|Either|Yes|Yes| | ||||||
|`<form>`|Either|Yes|Yes| | ||||||
|`<meta http-equiv="refresh">`|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|—|—|—| | ||||||
|`<a>`/`<area>`|Either|Yes|Yes ‡|Yes|Yes *| | ||||||
|`<form>`|Either|Yes|Yes ‡|Yes|Yes *| | ||||||
|`<meta http-equiv="refresh">`|Either|Yes|No|Yes|Yes *| | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Drive-by nitpick: meta Refresh and the Refresh header will always be cross-document, since they're effectively a reload. Same with form submission. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not true in Firefox, it turns out! https://boom-bath.glitch.me/meta-refresh.html fun times. |
||||||
|`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; | ||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had to think for a while before remembering that you can still cancel these navigations, even if you can't respond to them. Maybe an example? Otherwise some people may wonder why this is even needed
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
https://github.com/WICG/app-history#example-single-page-app-redirects is kind of that example. I split the examples so the most central ones were first and the less central ones were after the explanatory subsections, but maybe that was mistake.