Scroll Position Restoration -- How It's Done, How It's Lost, and How It's Fixed

Background: we think scroll position restoration is a critical UI

  • inherited habit from traditional HTML websites
  • when list is long, it is annoying having to find the previous position when navigating back

How it's done

browser has history.scrollRestoration = 'auto', but we would like to overwrite this, because:

  • auto can cause jumps in async rendering logic, with manual we can imperatively wait when necessary
  • auto doesn't work on horizontally scrolling component or local vertically scrolling component

platform to provide scroll position restoration behavior (wrapper component), key logic:

  • internally maintain a page / component - last position map
  • listen for history change:
    • hash change only: scroll target element into view
    • push: new navigation
    • otherwise: try to restore
  • wait mechanism:
    • wrapper component to provide a regsiterWaitingList util
    • when scroll position should wait (i.e., for async load to finish), it calls the util to register for wait
    • before each restoration, check if there's registered wait, and proceed with restoration only if there's no wait; if it needs to wait, try again (loop)

usage:

  • (default) host app to wrap the router layer with scroll position restoration component, this handles the default page navigation scroll position restoration automatically
  • when additional scroll position restoration is needed (i.e., a horizontally scrolling component), the component should be manually wrapped with the scroll position restoration wrapper

How it's lost

context:

  • app is now very complex, many different teams maintaining individual parts of the app
  • found out that the scroll position restoration behavior is not consistent, sometimes lost, sometimes ok

after investigation, we found various causes of lost and inaccuracy of scroll position restoration:

  • accidentally used the html anchor tag instead of Router Link component, so it's not an SPA navigation, but a browser native navigation
  • developer forgets to wrap horizontally scrolling component with scroll position restoration wrapper
  • page is rendering is heavy, scroll position restoration is delayed
  • page async load takes long (or unknown amount of) time, but did not register waiting list
  • in mobile browser and when user is scrolling up / down, the top nav can collapse / uncollapse and result in a change of scroll position
  • lazy loaded component grow in height (than placeholder component) after component is loaded and rendered
  • component grow in height due to content ready after async fetch
  • ...there could be more unknown causes

How the losts are hunted down

  • self-diagnosis approach (profiler): dev tools to implement a scroll position restoration behavior profiler, developers may run the profiler by themselves and self-diagnose what went wrong
  • e2e testing approach (CI): build a special e2e testing job, run in CI, and will fail when scroll position restoration is lost

Profiler design (for default behavior)

Collect link specimen

  1. devtool: request userland to collect page links
  2. userland: execute collect link specimen
    • querySelectorAll, then reduce to remove links of same class, this gets a specimen of 1 representative in each section
    • send to devtool the list of critical information: link href, class name, inner text
    • keep node list
  3. devtool: receive list of link information, generate hash as id, send back the list of hash (array of equal length)
  4. userland: create hash map indexed on hash, values are node list items

Trigger test on 1 link

  1. devtool: request user land to run test for link referenced by the relevant hash
  2. userland: check if the link is fully visible inside viewport
    • if so, proceed with action
    • if not, scroll into view, then proceed with action action: report to devtool that link is ready (payload: hash)
  3. devtool: request userland to report before position for link (payload: hash)
  4. userland: reply before position for the link (payload: hash, window.scrollY)
  5. devtool: record before position of the link, and then request userland to proceed with navigation (payload: hash)
  6. userland: trigger link.click()
  7. devtool: listen for location change of userland, when it happens and the url matches, request userland to nav back
  8. userland: trigger history.go(-1)
  9. devtool: listen for the next location change of userland, when it happens, request userland to report after scroll position (payload: hash)
  10. userland: reply after position for the link (payload: hash, window.scrollY)
  11. devtool: record after position of the link

Result

  • green: exact match
  • yellow: not exact match but within range (by % and is in viewport)
  • red: out of range
  • grey: restored to top (while previously not at the top)

Orchestrating auto run of all links

(wip)

E2E testing design

(wip)