feat(desktop): single-owner anchored scroll over the virtualized timeline#1123
Open
wpfleger96 wants to merge 7 commits into
Open
feat(desktop): single-owner anchored scroll over the virtualized timeline#1123wpfleger96 wants to merge 7 commits into
wpfleger96 wants to merge 7 commits into
Conversation
0ae7140 to
4127d37
Compare
This was referenced Jun 18, 2026
49db96c to
b218336
Compare
…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>
b218336 to
5b802c1
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
IntersectionObserverand makesuseLoadOlderOnScrollthe sole top-sentinel +fetchOlderowner. Eva's anchoredscrollBycedes 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.useAnchoredScrollResizeObservercedes to the in-flight index restore by readingloadOlderRestoreInFlightRef.current, so a mid-prepend resize cannot stomp the programmaticscrollToOffset. The cede flag is owned end-to-end by thewaitForPrependrAF loop and cleared only when that loop relinquishes scroll ownership atfinishPrepend.restoreScrollPosition. A small writer that re-derives the anchor after a programmatic scroll (syncAnchorAfterProgrammaticScroll) instead of re-introducing a competing scroll-owner. NouseTimelineScrollManagerstate machine.indexByMessageId+getOffsetForIndex) rather than aquerySelectorthat returns null off-screen.fetchOlder/virtualizer presence, soMessageThreadPaneldegrades to the passive anchor.scroll-history.spec.tsassertions match the windowed-DOM behavior (windowed-out rows resolved through the virtualizer, not the live DOM).Stack: #1115 -> this PR