Skip to content

fix(app): treat the reachable end as the end under desktop zoom - #60034

Merged
Hona merged 3 commits into
devfrom
fix-cold-bottom-loop
Oct 28, 2026
Merged

Hona merged 3 commits into
devfrom
fix-cold-bottom-loop

Conversation

@Hona

@Hona Hona commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Under desktop zoom (anything but 100%), opening a session could freeze the app (renderer unresponsive) or leave the timeline blank, and scrolling back down to the bottom never resumed following. Reported on 2.2.0 with a debug export; every renderer sample sat in the cold-mount settle loop in virtualizer.tsx.

Cause

Chrome rounds scrollHeight and clientHeight to whole CSS pixels but scrolls in zoomed ones. Under zoom, the furthest offset it reaches can sit up to ~2px short of scrollHeight - clientHeight (4px at 25%). Every "am I at the end?" check compared against that unreachable value.

Measured gap at the reachable end 100% 110% 125% 150% 175% 200%
max px 0 1.73 1.60 1.33 1.43 1.00
  • Freeze: after scrollToEnd(), TanStack keeps the offset it wrote (944) instead of the one the browser reached (942.857), because they are within its 1.5px read-back tolerance. The cold-mount settle pass waited for either distance to drop below 1px. That never happens, and each pass re-reported the offset, queuing the next pass as a microtask. Microtasks never yield, so the renderer hung.
  • Blank timeline: same wait, without the re-report. The pass gave up, and the content stayed visibility: hidden.
  • No re-follow: handleListScroll requires maxScroll - scrollTop <= 0.5 before it re-pins, which the browser can't reach.
flowchart LR
  S[settle microtask] --> P[pinColdBottom]
  P -->|gap 1.14 > 0.5| E[scrollToEnd: writes 944, browser lands 942.857]
  E -->|scrollOffset 944 != scrollTop| R[reportOffset]
  R -->|queueMicrotask| S
  P --> G{gap > 1?}
  G -->|yes: wait| X[return]
Loading

Captured live on the 2.2.0 code (zoom 175%):

{ "scrollHeight": 1493, "clientHeight": 549, "scrollTop": 942.857, "scrollOffset": 944, "intended": 944 }

Fix

  • atReachableEnd(root): within the zoom slack (4px), only a write finds the end the browser reaches. A write that doesn't move proves the view is already there; one that does is undone before anything paints. At 100% zoom it returns from the existing 0.5px check without writing.

    if (distance <= endEpsilon) return true
    if (distance > zoomSlack) return false
    root.scrollTop = root.scrollHeight
    if (root.scrollTop === top) return true
    root.scrollTop = top
    return false
  • Cold mount: a pinned mount reveals once pinColdBottom() has written and reported the end, instead of re-measuring a distance that zoom can keep open. The "range must catch up" wait now applies only to unpinned mounts.

  • Re-follow: a scroll that arrives within the slack marks the end as pending. Once the scroll settles (TanStack's isScrolling → false, no finger down), atReachableEnd decides whether to pin. Probing mid-scroll would cancel momentum.

  • Post-resize scrollToEnd: uses atReachableEnd, so under zoom it no longer reopens a settled scroll-to-end on every resize. That reopening is the case its comment says can fight keyboard scrolling.

Visual

<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72" fill="none">
  <style>
    .row { fill: currentColor; opacity: .14 }
    .end { stroke: currentColor; opacity: .5; stroke-dasharray: 3 3 }
    .list { animation: settle 2.4s ease-out infinite }
    @keyframes settle { 0% { transform: translateY(-26px); opacity: 0 } 35% { opacity: 1 } 60%, 100% { transform: translateY(0); opacity: 1 } }
    @media (prefers-reduced-motion: reduce) { .list { animation: none } }
  </style>
  <rect x="60" y="6" width="120" height="60" rx="4" stroke="currentColor" opacity=".3"/>
  <g class="list">
    <rect class="row" x="68" y="14" width="80" height="10" rx="2"/>
    <rect class="row" x="68" y="30" width="104" height="10" rx="2"/>
    <rect class="row" x="92" y="46" width="80" height="12" rx="2" style="opacity:.3"/>
  </g>
  <line class="end" x1="60" y1="60.5" x2="180" y2="60.5"/>
</svg>

Notes

  • The component test zooms the page to 110% with a 200.5px viewport, where the end is 1.09px out of reach. It checks that the timeline reveals, then unpins, scrolls up, scrolls back to the bottom, and expects following to resume. Each half fails without its fix.

Added by the opencode bot (AI-generated).

Related: #51423, #58127

@Hona
Hona requested a review from Brendonovich as a code owner October 28, 2026 21:41
Copilot AI balanced review requested due to automatic review settings October 28, 2026 21:41

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.

No blocking concerns. I reproduced the hang on the base branch and it is gone with this change. I ran the new zoom component test against the base source and the timeline stays hidden until the test times out. With this PR, all timeline-virtualizer component tests pass, including 25/25 over repeated runs. The timeline unit tests and tsc -b also pass, and the timeline-cache and mobile-scroll e2e suites fail the same 8 tests on the base branch as on this PR.

The loop now ends: the pinned pass still reports the offset and queues one more pass, but that pass reveals the timeline and clears coldPending, so the queued pass exits immediately. Unpinned mounts still wait for the range to catch up with the scroll, as before. The test is small and clear, and checking that the gap is bigger than 1 px confirms it really covers the zoom case.

Public API: unchanged (internal to the app timeline).

Related: #51423, #58127

@Hona Hona changed the title fix(app): stop the timeline freezing on open under desktop zoom fix(app): treat the reachable end as the end under desktop zoom Oct 28, 2026
@Hona
Hona enabled auto-merge (squash) October 28, 2026 22:01

@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.

Nothing new that needs fixing; my first review left no open points. I checked both halves of the new zoom test separately. Against the base branch, the timeline still stays hidden. Against your first-round commit, following isn't resumed after scrolling back to the bottom. Both pass on this head, 25/25 over repeated runs. The timeline-cache and mobile-scroll e2e suites (51/51), the timeline unit tests and tsc -b pass too.

atReachableEnd only probes by writing scrollTop when the view is between 0.5 and 4 px from the end, and it restores the position if the write moves it. The re-follow check waits for the scroll to settle and is cleared by any upward scroll, so momentum isn't interrupted and a deliberate upward nudge still unpins.

Public API: unchanged (internal to the app timeline).

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