Skip to content

fix(app): stop scrolling toward an end that page zoom cannot reach - #60488

Merged
Hona merged 2 commits into
devfrom
reachable-end-reconcile
Oct 30, 2026
Merged

Hona merged 2 commits into
devfrom
reachable-end-reconcile

Conversation

@Hona

@Hona Hona commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

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 scrollHeight and clientHeight to whole pixels but scrolls in zoomed ones, so the furthest offset it reaches can rest up to 4px short of scrollHeight - 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 within approxEqual (< 1.01px) of it. When the gap is wider, the command never retires: reconcileScroll polls 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"]
Loading

Fix

The @tanstack/virtual-core patch 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.

const reached = approxEqual(targetOffset, this.getScrollOffset())
const restsAtEnd =
  targetOffset === this.getMaxScrollOffset() &&
  targetOffset > this.getScrollOffset() &&
  targetOffset - this.getScrollOffset() <= 4

if (!targetChanged && (reached || restsAtEnd)) {
  // ...
  if (reached && this.getScrollOffset() !== targetOffset) this._scrollToOffset(targetOffset, { behavior: "auto" })
  this.scrollState = null
}
  • Only targets at the max are affected; index and offset targets inside the list still need approxEqual.
  • A new component test scrolls to the end at 110% zoom, scrolls up 40px, then grows the last row, and expects the reader to stay put.

Results

Timeline fixture (timeline-virtualizer.fixture.tsx, 40 rows), resting at the end, then "Scroll to end":

Zoom Gap at the reachable end dev frames in the next second This PR
100% 0 px 1 1
110% 1.09 px 62 1
125%, 150%, 90%, 50% ≤ 0 px 1 1

Scrolled up 40px after the scroll to the end, then the last row grows by 40px:

dev This PR
Distance from the end 41 px: pulled back to the old end, or re-pinned 81 px: stays in place

Visual

<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72" fill="none">
  <defs><clipPath id="v"><rect x="60" y="6" width="120" height="60" rx="3"/></clipPath></defs>
  <rect x="60.5" y="6.5" width="119" height="59" rx="3" stroke="currentColor" opacity=".45"/>
  <g clip-path="url(#v)">
    <g>
      <animateTransform attributeName="transform" type="translate" values="0 -40;0 -40;0 0;0 0;0 -40" keyTimes="0;.2;.4;.9;1" dur="3.5s" repeatCount="indefinite"/>
      <rect x="68" y="14" width="104" height="12" rx="2" fill="currentColor" opacity=".14"/>
      <rect x="68" y="32" width="104" height="12" rx="2" fill="currentColor" opacity=".14"/>
      <rect x="68" y="50" width="104" height="12" rx="2" fill="currentColor" opacity=".14"/>
      <rect x="68" y="68" width="104" height="12" rx="2" fill="currentColor" opacity=".14"/>
      <rect x="68.5" y="86.5" width="103" height="11" rx="2" stroke="currentColor" opacity=".5">
        <animate attributeName="height" values="11;11;11;31;31;11" keyTimes="0;.2;.5;.65;.9;1" dur="3.5s" repeatCount="indefinite"/>
      </rect>
    </g>
  </g>
  <path d="M192 44V22m0 0l-5 5m5-5l5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" opacity=".6"/>
</svg>

Related: #56285, #60034


Added by the opencode bot (AI-generated).

Related: #56285

Copilot AI balanced review requested due to automatic review settings October 30, 2026 05:39
@Hona
Hona requested a review from Brendonovich as a code owner October 30, 2026 05:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 clears endPending. 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).

@Hona
Hona merged commit 2339bda into dev Oct 30, 2026
18 checks passed
@Hona
Hona deleted the reachable-end-reconcile branch October 30, 2026 05:59
@amrkmn amrkmn mentioned this pull request Oct 30, 2026
1 task
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.

2 participants