Skip to content

fix: make scroll restoration robust against slow/loaded content - #59

Merged
Archmonger merged 2 commits into
mainfrom
fix-scroll-restoration
Sep 21, 2026
Merged

Archmonger merged 2 commits into
mainfrom
fix-scroll-restoration

Conversation

@Archmonger

Copy link
Copy Markdown
Contributor

Description

test_scroll_restoration_preserves_scroll was intermittently failing in CI with a scroll-restore timeout. Root-cause analysis (rAF/scrollTo tracing under CPU-pinned load) showed two independent defects — one in the component and one in the test.

Underlying component bug

The ScrollRestoration restore loop used a fixed 10-animation-frame budget. After a client-side navigation the destination route's content is streamed in by the ReactPy server round-trip, so the document can still be too short to scroll to the saved position, and frame cadence additionally collapses under CPU load. When the frame budget expired early, window.scrollTo(pos) clamped against the not-yet-tall document and the restored position was silently dropped (observed live: scrollY stuck near the top while the restored value was never applied).

Fix: replace the frame-count budget with a wall-clock window and stop the moment the target is reached (within a 2px tolerance). The loop keeps re-asserting the position while late content grows, and the early stop means it never fights the user's own scrolling. Effect cleanup cancels any in-flight loop so overlapping re-renders don't spawn competing loops.

Test methodology bug

The old test:

  • used a synthetic element.click() to navigate, which can outrun the Link component's preventDefault handler. When that happens the anchor performs a real navigation (a full page reload) that wipes the client-side scroll store — reproducing the CI got 0 signature under load.
  • placed the forward link at the bottom of the tall page, so Playwright's scroll-into-view-before-click saved the page-bottom position; the loose >= 450 assertion then passed for the wrong reason, never verifying the intended scroll was saved/restored.

Fix:

  • Use a native page.click, which waits for actionability so the router's handler is reliably attached (no accidental reload).
  • Add a spacer so the forward link sits in the viewport at the target scroll position; Playwright's scroll-into-view becomes a no-op, so exactly target_y is saved.
  • Return via the browser's own back action (popstate) — a genuine restore-path exercise without a second click that could race the event wiring.
  • Assert a near-exact restored position (|scrollY − 500| ≤ 5) instead of a loose lower bound, so a wrong-but-plausible outcome (top, or clamped to the bottom) cannot pass.

Verification

Stressed well beyond a single run:

  • 14/14 full CI-style hatch test --headless runs
  • 25/25 runs under heavy CPU oversubscription (taskset -c 0,1 -n 2)
  • 15/15 single-core pinned runs
  • hatch fmt (ruff + prettier) and pyright clean

Checklist

Please update this checklist as you complete each item:

  • Tests have been developed for bug fixes or new functionality.
  • The changelog has been updated, if necessary. (skipped — scroll_restoration is still unreleased)
  • Documentation has been updated, if necessary. (n/a)
  • GitHub Issues closed by this PR have been linked.

By submitting this pull request I agree that all contributions comply with this project's open source license(s).

Two issues made test_scroll_restoration_preserves_scroll flaky under CI load:

1. ScrollRestoration's restore loop used a fixed 10-animation-frame budget.
   After a client-side navigation the destination content is streamed in by
   the server, so the document can still be too short to reach the saved
   position, and frame cadence also collapses under CPU load. When the budget
   expired early, scrollTo clamped against a short/short-lived document and the
   position was silently dropped (observed: scroll stuck near 0).

   Replace the frame budget with a wall-clock window and stop as soon as the
   target is reached (within a small tolerance), so we keep re-asserting the
   position while late content grows, and never fight the user's own scroll.

2. The test navigated back with a synthetic element.click() and asserted a
   loose lower bound. A synthetic click can outrun the Link preventDefault
   handler, triggering a real anchor navigation (full page reload) that wipes
   the client-side scroll store; and the bottom-anchored forward link made
   Playwright scroll it into view, saving the page-bottom position so the
   lower-bound check passed without verifying real restoration.

   Use a native page.click (waits for actionability, so the handler is
   attached), place the forward link in-viewport at the target via a spacer so
   scroll-into-view is a no-op, return via the browser back action, and assert
   a near-exact restored position.
@Archmonger
Archmonger marked this pull request as ready for review September 21, 2026 04:57
@Archmonger
Archmonger requested a review from a team as a code owner September 21, 2026 04:57
@Archmonger
Archmonger merged commit d90b424 into main Sep 21, 2026
9 checks passed
@Archmonger
Archmonger deleted the fix-scroll-restoration branch September 21, 2026 07:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant