Skip to content
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

Merged
merged 6 commits into from
Mar 4, 2021
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 92 additions & 48 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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) {
Copy link
Contributor

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

Copy link
Collaborator Author

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.

return;
}

Expand Down Expand Up @@ -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 => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you need async here?

Suggested change
appHistory.addEventListener("navigate", e => {
appHistory.addEventListener("navigate", async (e) => {

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hypernit, let {key} is more common in the JS code I see, but maybe this is more common in specs?


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.
Copy link

@frehner frehner Mar 3, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may be reading this wrong, but you list user-initiated cross-document navigations via... back/forward button as something that will never fire navigate here.

yet below on line 372 it says user-initiated navigations (cross- or same-document) via browser's back/foward buttons which implies that navigate is fired (else there would be no need to mention event.preventDefault() ?)

also the table appears to list browser back/forward as firing navigate.

am I confused or are these at odds?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This "even if they are to same-origin documents" only refers to document.open() right? If so, move it into that bullet, otherwise it's confusing because it looks like it's also possible in bullet 1

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, this is meant to cover cases like:

  1. You are on https://example.com/foo
  2. The user types https://example.com/bar in the URL bar

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah okay I got confused by same-origin same-document again :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about clicking a cross-document link?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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.
Expand Down Expand Up @@ -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 *|
Copy link
Collaborator

Choose a reason for hiding this comment

The 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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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._

Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down