Definition
Scroll restoration is the UX of returning a list to the position the user was viewing after back navigation or reload. A virtual list (window virtualizer) keeps only visible rows in the DOM, so on restore, differences between estimated and measured row heights can change total document height and cause scrollY to clamp.
Single scroll authority means only one code path among bootstrap script, React effects, and virtualizer seeding decides the final scroll position. Multiple places calling scrollTo or 6-frame reassert loops at once cause flicker and jitter.
Why it matters
Infinite-scroll lists persist anchor id, scrollY, and row heights in sessionStorage to survive cold reload. But (1) if a throttled save listener re-subscribes every render, nothing gets saved; (2) if min-height placeholders are removed too early after reveal, totalSize shrinks and scrollY gets clipped; (3) bootstrap rAF×3 + inner seed + 6-frame scrollBy loops overwrite each other.
E2E that only verifies Chromium native scroll restoration yields false-green when snapshots were never saved.
How it works
- Save: Attach passive scroll listener once (
[]deps). Capture function reads latest ref. Throttle sessionStoragesetItemto 250ms. - Bootstrap: Inline script tail calls
window.scrollTo(0, y)once (remove rAF chains andloadlisteners). - React restore: Keep
visibility:hidden→ compare stored value to viewport offset of anchordata-row-id→ onescrollBy(delta). If no anchor, fall back to pixelscrollY(no index estimation). - Height seed: Persist measured row heights in sessionStorage
:hkeys → stablegetTotalSize()from first frame on reload. - Min-height floor: Keep placeholder after reveal; remove only when real content covers current scrollY.
- Failsafe: Unreachable anchor + cold-fetch exhausted → reveal anyway to avoid permanent
hiddenblank screen.
Practical application
@tanstack/react-virtualmeasureElementafter commit + one rAF is often enough for text row correction.- E2E: reload ±16px, no jitter, deep depth drift ≤1px, sessionStorage save gate, anchorless and unreachable paths.
history.scrollRestoration = "manual"conflicts with Next native back restore — confirm project policy.- Split ListRestoreBootstrap into an external script file for clearer CSP and cache policy.
Trade-offs
| Choice | Pros | Cons |
|---|---|---|
| Single corrective scroll | Predictable; removes jitter | Variable image rows may need more than one pass |
| N-frame reassert | Chases drift | Visible jitter; authority competition |
| Index fallback | Easy to implement | Snaps at cursor page boundaries |
| Pixel scrollY fallback | Stable when anchor missing | Error when rows are deleted |
| Persist measured heights | Accurate deep reload | sessionStorage size and migration |
When not to use
- Short lists with dozens of items and no virtualization (browser default restoration is enough).
- Row heights change heavily from async images while insisting on a single correction only.
- Passing E2E as "restored" without snapshot persistence.
Common mistakes
- Putting a new
captureSnapshotin throttle save effect deps every render → pending timer cleared → nothing saved. - Using only
estimateSize; after deep reload totalSize drops → scrollY clamped by hundreds of px. - Bootstrap + React + inner seed all calling scrollTo together.
- E2E verifying only native restoration and missing unsaved snapshot bugs.
- Dead helpers like
reassertScrollForFramesandestimateScrollYForRowIndexleft behind.
Related concepts
- list-virtualization-windowing — virtualizer and measureElement basics
- ssr-hydration-mismatch — bootstrap script vs React boundary