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

Clarify some joint session history relations #29

Merged
merged 5 commits into from
Feb 12, 2021
Merged
Changes from all commits
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
107 changes: 77 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

The web's existing [history API](https://developer.mozilla.org/en-US/docs/Web/API/History) is problematic for a number of reasons, which makes it hard to use for web applications. This proposal introduces a new one, which is more directly usable by web application developers to address the use cases they have for history introspection, mutation, and observation/interception.

This new `window.appHistory` API layers on top of the existing API and specification infrastructure, with well-defined interaction points. The main differences are that it is scoped to the current origin and frame, and it is designed to be pleasant to use instead of being a historical accident with many sharp edges.
This new `window.appHistory` API [layers](#integration-with-the-existing-history-api-and-spec) on top of the existing API and specification infrastructure, with well-defined interaction points. The main differences are that it is scoped to the current origin and frame, and it is designed to be pleasant to use instead of being a historical accident with many sharp edges.

<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
Expand Down Expand Up @@ -33,6 +33,9 @@ This new `window.appHistory` API layers on top of the existing API and specifica
- [Introspecting the history list](#introspecting-the-history-list)
- [Watching for navigations](#watching-for-navigations)
- [Integration with the existing history API and spec](#integration-with-the-existing-history-api-and-spec)
- [Correspondence with session history entries](#correspondence-with-session-history-entries)
- [Correspondence with the joint session history](#correspondence-with-the-joint-session-history)
- [Integration with navigation](#integration-with-navigation)
- [Impact on the back button and user agent UI](#impact-on-the-back-button-and-user-agent-ui)
- [Security and privacy considerations](#security-and-privacy-considerations)
- [Stakeholder feedback](#stakeholder-feedback)
Expand Down Expand Up @@ -169,6 +172,8 @@ As with `history.pushState()` and `history.replaceState()`, the new URL here mus

Note that `appHistory.pushNewEntry()` is asynchronous. As with other [navigations through the app history list](#navigation-through-the-app-history-list), pushing a new entry can be [intercepted or canceled](#navigation-monitoring-and-interception), so it will always be delayed at least one microtask.

Additionally, like `history.pushState()`, `appHistory.pushNewEntry()` will clear any future entries in the joint session history. (This includes entries coming from frame navigations, or cross-origin entries: so, it can have an impact beyond just the `appHistory.entries` list.)

In general, you would use `appHistory.updateCurrentEntry()` and `appHistory.pushNewEntry()` in similar scenarios to when you would use `history.pushState()` and `history.replaceState()`. However, note that in the app history API, there are some cases where you don't have to use `appHistory.pushNewEntry()`; see [the discussion below](#using-navigate-handlers-plus-non-history-apis) for more on that subject.

Crucially, `appHistory.currentEntry` stays the same regardless of what iframe navigations happen. It only reflects the current entry for the current frame. The complete list of ways the current app history entry can change are:
Expand All @@ -195,9 +200,7 @@ The way for an application to navigate through the app history list is using `ap

_TODO: realistic example of when you'd use this._

Unlike the existing history API's `history.go()` method, which navigates by offset, navigating by key allows the application to not care about intermediate history entries; it just specifies its desired destination entry.

There are also convenience methods, `appHistory.back()` and `appHistory.forward()`.
Unlike the existing history API's `history.go()` method, which navigates by offset, navigating by key allows the application to not care about intermediate history entries; it just specifies its desired destination entry. There are also convenience methods, `appHistory.back()` and `appHistory.forward()`.

All of these methods return promises, because navigations can be intercepted and made asynchronous by the `navigate` event handlers that we're about to describe in the next section. There are then several possible outcomes:

Expand All @@ -211,6 +214,8 @@ All of these methods return promises, because navigations can be intercepted and

- The navigation succeeds, and it was a different-document navigation. Then the promise will never settle, because the entire document and all its promises will disappear.

As discussed in more detail in the section on [integration with the existing history API and spec](#integration-with-the-existing-history-api-and-spec), navigating through the app history list does navigate through the joint session history. This means it _can_ impact other frames on the page. It's just that, unlike `history.back()` and friends, such other-frame navigations always happen as a side effect of navigating your own frame; they are never the sole result of an app history traversal.

### Navigation monitoring and interception

The most interesting event on `window.appHistory` is the one which allows monitoring and interception of navigations: the `navigate` event. It fires on almost any navigation, either user-initiated or application-initiated, which would update the value of `appHistory.currentEntry`. This includes cross-origin navigations (which will take us out of the current app history list); see [below](#example-cross-origin-affiliate-links) for an example of how this is useful. **We expect this to be the main event used by application- or framework-level routers.**
Expand Down Expand Up @@ -617,7 +622,7 @@ Instead of using `history.replaceState(state, uselessTitle, url)`, use `await ap

Instead of using `history.back()` and `history.forward()`, use `await appHistory.back()` and `await appHistory.forward()`. Note that unlike the `history` APIs, the `appHistory` APIs will ignore other frames, and will only control the navigation of your frame. This means it might move through multiple entries in the joint session history, skipping over any entries that were generated purely by other-frame navigations.

Additionally, for same-document navigations, you can test whether the navigation had an effect using a pattern like the following:
For same-document navigations, you can test whether the navigation had an effect using a pattern like the following:

```js
const startingEntry = appHistory.currentEntry;
Expand All @@ -627,6 +632,8 @@ if (startingEntry === appHistory.currentEntry) {
}
```

Note that unlike the `history` APIs, these `appHistory` APIs will not go to another origin. For example, trying to call `appHistory.back()` when the previous document in the joint session history is cross-origin will just do nothing, and trigger the `console.log()` call above.

Instead of using `history.go(offset)`, use `await appHistory.navigateTo(key)` to navigate to a specific entry. As with `back()` and `forward()`, `appHistory.navigateTo()` will ignore other frames, and will only control the navigation of your frame. If you specifically want to reproduce the pattern of navigating by an offset (not recommended), you can use code such as the following:

```js
Expand Down Expand Up @@ -723,18 +730,28 @@ The app history API provides several replacements that subsume these events:

## Integration with the existing history API and spec

An `AppHistoryEntry` corresponds directly to a [session history entry](https://html.spec.whatwg.org/#session-history-entry) from the existing HTML specification. However, not every session history entry would have a corresponding `AppHistoryEntry`: `AppHistoryEntry` objects only exist for session history entries which are same-origin to the current one, and contiguous.
At a high level, app history is meant to be a layer on top of the HTML Standard's existing concepts. It does not require a novel model for session history, either in implementations or specifications. (Although, it will only be possible to specify it rigorously once the existing specification gets cleaned up, per the work we're doing in [whatwg/html#5767](https:/whatwg/html/issues/5767).)

This is done through:

Example: if a browsing context contains history entries with the URLs
- Ensuring that app history entries map directly to the specification's existing history entries. The `appHistory.entries` API only presents a subset of them, namely same-frame contiguous, same-origin ones, but each is backed by an existing entry.

1. `https://example.com/foo`
1. `https://example.com/bar`
1. `https://other.example.com/whatever`
1. `https://example.com/baz`
- Ensuring that traversal through app history always maps to a traversal through the joint session history, i.e. a traversal which is already possible today.

then, if the current entry is (4), there would only be one `AppHistoryEntry` in `appHistory.entries`, corresponding to (4) itself. If the current entry is (2), then there would be two `AppHistoryEntries` in `appHistory.entries`, corresponding to (1) and (2).
### Correspondence with session history entries

An `AppHistoryEntry` corresponds directly to a [session history entry](https://html.spec.whatwg.org/#session-history-entry) from the existing HTML specification. However, not every session history entry would have a corresponding `AppHistoryEntry` in a given `Window`: `AppHistoryEntry` objects only exist for session history entries which are same-origin to the current one, and contiguous within that frame.

Example: if a browsing session contains session history entries with the URLs

```
1. https://example.com/foo
2. https://example.com/bar
3. https://other.example.com/whatever
4. https://example.com/baz
```

Furthermore, unlike the view of history presented by `window.history`, `window.appHistory` only gives a view onto session history entries for the current browsing context; it does not present the joint session history, i.e. it is not impacted by frames.
then, if the current entry is 4, there would only be one `AppHistoryEntry` in `appHistory.entries`, corresponding to 4 itself. If the current entry is 2, then there would be two `AppHistoryEntries` in `appHistory.entries`, corresponding to 1 and 2.

To make this correspondence work, every spec-level session history entry would gain two new fields:

Expand All @@ -752,38 +769,67 @@ Apart from these new fields, the session history entries which correspond to `Ap

_TODO: actually, we should probably expose scroll restoration mode, like `history.scrollRestoration`? That API has legitimate use cases, and we'd like to allow people to never touch `window.history`..._

Finally, all the higher-level mechanisms of session history entry management, such as the interaction with navigation, continue to work as they did before; the correspondence to `AppHistoryEntry` APIs does not change the processing there.
### Correspondence with the joint session history

To understand how navigation interception and queuing interacts with the existing navigation spec, see [the navigation types appendix](#appendix-types-of-navigations). Also note the open questions in [#19](https:/WICG/app-history/issues/19).

## Impact on the back button and user agent UI
The view of history which the user sees, and which is traversable with existing APIs like `history.go()`, is the joint session history.

The app history API doesn't change anything about how user agents implement their UI: it's really about developer-facing affordances. Users still care about the joint session history, and so that will continue to be presented in UI surfaces like holding down the back button. Similarly, pressing the back button will continue to navigate through the joint session history, potentially across origins and out of the current app history (into a new app history, on the new origin).
Unlike the view of history presented by `window.history`, `window.appHistory` only gives a view onto session history entries for the current browsing session. This view does not present the joint session history, i.e. it is not impacted by frames. Notably, this means `appHistory.entries.length` is likely to be quite different from `history.length`.

An important consequence of this is that when iframes are involved, the back button may navigate through the joint session history, without changing the current _app history_ entry. For example, consider the following sequence:
Example: consider the following setup.

1. `https://example.com/start` loads
1. `https://example.com/start` loads.
1. The user navigates to `https://example.com/outer` by clicking a link. This page contains an iframe with `https://example.com/inner-start`.
1. Code on `https://example.com/outer` calls `appHistory.pushNewEntry({ url: "/outer-pushed" })`.
1. The iframe navigates to `https://example.com/inner-end`.

The app history list for the outer frame contains two entries:

```
1. https://example.com/start
2. https://example.com/outer
```

The joint session session history contains three entries:
The joint session session history contains four entries:

```
A. https://example.com/start
B. https://example.com/outer
┗ https://example.com/inner-start
C. https://example.com/outer
C. https://example.com/outer-pushed
┗ https://example.com/inner-start
D. https://example.com/outer-pushed
┗ https://example.com/inner-end
```

The user's back button, as well as the `history.back()` API, will navigate the joint session history back to (B). However, they will have no effect on the app history list; that will stay on (2). Pressing the back button or calling `history.back()` a second time would then move the joint session history back to (A), and the app history list back to (1).
The app history list (which also matches the existing spec's session history) for the outer frame looks like:

```
O1. https://example.com/start (associated to A)
O2. https://example.com/outer (associated to B)
O3. https://example.com/outer-pushed (associated to C and D)
```

The app history list for the inner frame looks like:

```
I1. https://example.com/inner-start (associated to B and C)
I2. https://example.com/inner-end (associated to D)
```

Traversal operates on the joint session history, which means that it's possible to impact other frames. Continuing with our previous setup, and assuming the current entry in the joint session history is D, then:

- If code in the outer frame calls `appHistory.back()`, this will take us back to O2, and thus take the joint session history back to B. This means the inner frame will be navigated from `/inner-end` to `/inner-start`, changing its current app history entry from I2 to I1.

- If code in the inner frame calls `appHistory.back()`, this will take us back to I1, and take the joint session history back to C. (This does not impact the outer frame.) The rule here for choosing C, instead of B, is that it moves the joint session history the fewest number of steps necessary to make I1 active.

- If code in either the inner frame or the outer frame calls `history.back()`, this will take the joint session history back to C, and thus update the inner frame's current app history entry from I2 to I1. (There is no impact on the outer frame.)

Choose a reason for hiding this comment

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

This is an interesting outcome, because C isn't an entry in the iframe, it belongs to the parent.

I'm not saying it's wrong, but it's something that might need further documentation.

You could say: To traverse to a specific appHistory entry, joint history should traverse the least number of steps to make that history entry active.

That means you'd end up on a different item of joint session history depending on the direction of travel. I can't decide if that's intuitive or not.

The alternative is to always travel to the step in joint history where that entry was created. That means it's consistent going back & forward, but may result in changing other frames more than necessary. In this example it would navigate the parent frame (and of course hit that fun bug).

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, I was going for least number of steps... hmm. I'll try to capture this and maybe open a tracking issue.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Although I assume your comment is meant to apply to the second bullet, not the third. The third uses history.back(), not appHistory.back().

Choose a reason for hiding this comment

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

Ah yes sorry


Note that as currently planned, any such programmatic navigations, including ones originating from other frames, are [interceptable and cancelable](#navigation-monitoring-and-interception) as part of the `navigate` event part of the proposal.

### Integration with navigation

To understand how navigation interception and queuing interacts with the existing navigation spec, see [the navigation types appendix](#appendix-types-of-navigations). Also note the open questions in [#19](https:/WICG/app-history/issues/19).

The way in which navigation interacts with session history entries generally is not meant to change; the correspondence of a session history entry to an `AppHistoryEntry` does not introduce anything novel there.

## Impact on the back button and user agent UI

The app history API doesn't change anything about how user agents implement their UI: it's really about developer-facing affordances. Users still care about the joint session history, and so that will continue to be presented in UI surfaces like holding down the back button. Similarly, pressing the back button will continue to navigate through the joint session history, potentially across origins and out of the current app history (into a new app history, on the new origin). The design discussed in [the previous section](#correspondence-with-the-joint-session-history) ensures that app history cannot get the browser into a strange novel state that has not previously been seen in the joint session history.

One consequence of this is that when iframes are involved, the back button may navigate through the joint session history, without changing the current _app history_ entry. This is because, for the most part, the behavior of the back button is the same as that of `history.back()`, which as the previous section showed, only impacts one frame (and thus one app history list) at a time.

Finally, note that user agents can continue to refine their mapping of UI to joint session history to give a better experience. For example, in some cases user agents today have the back button skip joint session history entries which were created without user interaction. We expect this heuristic would continue to be applied for `appHistory.pushNewEntry()`, just like it is for today's `history.pushState()`.

Expand Down Expand Up @@ -818,6 +864,7 @@ This proposal is based on [an earlier revision](https:/slightlyoff/h

Thanks also to
[@chrishtr](https:/chrishtr),
[@creis](https:/creis),
[@dvoytenko](https:/dvoytenko),
[@housseindjirdeh](https:/housseindjirdeh),
[@jakearchibald](https:/jakearchibald),
Expand Down