Skip to content

Commit

Permalink
Simplify navigate event firing for now
Browse files Browse the repository at this point in the history
Closes #178 by implementing the conclusion there, of firing non-cancelable navigate events for all traversals.
  • Loading branch information
domenic committed Nov 4, 2021
1 parent c9e16f1 commit 67a052c
Show file tree
Hide file tree
Showing 2 changed files with 28 additions and 41 deletions.
31 changes: 18 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -464,28 +464,33 @@ There are many types of navigations a given page can experience; see [this appen
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.
- [Cross-document](#appendix-types-of-navigations) navigations initiated from other [cross origin-domain](https://html.spec.whatwg.org/multipage/origin.html#same-origin-domain) windows, e.g. via `window.open(url, nameOfYourWindow)`, or clicking on `<a href="..." target="nameOfYourWindow">`
- [`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 [cross-document](#appendix-types-of-navigations) navigations via non-back/forward browser UI, such as the URL bar, bookmarks, or the reload button
- [Cross-document](#appendix-types-of-navigations) navigations initiated from other cross origin windows, e.g. via `window.open(url, nameOfYourWindow)`, or clicking on `<a href="..." target="nameOfYourWindow">`
- [`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
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.
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. (We do fire a `navigate` event for browser-UI back/forward buttons; see more discussion below.)

Similarly, cross-document navigations initiated from other windows are not something that can be intercepted today, and for security reasons, we don't want to introduce the ability for your origin to mess with the operation of another origin's scripts. (Even if the purpose of those scripts is to navigate your frame.)

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 same-document navigations via the browser's back/forward buttons.
- User-initiated traversals via the browser's back/forward buttons (either same- or cross-document)
- Programmatic traversals via `history.back()`/`history.forward()`/`history.go()`
- Programmatic traversals via `appHistory.back()`/`appHistory.forward()`/`appHistory.go()`
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 would like to make these cancelable in the future. However, we need to take care when doing so:
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.
- Canceling user-initiated traversals can be abused to trap 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.
- Both user-initiated and programmatic traversals of this sort are hard to intercept for technical reasons, as doing so can require cross-process communication.

See discussion in [#32](https://github.com/WICG/app-history/issues/32) about how we can make user-initiated traversals cancelable in a safe way, and [#178](https://github.com/WICG/app-history/issues/178) for the general discussion of loosening the cancelability restrictions over time.

Finally, the following navigations **cannot be replaced with same-document navigations** by using `event.transitionWhile()`, and as such will have `event.canTransition` 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 programmatically-initiated [cross-document](#appendix-types-of-navigations) back/forward navigations. (Recall that _user_-initiated cross-document navigations will not fire the `navigate` event at all.) Transitioning two adjacent history entries from cross-document to same-document has unpleasant ripple effects on web application and browser implementation architecture.
- 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.

Expand Down Expand Up @@ -1349,18 +1354,17 @@ Here's a summary table:
|Trigger|Cross- vs. same-document|Fires `navigate`?|`e.userInitiated`|`e.cancelable`|`e.canTransition`|
|-------|------------------------|-----------------|-----------------|--------------|--------------|
|Browser UI (back/forward,<br>same-document)|Same|Yes|Yes|No|Yes|
|Browser UI (back/forward,<br>cross-document)|Cross|No|—|—|—|
|Browser UI (back/forward)|Either|Yes|Yes|No ❖|Yes †*|
|Browser UI (non-back/forward<br>fragment change only)|Same|Yes|Yes|Yes|Yes|
|Browser UI (non-back/forward<br>other)|Cross|No|—|—|—|
|`<a>`/`<area>`/`<form>` (`target="_self"` or no `target=""`)|Either|Yes|Yes ‡|Yes|Yes *|
|`<a>`/`<area>`/`<form>`<br>(non-`_self` `target=""`)|Either|Yes Δ|Yes ‡|Yes|Yes *|
|`<meta http-equiv="refresh">`|Either ◊|Yes|No|Yes|Yes *|
|`Refresh` header|Either ◊|Yes|No|Yes|Yes *|
|`window.location`|Either|Yes Δ|No|Yes|Yes *|
|`history.{back,forward,go}()`|Either|Yes|No|Yes|Yes †*|
|`history.{back,forward,go}()`|Either|Yes|No|No ❖|Yes †*|
|`history.{pushState,replaceState}()`|Same|Yes|No|Yes|Yes|
|`appHistory.{back,forward,goTo}()`|Either|Yes|No|Yes|Yes †*|
|`appHistory.{back,forward,goTo}()`|Either|Yes|No|No ❖|Yes †*|
|`appHistory.navigate()`|Either|Yes|No|Yes|Yes *|
|`appHistory.reload()`|Cross|Yes|No|Yes|Yes|
|`window.open(url, "_self")`|Either|Yes|No|Yes|Yes *|
Expand All @@ -1370,8 +1374,9 @@ Here's a summary table:
- † = No if cross-document
- ‡ = No if triggered via, e.g., `element.click()`
- \* = No if the URL differs from the page's current one in components besides path/query/fragment, or is cross-origin from the current page and differs in any component besides fragment.
- Δ = No if cross-document and initiated from a [cross origin-domain](https://html.spec.whatwg.org/multipage/origin.html#same-origin-domain) window, e.g. `frames['cross-origin-frame'].location.href = ...` or `<a target="cross-origin-frame">`
- Δ = No if cross-document and initiated from a cross origin window, e.g. `frames['cross-origin-frame'].location.href = ...` or `<a target="cross-origin-frame">`
- ◊ = fragment navigations initiated by `<meta http-equiv="refresh">` or the `Refresh` header are only same-document in some browsers: [whatwg/html#6451](https://github.com/whatwg/html/issues/6451)
- ❖ = We would like to make these cancelable in the future, after additional implementation and spec work: see [#178](https://github.com/WICG/app-history/issues/178) and [#32](https://github.com/WICG/app-history/issues/32).
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.
Expand Down
38 changes: 10 additions & 28 deletions spec.bs
Original file line number Diff line number Diff line change
Expand Up @@ -1212,7 +1212,8 @@ The <dfn attribute for="AppHistoryDestination">sameDocument</dfn> getter steps a
1. Set |destination|'s [=AppHistoryDestination/index=] to &minus;1.
1. Set |destination|'s [=AppHistoryDestination/state=] to null.
1. Set |destination|'s [=AppHistoryDestination/is same document=] to true if |destinationEntry|'s [=session history entry/document=] is equal to |appHistory|'s [=relevant global object=]'s [=associated Document=]; otherwise false.
1. Return the result of performing the [=inner navigate event firing algorithm=] given |appHistory|, "{{AppHistoryNavigationType/traverse}}", |event|, |destination|, |userInvolvement|, and null.
1. Let |result| be the result of performing the [=inner navigate event firing algorithm=] given |appHistory|, "{{AppHistoryNavigationType/traverse}}", |event|, |destination|, |userInvolvement|, and null.
1. Assert: |result| is true (traversals are never cancelable).
</div>

<div algorithm="fire a push or replace navigate event">
Expand All @@ -1235,15 +1236,13 @@ The <dfn attribute for="AppHistoryDestination">sameDocument</dfn> getter steps a

1. [=AppHistory/Promote the upcoming navigation to ongoing=] given |appHistory| and |destination|'s [=AppHistoryDestination/key=].
1. Let |ongoingNavigation| be |appHistory|'s [=AppHistory/ongoing navigation=].
1. Let |document| be |appHistory|'s [=relevant global object=]'s [=associated document=].
1. If |document| <a spec="HTML">can have its URL rewritten</a> to |destination|'s [=AppHistoryDestination/URL=], and either |destination|'s [=AppHistoryDestination/is same document=] is true or |navigationType| is not "{{AppHistoryNavigationType/traverse}}", then initialize |event|'s {{AppHistoryNavigateEvent/canTransition}} to true. Otherwise, initialize it to false.
1. If either |userInvolvement| is not "<code>[=user navigation involvement/browser UI=]</code>" or |navigationType| is not "{{AppHistoryNavigationType/traverse}}", then initialize |event|'s {{Event/cancelable}} to true. Otherwise, initialize it to false.
1. If both |event|'s {{AppHistoryNavigateEvent/canTransition}} and |event|'s {{Event/cancelable}} are false, then return true.
<p class="note">In this case we are definitely performing a cross-document navigation or traversal. We don't clean up |ongoingNavigation| however, since we might end up [=finalizing with an aborted navigation error=] before the current {{Document}} unloads.
1. If |appHistory| [=AppHistory/has entries and events disabled=], then:
1. If |ongoingNavigation| is not null, then [=app history API navigation/clean up=] |ongoingNavigation|.
<p class="note">In this case the [=app history API navigation/committed promise=] and [=app history API navigation/finished promise=] will never fulfill, since we never create {{AppHistoryEntry}}s for the initial `about:blank` {{Document}} so we have nothing to [=resolve=] them with.
1. Return true.
1. Let |document| be |appHistory|'s [=relevant global object=]'s [=associated document=].
1. If |document| <a spec="HTML">can have its URL rewritten</a> to |destination|'s [=AppHistoryDestination/URL=], and either |destination|'s [=AppHistoryDestination/is same document=] is true or |navigationType| is not "{{AppHistoryNavigationType/traverse}}", then initialize |event|'s {{AppHistoryNavigateEvent/canTransition}} to true. Otherwise, initialize it to false.
1. If |navigationType| is not "{{AppHistoryNavigationType/traverse}}", then initialize |event|'s {{Event/cancelable}} to true. Otherwise, initialize it to false.
1. Initialize |event|'s {{Event/type}} to "{{AppHistory/navigate}}".
1. Initialize |event|'s {{AppHistoryNavigateEvent/navigationType}} to |navigationType|.
1. Initialize |event|'s {{AppHistoryNavigateEvent/destination}} to |destination|.
Expand All @@ -1257,7 +1256,7 @@ The <dfn attribute for="AppHistoryDestination">sameDocument</dfn> getter steps a
* |destination|'s [=AppHistoryDestination/URL=]'s [=url/fragment=] is not [=string/is|identical to=] |currentURL|'s [=url/fragment=]

then initialize |event|'s {{AppHistoryNavigateEvent/hashChange}} to true. Otherwise, initialize it to false.
1. If |userInvolvement| is "<code>[=user navigation involvement/none=]</code>", then initialize |event|'s {{AppHistoryNavigateEvent/userInitiated}} to false. Otherwise, initialize it to true.
1. If |userInvolvement| is not "<code>[=user navigation involvement/none=]</code>", then initialize |event|'s {{AppHistoryNavigateEvent/userInitiated}} to true. Otherwise, initialize it to false.
1. If |formDataEntryList| is not null, then initialize |event|'s {{AppHistoryNavigateEvent/formData}} to a [=new=] {{FormData}} created in |appHistory|'s [=relevant Realm=], associated to |formDataEntryList|. Otherwise, initialize it to null.
1. [=Assert=]: |appHistory|'s [=AppHistory/ongoing navigate event=] is null.
1. Set |appHistory|'s [=AppHistory/ongoing navigate event=] to |event|.
Expand Down Expand Up @@ -1592,7 +1591,7 @@ With the above infrastructure in place, we can actually fire and handle the {{Ap
Modify the <a spec="HTML">navigate</a> algorithm to take an optional <dfn for="navigate">|appHistoryState|</dfn> argument (default null). Then, insert the following steps right before the step which goes [=in parallel=]. (Recall that per [[#user-initiated-patches]] we have introduced |userInvolvement| argument, and per [[#form-patches]] we have introduced an |entryList| argument.)

1. Let |appHistory| be <var ignore>browsingContext</var>'s [=browsing context/active window=]'s [=Window/app history=].
1. If none of the following are true:
1. If all of the following are false:
* <var ignore>historyHandling</var> is "<a for="history handling behavior">`entry update`</a>"
* <var ignore>userInvolvement</var> is "<code>[=user navigation involvement/browser UI=]</code>"
* <var ignore>browsingContext</var>'s [=active document=]'s [=Document/origin=] is not [=same origin-domain=] with the [=source browsing context=]'s [=active document=]'s [=Document/origin=]
Expand Down Expand Up @@ -1641,26 +1640,9 @@ With the above infrastructure in place, we can actually fire and handle the {{Ap
</div>

<div algorithm="apply the history step">
Modify the <a spec="HTML">apply the history step</a> algorithm as follows. Change <var ignore>checkForUserCancellation</var> to |fireBeforeunloadAndNavigate|. Add the |userInvolvement| parameter. Then, insert the following step after step 12 (which assembles |toTraverse| and other lists) but before step 13 (which checks if unloading is user-cancelled):
Modify the <a spec="HTML">apply the history step</a> algorithm as follows. Inside the loop over each <var ignore>navigable</var> of <var ignore>toTraverse</var>, inside the task that is posted, after the check if |targetEntry|'s document is |previousDocument| that might abort the algorithm, add the following steps:

1. If |fireBeforeunloadAndNavigate| is true and the result of <a>firing traversal `navigate` events</a> given |toTraverse|, <var ignore>step</var>, <var ignore>initiatorToCheck</var>, and |userInvolvement| is false, then return.
</div>

<div algorithm>
To <dfn>fire traversal `navigate` events</dfn> for a [=list=] of [=navigables=] |toTraverse|, an integer |step|, an [=origin=] |initiatorOrigin|, and a [=user navigation involvement=] |userInvolvement|:

1. Let |overallResult| be true.
1. Let |totalTasks| be the [=list/size=] of |toTraverse|.
1. Let |completedTasks| be 0.
1. [=list/For each=] |navigable| of |toTraverse|, [=queue a global task=] on the [=history traversal task source=] given |navigable|'s [=navigable/active document=]'s [=relevant global object=] to run these steps:
1. Let |destinationEntry| be the item in the result of [=navigable/getting the session history entries=] for |navigable| that has the greatest [=session history entry/step=] less than or equal to |step|.
1. If |destinationEntry|'s [=session history entry/document=] is not equal to |navigable|'s [=navigable/active document=], and |initiatorOrigin| is not [=same origin-domain=] with |navigable|'s [=navigable/active document=]'s [=Document/origin=], then abort these steps.
1. Let |appHistory| be |navigable|'s [=navigable/active document=]'s [=relevant global object=]'s [=Window/app history=].
1. Let |result| be the result of [=firing a traversal navigate event=] at |appHistory| with <i>[=fire a traversal navigate event/destinationEntry=]</i> set to |destinationEntry| and <i>[=fire a traversal navigate event/userInvolvement=]</i> set to |userInvolvement|.
1. If |result| is false, then set |overallResult| to false.
1. Increment |completedTasks|.
1. Wait for |completedTasks| to be |totalTasks|.
1. Return |overallResult|.
1. [=Fire a traversal navigate event=] at |previousDocument|'s [=relevant global object=]'s [=Window/app history=] with <i>[=fire a traversal navigate event/destinationEntry=]</i> set to |targetEntry| and <i>[=fire a traversal navigate event/userInvolvement=]</i> set to <var ignore>userInvolvement</var>.
</div>

<h2 id="session-history-patches">Patches to session history</h2>
Expand Down Expand Up @@ -1760,4 +1742,4 @@ The integration is then as follows:

* Wherever the spec ends up canceling not-yet-mature navigations for a [=browsing context=] |bc|, we also [=inform app history about canceling navigation=] in |bc|. (Regardless of whether or not there are any not-yet-mature navigations still in flight.)

* When the spec [=browsing context/discards=] a [=browsing context=] |bc|, we also [=inform app history about browsing context discarding=] given |bc|. (Regardless of whether or not there are any not-yet-mature navigations still in fligh, or any traversals queued up.)
* When the spec [=browsing context/discards=] a [=browsing context=] |bc|, we also [=inform app history about browsing context discarding=] given |bc|. (Regardless of whether or not there are any not-yet-mature navigations still in flight, or any traversals queued up.)

0 comments on commit 67a052c

Please sign in to comment.