Skip to content

perf(desktop): virtualize message timeline to stop the cold-switch beachball#1110

Closed
wpfleger96 wants to merge 3 commits into
mainfrom
duncan/timeline-virtualization
Closed

perf(desktop): virtualize message timeline to stop the cold-switch beachball#1110
wpfleger96 wants to merge 3 commits into
mainfrom
duncan/timeline-virtualization

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

buzz beachballs on every main-nav transition (channel switch, Home/inbox, Agents menu) and on the first thread-open after a channel switch on v0.3.25. The synchronous block is the message timeline: a channel switch streams up to 200 (CHANNEL_HISTORY_LIMIT) uncontained MessageRows — each with synchronous shiki markdown — then useTimelineScrollManager fires scrollToBottom("auto") from a useLayoutEffect, and alignToBottom does a synchronous scrollHeight read-then-write that forces a full-document reflow over all 200 rows before paint. The Agents-menu stall is a separate cause: a React.lazy route chunk with no preload.

This windows the main timeline on @tanstack/react-virtual, eliminating the reflow at its root, plus a route-chunk preload for the lazy stall.

What changed

Flatten the heterogeneous tree. The day-grouped <section> tree is flattened to a typed TimelineItem[] discriminated-union stream (day-divider / unread-divider / system / message / thread-summary) in a pure lib builder. The same single walk produces the messageId -> itemIndex map, so the stream and the map cannot drift. A single useMemo keyed on [deferredMessages, firstUnreadMessageId] owns both — firstUnreadMessageId is load-bearing because the unread-divider item shifts indices.

Re-path every scroll path onto the index model. Windowing removes off-screen rows from the DOM, so the old querySelector('[data-message-id]') lookups would silently fail for any off-screen target. scrollToMessage, in-channel search-jump, jump-to-oldest-unread, scrollToBottom, and the load-older prepend anchor now resolve through the index map instead of the DOM. Load-older captures the first-visible item index before fetch and re-aims at oldFirstIndex + N after the prepend, restoring the intra-row offset.

Split scroll convergence. @tanstack/react-virtual owns offset convergence — its internal rAF loop re-runs getOffsetForIndex as off-screen rows mount and measureElement corrects their estimated heights. A pure reducer (scrollConvergence.ts) owns only what the library cannot: it re-resolves the target's index by id every frame, so a concurrent prepend or delete that shifts the target re-aims the library instead of stranding it on a stale index; it terminates with converged: false if the target is deleted mid-settle, and caps at 32 frames. A thin rAF adapter (useConvergentScrollToMessage.ts) drives the virtualizer and re-issues scrollToIndex only when the index moves, so a steady settle does not reset the library's stable-frame counter.

Thread reply list gets content-visibility: auto rather than virtualization. It is bounded (replies to one root), unpaginated, ungrouped, and shares useTimelineScrollManager — virtualizing it would force a second index re-path on the shared hook and a head/prologue split for no beachball gain. content-visibility: auto skips layout for off-screen reply rows while keeping them DOM-resident, so the shared deep-link path still works there.

Route-chunk preload (Phase 2). agents.tsx, channels.$channelId.tsx, and ChannelScreenLazyViews.ts export preload*() for their React.lazy chunks; a single idle-gated effect in AppShell.tsx warms all three once startup is ready, clearing the Agents-menu first-visit stall.

Tests

The breaking math lives in lib/ under the .mjs suite: convergence within the 32-frame cap on mixed measured/estimated heights, landing within tolerance, termination when a row keeps re-measuring, target-removed-mid-settle terminating with converged: false, and the staleness re-resolve. The builder tests gate divider placement (3-day channel, unread divider mid-day-2) and map index correctness across new-message / prepend / delete.

npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 3 commits June 17, 2026 23:05
…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>
@wpfleger96 wpfleger96 marked this pull request as draft June 18, 2026 17:07
@wpfleger96

Copy link
Copy Markdown
Collaborator Author

Superseded by #1123 — the hybrid work (virtualization + single-owner anchored scroll + role-3 convergence + spinner slot) lives there, rebased onto eva and now CI-unblocked.

@wpfleger96 wpfleger96 closed this Jun 18, 2026
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