-
Notifications
You must be signed in to change notification settings - Fork 28
Description
Talking with a few web developers, we've pinpointed a few issues with scroll restoration during traversal, that seem relevant to any history/navigation API.
The essential problem we've heard so far is that scroll restoration happens unpredictably and often at the wrong times. For example:
- The browser tries to restore scroll position, but your app is still setting up the DOM and the relevant elements aren't ready yet
- The browser tries to restore scroll position, but the page's contents have changed and scroll restoration doesn't work that well (e.g., in a listing of files in some shared folder, someone deleted a bunch of the files)
- You need to make some measurements in order to do a proper transition, but the browser does scroll restoration during your transition which messes it up. (Demo)
Note that these problems are present for both cross-document traversals and same-document traversals.
The developers I've talked to have expressed that they're looking for something between the two extremes we currently have available:
- Disabling scroll restoration and handling it yourself (via
history.scrollRestoration = "manual"
) - Letting the browser perform scroll restoration at an unpredictable time and in an unpredictable manner, after traversal
Here's an initial stab at a solution for this problem space. Note that the core proposal is not really tied to the rest of the app history API, and might end up advancing as a separate proposal on the whatwg/html repo. But I wanted to start with this community, since it fits into the general problem space of "ideal new APIs for handling history/navigation". And I do have an app history specific bonus proposal below the main one.
Proposal
Introduce a new event, beforescrollrestoration
. It can be targeted at any scrollable container, with the <html>
element being the one for the overall viewport. (Or, the <body>
element, in quirks mode...) But e.g. if you were implementing your page as one large scrollable <div>
inside a non-scrolling viewport, then you'd listen for the event on that <div>
.
This event would fire whenever the browser would normally perform scroll restoration. Per the current spec and tests, is after popstate
/before hashchange
. (App history's currentchange
comes before popstate
, so the total sequence woul be: currentchange
, popstate
, beforescrollrestoration
, hashchange
.) But, see below...
The event has the following methods:
preventDefault()
: stops scroll restoration from happening, for nowgetDestination()
: returns a{ scrollLeft, scrollTop }
pair corresponding to where the browser would currently restore the scroll position, if scroll restoration were to happen right now.restore()
: performs scroll restoration, essentially equivalent toconst { scrollLeft, scrollTop } = event.getDestination(); event.target.scrollLeft = scrollLeft; event.target.scrollTop = scrollTop;
The key thing about this event is that its getDestination()
and restore()
methods can still be used, in the future, even after preventDefault()
was called. So here is an example use:
document.documentElement.addEventListener("beforescrollrestoration", e => {
if (!everythingIsLoadedAndClientSideRendered()) {
e.preventDefault();
setClientSideRenderingFinishedCallback(() => doScrollRestoration(e));
}
});
function doScrollRestoration(event) {
const { scrollLeft, scrollTop } = event.getDestination();
if (!shouldStillDoScrollRestoration(scrollLeft, scrollTop)) {
// Don't do any scroll restoration if something dramatic has changed,
// e.g. if a bunch of files were deleted and we're going back to the
// file list view. Maybe scrollLeft/scrollTop could help with this logic.
return;
}
event.restore();
}
Note in particular that the value getDestination()
returns can change over time, as a result of this sort of delay. E.g., if the browser has stored internally something like "100px from the top of the #foo
element", if the #foo
element is not in the DOM at beforescrollrestoration
time, it will probably return (0, 0) or some other inaccurate value. But if, by the time we get to the doScrollRestoration
function, the page has done all its client-side rendering and put #foo
into the DOM, getDestination()
will return some useful value, and restore()
will restore the scroll position to that useful location.
Potentially at some point (such as if the user ever starts scrolling?) these methods should stop working, e.g. returning null
for getDestination()
and doing nothing when you call restore()
. At least in Chromium we abort any attempts at scroll restoration if the user starts scrolling plus a few other conditions I haven't fully understood yet.
Also note that this event fires both for same-document navigations, and cross-document navigations.
App history-specific bonus proposal
The above seems like the right primitive that should allow developers all the control they need. (At least, in cases where they don't want to just handle scroll restoration completely manually, using history.scrollRestoration = "manual"
.)
But for the specific case of same-document transitions, we can leverage the navigate
and the promise passed to transitionWhile()
to handle things a bit more automatically. I'm thinking that by default, whenever you use transitionWhile()
, we delay scroll restoration until the promise settles. Since the promise should ideally be settled when everything is loaded and the DOM is ready, this will probably work well in most cases.
Doing so takes the scroll restoration process, as well as its corresponding beforescrollrestoration
event, out of the normal ordering mentioned above.
We'd let you opt out of this, using something like the following:
navigateEvent.transitionWhile(promise, { scrollRestoration: "immediate" });
// Default value: "after-transition". Another possible value: "manual".
This doesn't solve all the scroll restoration problems:
- It does nothing for cross-document cases, since those don't involve
transitionWhile()
(and, more generally, thenavigate
event fires in the context of the old document, not the new one) - It doesn't help with cases like https://nifty-blossom-meadow.glitch.me/legacy-history/transition.html , where the concern is not about setting up the DOM, but instead performing correct measurements that don't get borked by scroll restoration.
To solve the latter, you could mix in the beforescrollrestoration
event in a somewhat messy way:
navigateEvent.transitionWhile((async () => {
let bsrEvent;
document.documentElement.addEventListener("beforescrollrestoration", e => {
e.preventDefault();
bsrEvent = e;
}, { once: true });
await fetchDataAndSetUpTheDOM();
bsrEvent.restore();
await measureAndDoTransition();
})(), { scrollRestoration: "immediate" });
We could make this nicer by introducing navigateEvent.scrollRestoration
, with getDestination()
and restore()
methods, whenever scrollRestoration: "manual"
is set. Then the above messy code could be rewritten as
navigateEvent.transitionWhile((async () => {
await fetchDataAndSetUpTheDOM();
navigateEvent.scrollRestoration.restore();
await measureAndDoTransition();
})(), { scrollRestoration: "manual" });