Skip to content

feat(desktop): single-owner anchored scroll over the virtualized timeline#1123

Open
wpfleger96 wants to merge 7 commits into
mainfrom
duncan/timeline-virtualization-hybrid
Open

feat(desktop): single-owner anchored scroll over the virtualized timeline#1123
wpfleger96 wants to merge 7 commits into
mainfrom
duncan/timeline-virtualization-hybrid

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Draft — not merge-ready. Stacks on #1115 and is merge-gated on it landing first.

Closes the macOS beachball on channel switch and the scroll-jump symptoms by re-platforming the index-anchor virtualized timeline onto eva's single-owner anchor (useAnchoredScroll) from #1115, so the virtualized timeline keeps exactly one scroll writer under windowing.

What this changes

  • Single load-older owner. Removes eva's older-history IntersectionObserver and makes useLoadOlderOnScroll the sole top-sentinel + fetchOlder owner. Eva's anchored scrollBy cedes to the index path on a prepend via a front-id/tail-id discriminator (prevFirstMessageIdRef) — one writer by construction, which matters most under the multi-batch prepend that pages until N rows are visible.
  • Unconditional bottom-pin on load, observer cedes mid-jump. On a populated-timeline load the bottom row is re-pinned unconditionally; the long-lived useAnchoredScroll ResizeObserver cedes to the in-flight index restore by reading loadOlderRestoreInFlightRef.current, so a mid-prepend resize cannot stomp the programmatic scrollToOffset. The cede flag is owned end-to-end by the waitForPrepend rAF loop and cleared only when that loop relinquishes scroll ownership at finishPrepend.
  • Minimal restoreScrollPosition. A small writer that re-derives the anchor after a programmatic scroll (syncAnchorAfterProgrammaticScroll) instead of re-introducing a competing scroll-owner. No useTimelineScrollManager state machine.
  • Windowed-out jump convergence. Deep-link / find-bar / oldest-unread targets that are windowed out of the DOM converge through the virtualizer (indexByMessageId + getOffsetForIndex) rather than a querySelector that returns null off-screen.
  • Optional roles. Load-older and convergence roles are gated on fetchOlder/virtualizer presence, so MessageThreadPanel degrades to the passive anchor.
  • Spinner slot. Fixed-height fetch-indicator slot so toggling it never shifts the bottom row.
  • E2E assertions aligned to the virtualized contract. scroll-history.spec.ts assertions match the windowed-DOM behavior (windowed-out rows resolved through the virtualizer, not the live DOM).

Stack: #1115 -> this PR

@wpfleger96 wpfleger96 force-pushed the duncan/timeline-virtualization-hybrid branch from 0ae7140 to 4127d37 Compare June 18, 2026 22:37
@wpfleger96 wpfleger96 force-pushed the duncan/timeline-virtualization-hybrid branch from 49db96c to b218336 Compare June 19, 2026 00:39
Base automatically changed from eva/scroll-single-owner to main June 19, 2026 00:40
@wpfleger96 wpfleger96 marked this pull request as ready for review June 19, 2026 00:41
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 7 commits June 18, 2026 20:44
…achball

Channel switch streamed up to 200 uncontained MessageRows (each with
synchronous shiki markdown), then scrollToBottom("auto") forced a
full-document scrollHeight read-then-write reflow before paint over
every row — the macOS beachball Will reported on v0.3.25.

Windows the main timeline on @tanstack/react-virtual. The day-grouped
section tree is flattened to a typed TimelineItem[] stream plus a
messageId->itemIndex map from one walk (cannot drift), and every
DOM-querySelector scroll path (deep-link, search-jump, jump-to-unread,
scrollToBottom, load-older anchor) is re-pathed onto the index model so
windowing does not silently break jumps to off-screen rows.

Scroll convergence is split: @tanstack/react-virtual owns offset
convergence (its rAF loop re-aims getOffsetForIndex as rows mount and
measure); a pure reducer owns only staleness re-resolution and
termination — re-resolving the target's index by id each frame so a
concurrent prepend/delete cannot strand the loop on a stale index, and
terminating when the target is deleted or a 32-frame cap is hit. The
breaking math lives in lib/ under the .mjs suite.

The thread reply list stays content-visibility:auto rather than
virtualized — it is bounded, unpaginated, ungrouped, and shares the
scroll hook, so virtualizing it would force a second index re-path and a
head/prologue split for no beachball gain. Phase-2 route-chunk preload
warms the agents/channel/lazy-view chunks on idle to clear the
Agents-menu first-visit stall.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… anchor

Loading older messages under virtualization let three writers fight over
scrollTop on overlapping frames, so the anchored row jittered or collapsed
to the top (~33% of prepends) and the library's reconcile spun the full 5s
MAX_RECONCILE_MS valve. Establish a single owner of scroll position across
the whole fetch+restore window:

- useLoadOlderOnScroll restores by scrollTop ONLY (drop scrollToIndex), via
  one getOffsetForIndex(anchorIndex + prepended, "start")[0] + intra-row gap
  write. getOffsetForIndex is a pure measurement-cache read, so no library
  scrollState is set and the reconcile loop has nothing to fight.
- The viewport ResizeObserver in useTimelineScrollManager no longer runs a
  competing restore during a fetch: it skips while isFetchingOlder is true
  (the spinner's clientHeight 720->590 mount-shift fires before the lock is
  set) and otherwise defers to lockedScrollTopRef when the load-older restore
  holds it. MessageTimeline threads isFetchingOlder into the manager.

The defect was invisible to unit tests (jsdom getBoundingClientRect -> 0) and
to static traces; the new load-older E2E drives a real prepend on six fresh
page loads and asserts the anchor holds every run, the scroller genuinely
grew, and the reconcile terminates. emitMockHistory now honors the relay
filter's until/limit so the mock relay paginates like a real one, which the
E2E needs to exercise a genuine older page.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
biome check enforces line-wrapping that biome lint does not. The
load-older test 07 had two over-width statements that passed local lint
but failed the Desktop Core biome check gate. Format-only, no behavior
change.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…line

Re-platforms the index-anchor scroll model onto eva's single-owner anchor
(useAnchoredScroll) so the virtualized timeline keeps one scroll writer
under windowing. Removes eva's older-history IntersectionObserver and makes
useLoadOlderOnScroll the sole top-sentinel/fetchOlder owner; eva's anchored
scrollBy cedes to the index path on a prepend via a front-id/tail-id
discriminator (new prevFirstMessageIdRef). Adds a minimal restoreScrollPosition
writer that re-derives the anchor after a programmatic scroll instead of
re-introducing a competing scroll-owner. Windowed-out jump targets converge
through the virtualizer (indexByMessageId + getOffsetForIndex) rather than a
querySelector that goes null off-screen. Load-older roles are gated on
fetchOlder/virtualizer presence so MessageThreadPanel degrades to the passive
anchor. Reserves a fixed-height spinner slot so the fetch indicator does not
shift the bottom row.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…r mid-jump

The rebase resolution wrapped the ResizeObserver at-bottom re-pin in an isAtBottomNow(container) guard. On initial load the virtualizer grows scrollHeight a frame after the first pin (off-screen rows measure late) with no messages change, so the guard read ">2px from the new floor" and skipped the legitimate load-time floor pin, leaving the timeline not at bottom (scroll-history:190). Cede the whole callback while convergingTargetIdRef.current !== null \u2014 the precise jump-in-flight signal the geometry proxy was approximating, mirroring the layout effect's existing bail \u2014 and restore eva's unconditional at-bottom re-pin.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The load-older index restore (useLoadOlderOnScroll) is the single scroll
writer across a prepend, and the layout effect cedes to it via the isPrepend
bail. The ResizeObserver in useAnchoredScroll did not: it ceded only for
convergence. So when the prepended rows measured late and grew scrollHeight,
the observer fired with the now-windowed-out message anchor, hit
restoreAnchorToMessage's all-gone fallback, pinned to the floor, and stomped
the index restore's correct offset.

Add a shared in-flight flag the index loop sets while it owns scroll, checked
at the top of the ResizeObserver callback alongside the convergence cede. The
restore math and the all-gone fallback are unchanged; the observer simply
defers to the single writer for the duration of the prepend.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Three eva-authored e2e assertions encoded the pre-virtualization layout and
break once the timeline windows rows out of the DOM and positions them with
absolute/translateY:

- relay-reconnect: the reconnect-backfill test expected both the newest and the
  260-rows-old message mounted at once. A virtualized timeline windows the
  oldest rows out while the user sits at the bottom, so assert the newest at the
  bottom, then scroll to the top and poll until the oldest mounts -- the backfill
  depth is now proven by reachability, not simultaneous mounting.
- channels (x2): expectIntroBalancedAroundDayDivider compared the intro->divider
  gap against the divider->message gap for equality. The intro is a flex sibling
  above the timeline while the divider and first row are virtualized items, so
  the two gaps are measured across different layout regimes and no longer match
  within a pixel. Assert the intended reading order instead: intro, divider,
  then the first message, cleanly separated with no overlap.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 force-pushed the duncan/timeline-virtualization-hybrid branch from b218336 to 5b802c1 Compare June 19, 2026 00:48
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