Description
I'd like to update session history traversal to cater for multiple browsing contexts, and specify features that are somewhat shared by all browsers but missing from the spec. Unfortunately no browser behaves exactly the same, so I've tried to pick the common parts, then a couple of outlying behaviours that seem 'right'. I'd like to get some rough agreement on this before proceeding.
- Moving between browsing contexts at a top level (eg COOP) shouldn't lose any session history.
- Going back to a session history item should reuse the browsing context it used previously, if that browsing context is still in active use.
- Session history items should be discarded when nested contexts are discarded. Firefox currently does this, and it most closely matches the current spec.
- Moving a frame should discard its session history, since moving involves removing, and removing involves discarding the browsing context. Again this is Firefox/spec behaviour.
- Adding an item to session history should clear all current items of session history after the current point. This should be done in the joint session history. All browsers do this, but it isn't in the spec.
- When a document is discarded, the session history of its nested contexts need to be stored somehow, so it can be restored when the user navigates back. All browsers do some version of this.
When storing/reconnecting nested session history:
- A frame's
name
should be used when reconnecting nested session history from one document to another (reloaded) document. Chrome/Safari currently do this. - If a frame doesn't have a name, it's connection order should be used to associate frames from one document to another (reloaded) document. All browsers do this, but I'm sure there's some inconsistencies around timing.
- Frames created with JS, should be included in this process, except frames created after
DOMContentLoaded
. Safari sort-of does this, but it will go beyondDOMContentLoaded
, but things get pretty unreliable after that point. It seems to make sense to have a clear 'deadline' for session history restoration, after which it can be discarded. - If an item of session history cannot be reconnected, it should be discarded. Firefox currently does this.
My hope is this would resolve (at least large parts of) the following:
- Impact of browsing context group switch during navigation #5350
- User agents, session history, browsing contexts, and agent clusters #4782
- Consider rewriting session history to follow closer what implementations do using list of trees #1454
- Scroll restoration only talks about child browsing contexts #5103
Implementers and spec folks: Are you happy with the above behaviour becoming 'the way'?
Current browser behaviour
I poked the nightlies/technical previews of Chrome, Safari, and Firefox using this test page to try and figure out how session history worked. Every browser behaves differently, so in order to specify a behaviour we need to pick the best from each browser + the current spec and compromise.
Session history and inner browsing contexts
All browsers seem to agree that session history for a 'tab' includes navigations applied to frames. The back/forward buttons treat this as a flat list that can be traversed. The spec kinda defines this with the joint session history, which is a 'getter' on the top level browsing context, which combines the history of itself and all child browsing contexts, in chronological order.
However, the way this actually works differs between Chrome, Safari, and Firefox, especially after some recent Chrome changes.
Chrome
If a history item targets a browsing context that no-longer exists (eg an iframe removed from the DOM), Chrome will retain the items in session history, but they'll be a no-op when activated.
This is also the case for iframes that are 'moved' around the DOM, since moving is remove + add, and remove discards the iframe's browsing context – Chrome does not maintain a link between the old & new browsing contexts.
Safari
I haven't fully understood Safari's behaviour yet. It seems like every item in session history has some kind of “expected browsing contexts” map, and if the reality doesn't match that map, it reloads the page from the memory cache (unless the page was served with no-store, in which case it performs a full reload) and attempts to reapply iframe navigation state in the new page.
For example:
- Page has iframe 1, which has src 1.html.
- Navigate iframe 1 to 2.html.
- Remove iframe 1.
- Press back.
Safari will reload the page, and set iframe 1 to 1.html. However:
- Page has iframe 1, which has src 1.html.
- Navigate iframe 1 to 2.html.
- Add another iframe to the page.
- Press back.
Again, Safari will reload the page, which is what makes me think its logic is broader than browsing contexts involved in a given entry of session history. However:
- Page has iframe, with name=”iframe-1”, which has src 1.html.
- Navigate iframe to 2.html.
- Navigate iframe to 3.html.
- Remove iframe.
- Create a new iframe, with name=”iframe-1”, which has src 1.html, and add it to the DOM (doesn't matter where).
- Press back.
In this case Safari will navigate the new iframe 'back' to 2.html without reloading the page. This only works if the iframe is named. This also means session history for a named iframe appears to be preserved when it's 'moved' around the DOM, even though moving an iframe causes the inner browsing context to be discarded and a new one created.
Firefox
When an inner browsing context is discarded (removed, or moved), its session history items are also discarded. history.length
is immediately updated, but some other parts of browser UI (such as the drop down on the back button) lag behind - clicking on these entries does nothing, you remain on the same session history item.
Firefox's behaviour seems best to me, and closely matches the current spec.
Navigating a context that already has 'future' history items
All browsers seem to discard all future history items in the joint session history, whereas the spec (2.otherwise.1) only removes from the current context's browsing history.
Current browser behaviour seems best here.
Changing top level browsing context on navigation
More recently, browsers will sometimes change top level browsing context as part of a navigation, which breaks session history as defined by the spec, since a navigation that changes top-level browsing context should (as defined by the spec) lose all session history, but that isn't the behaviour we want.
There's a placeholder in the spec to deal with this.
Going 'back' to pages with inner browsing contexts
All browsers seem to agree that 'deep' session history is kinda retained despite navigating the parent. For instance, if you:
- Navigate an iframe
- Navigate the page containing the iframe
- Press back
…browsers will attempt to put the iframe back into its final state in terms of session history, and pressing back again will navigate it to its previous session state.
This is especially easy in browsers that can restore the previous page from the bfcache, as all the parts are still 'live'. However, the bfcache is an optimisation, and may not be used due to particular page conditions, or may be dropped due to resource constraints.
Without bfcache, browsers will still try to restore the final navigation state by ignoring the src specified on the frame, and instead using their final url from session history.
This behaviour appears to be missing from the spec. Since the joint session history is a getter, the items which reference child browsing contexts would be lost when the parent Document is discarded. Retaining session history seems important here, so the spec needs to change. However, browser behaviour isn't consistent.
The following results are based on no-bfcache, which was simulated by serving pages with Cache-Control: no-store, and adding an unload event listener to the window.
Chrome
When you navigate back to a page, Chrome will 'reconnect' iframes with session histories based on their name attribute, falling back to their connection order.
This only applies to iframes created by the parser. Any iframes created with JS (even before DOM-ready) are not reconnected with session history.
If session history items cannot be associated with particular frames (either the named iframe is missing, the parser created fewer this time, or the iframe was created with JS), the number of items in session history is unchanged, but those session items become no-ops.
Safari
Similar to Chrome, Safari will 'reconnect' iframes with session histories based on their name attribute, falling back to their connection order.
However, this includes iframes created with JS. Even if the iframe is created after DOM-ready. For instance:
- Add iframe-1 to DOM with src 1.html
- Navigate iframe-1 to 2.html
- Navigate iframe-1 to 3.html
- Navigate top level page.
- Back. Iframe-1 isn't there, since the page is reloaded.
- Add iframe-1 to DOM with src 1.html.
- Back. Iframe-1 is navigated to 2.html.
- Back. Iframe-1 is navigated to 1.html.
Although Safari can't find iframe-1 to give it its final navigation state when the top level page is reloaded, it can 'reconnect' with it when going back through the previous session states.
Safari's “reload if browsing contexts don't look as expected” behaviour, as described earlier, also applies here. In the case of JS-created iframes, this can lead to every back-button press reloading the page but not being able to find the iframe it wants to navigate.
Firefox
Firefox will 'reconnect' iframes with session histories based on their connection order. It doesn't take the iframe's name into consideration.
Like Chrome, this only applies to iframes created by the parser. Any iframes created with JS (even before DOM-ready) are not reconnected with session history.
If session history items cannot be associated with particular frames (either the named iframe is missing, the parser created fewer this time, or the iframe was created with JS), those session history items are discarded, and history.length
is immediately updated.
Specifying the history as a 'timeline'
In the current spec, the history timeline is derived from independent session history 'current entries', where the single history timeline has to be enforced through algorithms. I'd like to flip this around so the browsing session is in charge of the current history position.
I've made a little 10 minute presentation where I go through the spec changes at a high level https://www.youtube.com/watch?v=nZb0U3rFQXw.
You can think of session history as a timeline:
Step 0 | Step 1 | Step 2 | Step 3 | Step 4 |
1.html | 2.html | 3.html | ||
iframe1.html | bar.html | |||
iframe2.html | foo.html |
(ignore the cell background colours, they're GitHub's default styles)
This example has 5 steps of session history:
- Top level navigation to 1.html
- Top level navigation to 2.html, which contains two iframes, one pointing to iframe1.html, the other to iframe2.html.
- Navigate the second iframe to foo.html.
- Navigate the first iframe to bar.html.
- Top level navigation to 3.html.
In this model, each session history item is given a step number. Some history items will share a step number – in the example above there are three history items with step number 1.
The browsing session will have a 'current step', representing the current point in the timeline. The intended 'current' session history item for all navigables in a browsing session can be derived from their session history and the browsing session's current step.
For example, if the current step is 2:
Step 0 | Step 1 | Step 2 | Step 3 | Step 4 |
1.html | 2.html | 3.html | ||
iframe1.html | bar.html | |||
iframe2.html | foo.html |
It's easy to derive the current history item of the top level and all the child navigables.
Then, if the second iframe is removed:
Step 0 | Step 1 | Step 2 | Step 3 | Step 4 |
1.html | 2.html | 3.html | ||
iframe1.html | bar.html |
The iframe and its session history items are simply removed. Rather than try and remove step 2 at this point, I think it's better to handle empty steps at navigation time. For example, to go 'back':
- Let step be the browsing session's current history step.
Which would be 2 in this case. - While there isn't a session history item with step step deeply within the current browsing session, decrement step.
Which would take step down to 1. - Set the browsing session's current history step to step - 1.
Which would set the current step to 0.
If, instead, the iframe was navigated to 'hello.html':
- Let step be the browsing session's current history step.
Which would be 2 in this case. - Remove all session history items, deeply, with a step greater than step.
This removes the bar.html and 3.html history entries. - Add a new history item to the iframe's navigable's session history with step step + 1.
- Set the browsing session's current history step to step + 1.
Resulting in:
Step 0 | Step 1 | Step 2 | Step 3 |
1.html | 2.html | ||
iframe1.html | hello.html |
If a new navigable is added to the page, its initial session history step will be the same as the step of the parent navigable's current session history item. Which would be 1 in this case:
Step 0 | Step 1 | Step 2 | Step 3 |
1.html | 2.html | ||
iframe1.html | hello.html | ||
new-iframe.html |
I think this would also solve a bug in the current entry of the joint session history, which would treat the session item in the new iframe as the current entry, whereas in this model the current history item would be the parent-most session history item with the current step (decrementing as necessary), which would be hello.html
.
To get the 'length' of the overall session history, take all the session history items and return the number of unique steps.
Implementers and spec folks: Would you be happy if session history was spec'd in this way?
Spec changes
I haven't thought enough about this, so there will almost certainly be changes as I work through stuff, but here's some of my notes:
A browsing session is a top-level navigable that also has:
- A current history step, a number, initially -1.
There's never a history item with step -1. The step will be incremented when the first history item is created.
A navigable represents something that can be navigated. Things which currently contain browsing contexts for navigation purposes will use a navigable instead, and may be null in cases where browsing contexts can be null, such as a disconnected iframe. A navigable has:
- A session history, a list of session history entries.
A session history entry has:
- Everything currently defined, except the
Document
object (although "other information" makes me sad). - A history step, a number. This places this session history item on the history 'timeline' of the browsing session.
- A target browsing context, a reference to a browsing context or null. This reference if weak if restorable state is not a
Document
.
Yeah, I'm unsure about making a reference weak conditionally like this. I'll look for a better way to do this. The idea is that browsing contexts can go away when they don't have documents associated with them. - A restorable state, either a
Document
, a restorable session history, or null. This is the new home for theDocument
object currently on the session history entry.
A restorable session history allows the browser to 'reconnect' child contexts with their session history when a document is recreated for a session history item. A restorable children's session history has:
- Named children, a map where the keys are strings and the values are lists of session history entries. Used to restore frames with a defined name.
- Unnamed children, a list of lists of session history entries. Used to restore frames without a name.
When an iframe is connected for the first time before document parsing is complete, it takes its URL from the restorable session history if possible, and falls back to its src. A weak reference to it is added to a list of navigables that may be serialised as part of restorable session history when the parent Document
is discarded. TODO: I'm not yet sure of the exact timing of this, eg when is an iframe associated with its name in terms of history restoration?
In my model, an iframe's current browsing context is only accessible through its session history. @domenic doesn't think this will work, but I'd like to see how far I get with this 'pure' model before unravelling it a bit.
Before I spend more time figuring this out, am I at least heading in the right direction?