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, withmanual
we can imperatively wait when necessaryauto
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)
- wrapper component to provide a
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
- devtool: request userland to collect page links
- 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
- devtool: receive list of link information, generate hash as id, send back the list of hash (array of equal length)
- userland: create hash map indexed on hash, values are node list items
Trigger test on 1 link
- devtool: request user land to run test for link referenced by the relevant hash
- 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)
- devtool: request userland to report before position for link (payload: hash)
- userland: reply before position for the link (payload: hash, window.scrollY)
- devtool: record before position of the link, and then request userland to proceed with navigation (payload: hash)
- userland: trigger
link.click()
- devtool: listen for location change of userland, when it happens and the url matches, request userland to nav back
- userland: trigger
history.go(-1)
- devtool: listen for the next location change of userland, when it happens, request userland to report after scroll position (payload: hash)
- userland: reply after position for the link (payload: hash, window.scrollY)
- 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)