Skip to content

Better control over scroll restoration during traversal #187

@domenic

Description

@domenic

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 now
  • getDestination(): 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 to
    const { 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, the navigate 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" });

Metadata

Metadata

Assignees

No one assigned

    Labels

    additionA proposed addition which could be added later without impacting the rest of the APIfeedback wanted

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions