Repository navigation
fix(app): stop scrolling toward an end that page zoom cannot reach - #60488
Conversation
There was a problem hiding this comment.
Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.
An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.
There was a problem hiding this comment.
The fix works: the new component spec fails on dev (the reader ends up 41px from the end, pulled back) and passes with this PR (81px). The full component suite passes (422 tests, run twice) and tsc -b in packages/app is clean. The patch's restsAtEnd only covers targets at the max scroll offset with the view less than 4px short of it, so scrolls to an index or offset inside the list still have to reach their exact position, and when the target moves the new max is still written.
Public API: unchanged. This patches a dependency and the app's test fixture only.
One concern: the new spec's unpin step is timing-dependent and flaked under load (see inline).
Related: #56285
| const frames = () => | ||
| page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))) | ||
|
|
||
| await root.getByRole("button", { name: "Scroll to end", exact: true }).click() |
There was a problem hiding this comment.
This step depends on timing. It assumes everything from the "Scroll to end" click has settled within two rAFs before the synthetic wheel. With parallel workers on a 1-CPU box, it failed 3 of about 30 runs on this PR at the data-pinned="false" assertion: the controls stayed pinned, presumably because a late scroll-end pass re-pinned at the reachable end. With one worker it never failed. Could the reader's scroll-up come first (e.g. scrollTop -= 40 plus the wheel), with a poll for data-pinned="false" after it, instead of relying on the frame count? That would also let the spec drop the frames() helper before the unpin.
There was a problem hiding this comment.
Fixed in 87c1754. The wheel and its 40px scroll now arrive together, and the spec polls for data-pinned="false" after the scroll.
- Cause: you were right. An earlier scroll that arrives within the zoom slack sets
endPending, and TanStack's 150ms scroll-end report pins again once the scroll settles. A bare synthetic wheel unpins without scrolling, so nothing clearsendPending. A real wheel always scrolls, and an upward scroll clears it. - Repro: with a scroll-end pass left pending on purpose, a bare wheel ended pinned 3 of 3 times, and wheel plus scroll-up ended unpinned 3 of 3 times.
frames()stays before the scroll-up: in TanStack 3.17.8, a user scroll does not end an in-flight scroll command. A scroll-up that lands before the command's first reconcile frame keeps it alive at any zoom, so the next resize pulls the reader back. Without the wait, the spec failed 4 of 6 runs on this PR (41px).- Results: 40/40 with 8 workers, 40/40 with 8 workers at 4× CPU throttle, and the whole file 30/30. On
dev, the spec still fails 8/8 (41px).
Under page zoom or fractional display scaling, a scroll to the end of the timeline kept running for five seconds after it landed. For that whole window the renderer scheduled a frame every vsync, and any change to the scroll height (a row measuring 1px differently, a streamed line) scrolled a reader who had already moved up straight back to the end.
Cause
Chrome rounds
scrollHeightandclientHeightto whole pixels but scrolls in zoomed ones, so the furthest offset it reaches can rest up to 4px short ofscrollHeight - clientHeight(#60034 measured 1.0–1.7px at 110–200%). TanStack's scroll command targets exactly that max and only retires once the offset is withinapproxEqual(< 1.01px) of it. When the gap is wider, the command never retires:reconcileScrollpolls on every animation frame until its 5s safety valve, and on any target change it writes the new max.flowchart LR S["scrollToEnd()"] --> W["write max = scrollHeight - clientHeight"] W --> L["browser rests 1.09px short"] L --> R{"reconcileScroll, every frame"} R -->|"|max - offset| >= 1.01"| R R -->|"max changed"| Y["write new max: reader pulled to the end"] R -->|"5s"| X["give up"]Fix
The
@tanstack/virtual-corepatch treats an offset resting within 4px below the max as the end, which retires the command on its next frame. It skips the final exact-landing write there, since that write cannot move the view.approxEqual.Results
Timeline fixture (
timeline-virtualizer.fixture.tsx, 40 rows), resting at the end, then "Scroll to end":devframes in the next secondScrolled up 40px after the scroll to the end, then the last row grows by 40px:
devVisual
Related: #56285, #60034
Added by the opencode bot (AI-generated).
Related: #56285