perf(desktop): virtualize unbounded lists and warm the emoji index#1089
Conversation
The list-virtualization PR closes a correctness residual about absolute-position / scrollMargin geometry under live dynamic measurement and the content-visibility "rows stay committed" invariant. Those claims need empirical proof, not just review. This adds one Playwright spec that captures four shots (Pulse sticky-composer-while-scrolled, forum deep-link to a far-down/never-painted reply, members search with both sticky titles, and a custom-section dnd reorder), each gated by an assertion so a geometry regression fails the run instead of silently producing a misleading image. Fixtures are seeded in e2eBridge.ts (large enough to overflow each viewport) since the prior mock fixtures were too small to demonstrate windowing. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…tual Headless windowed-list component supporting dynamic row height via measureElement. Contract supports optional sticky-header slot and optional externally-owned scrollElement for surfaces that share a scroll container with non-row siblings. No surfaces migrated yet — this commit adds only the primitive and the new dependency. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The inbox list rendered every item synchronously with a heavy Markdown preview per row, so opening a large inbox fired the macOS busy cursor. Window the rows with VirtualizedList against the pane's existing scroll container (passed as scrollRef), keeping the home-inbox-list test hook and overscroll behavior intact. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Both Pulse tabs rendered every row synchronously — the notes timeline and the grouped agent-activity feed each map an unbounded list of heavy Markdown cards into the same scroll container, so opening a busy Pulse fired the busy cursor. Window both lists against the pane's scroll container, leaving the sticky composer in normal flow above them. Extends VirtualizedList with a measured scrollMargin so the windowing math accounts for non-row content (the sticky composer) above the rows in a shared scroll container — without it the visible range is offset by the header height and the wrong rows render near the top. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The forum post list rendered every ForumPostCard (heavy Markdown) eagerly on open, contributing to the busy-cursor freeze. Migrate it onto VirtualizedList via the external-scroll path so the existing data-scroll-restoration-id container keeps owning scroll (TanStack Router restores scrollTop against it). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The forum reply list mounts every ReplyRow (heavy Markdown) eagerly. It is a heterogeneous scroll region (post header, replies header, rows, composer) and carries a deep-link that locates the target reply via querySelector + scrollIntoView — windowing would unmount the target before it could be found. content-visibility:auto skips offscreen layout/paint while keeping every row in the DOM, so the deep-link still resolves. Adds a shared content-visibility-auto utility reused by other in-DOM-state surfaces. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The ACP transcript mounts every item (heavy Markdown) on open. ThoughtItem holds open/closed state in a DOM <details>, and the list does not own its scroll container — windowing would both reset that state on scroll-out and need an external scroll element. content-visibility:auto skips offscreen layout/paint while leaving every item mounted, so <details> state survives. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
RelayMembersSettingsCard owns a single clean scroll region, so it migrates to VirtualizedList (windowed). MembersSidebar and CustomChannelSection do not window cleanly: the sidebar shares one inner scroll container across sticky section titles, a <details> archived section, and two heterogeneous search-mode lists; the channel section is drag-sortable. Both keep all rows in the DOM via content-visibility so sticky headers, <details> state, and drag-and-drop survive. Adds a compact content-visibility-auto-row variant for short single-line channel rows. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The canvas renders one potentially large Markdown document synchronously on open. Wrap it in useDeferredValue so the surrounding chrome commits first and the heavy parse reconciles in a low-priority pass. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The reaction popover froze the cursor on first open because emoji-mart builds
its ~1.8k-emoji search index synchronously inside init(), which <Picker> calls
on mount. Warm init({ data }) once at idle from EmojiPicker's module so the
index is prebuilt before any popover opens; init no-ops on its Data singleton
afterward, so the Picker's mount-time init skips the build. Fixes all picker
consumers from the single shared component.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The list-virtualization PR closes a correctness residual about absolute-position / scrollMargin geometry under live dynamic measurement and the content-visibility "rows stay committed" invariant. Those claims need empirical proof, not just review. This adds one Playwright spec that captures four shots (Pulse sticky-composer-while-scrolled, forum deep-link to a far-down/never-painted reply, members search with both sticky titles, and a custom-section dnd reorder), each gated by an assertion so a geometry regression fails the run instead of silently producing a misleading image. Fixtures are seeded in e2eBridge.ts (large enough to overflow each viewport) since the prior mock fixtures were too small to demonstrate windowing. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
CI Biome check flagged two formatting spots in the new spec: the composer.evaluate(...) call args and the rows.map(...) ternary needed line-splitting per the formatter. No logic change. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
dd603ea to
1c43644
Compare
Desktop list virtualization — screenshot evidenceCaptured from the seeded Playwright spec ( Pulse windowed feed — sticky composer while scrolledPulseView windowed feed scrolled mid-list with the composer staying pinned at the top, proving the Forum deep-link to an offscreen reply
Members sidebar — both sticky titles alive in search
Drag-and-drop section reorderVerified by the green |



Opening threads, channels, and menus fired the macOS busy cursor because the desktop renders unbounded heavy-
Markdownlists synchronously (no virtualization existed anywhere in the app) and emoji-mart builds its search index synchronously when the reaction popover first opens. #1022 deferred only the main timeline and thread reply list, which left the freeze firing about half as often. This addresses the whole class.Approach
One windowing primitive plus targeted treatments per surface, chosen by what each surface's rows require.
VirtualizedList(@tanstack/react-virtual, new dep) windows lists whose rows have no in-DOM state. The contract supports an optional non-virtualized sticky-header slot and an optional externally-owned scroll container, so surfaces that share their scroll region with non-row siblings still migrate.content-visibility: autoskips offscreen layout and paint while keeping every row mounted. Used where in-DOM state must survive: deep-linkquerySelectortargets, open<details>, and drag-and-drop reorder — windowing would unmount those before they could be reached.useDeferredValuedefers a single large parse so the surrounding chrome commits first.Changes
VirtualizedListInboxListPanePulseView(notes + agent-activity)ForumView(post list)data-scroll-restoration-idRelayMembersSettingsCardForumThreadPanel(reply list)content-visibilityquerySelector+scrollIntoViewAgentSessionTranscriptListcontent-visibilityThoughtItemopen/closed state lives in a DOM<details>; list does not own its scroll containerMembersSidebarcontent-visibility<details>archived section, and heterogeneous search-mode listsCustomChannelSectioncontent-visibilityChannelCanvasuseDeferredValueEmojiPickerinit({ data })at idleThe main timeline (
MessageTimeline/useTimelineScrollManager) is intentionally untouched — it already has #1022'suseDeferredValueand its scroll manager is deeply DOM-coupled (scrollToMessagedeep-link,ResizeObserverautoscroll, lock/restore). Windowing it is a separate, higher-risk change.