Skip to content

perf(desktop): virtualize unbounded lists and warm the emoji index#1089

Merged
wpfleger96 merged 11 commits into
mainfrom
duncan/desktop-list-virtualization
Jun 17, 2026
Merged

perf(desktop): virtualize unbounded lists and warm the emoji index#1089
wpfleger96 merged 11 commits into
mainfrom
duncan/desktop-list-virtualization

Conversation

@wpfleger96

Copy link
Copy Markdown
Collaborator

Opening threads, channels, and menus fired the macOS busy cursor because the desktop renders unbounded heavy-Markdown lists 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: auto skips offscreen layout and paint while keeping every row mounted. Used where in-DOM state must survive: deep-link querySelector targets, open <details>, and drag-and-drop reorder — windowing would unmount those before they could be reached.
  • useDeferredValue defers a single large parse so the surrounding chrome commits first.

Changes

Surface Treatment Why
VirtualizedList new primitive Windowed render, dynamic row height, sticky-header slot, external scroll container
InboxListPane windowed Clean self-owned scroll list
PulseView (notes + agent-activity) windowed Two lists share one scroll container with a sticky composer; uses the sticky-header slot + scroll-margin offset
ForumView (post list) windowed External scroll path preserves data-scroll-restoration-id
RelayMembersSettingsCard windowed Single clean scroll region
ForumThreadPanel (reply list) content-visibility Deep-link locates the target reply via querySelector + scrollIntoView
AgentSessionTranscriptList content-visibility ThoughtItem open/closed state lives in a DOM <details>; list does not own its scroll container
MembersSidebar content-visibility One inner scroll container spans sticky section titles, a <details> archived section, and heterogeneous search-mode lists
CustomChannelSection content-visibility Rows are drag-sortable
ChannelCanvas useDeferredValue One large Markdown document parsed on open
EmojiPicker warm init({ data }) at idle Prebuilds the emoji-mart search index before first popover open; fixes every picker consumer from the shared component

The main timeline (MessageTimeline / useTimelineScrollManager) is intentionally untouched — it already has #1022's useDeferredValue and its scroll manager is deeply DOM-coupled (scrollToMessage deep-link, ResizeObserver autoscroll, lock/restore). Windowing it is a separate, higher-risk change.

wpfleger96 added a commit that referenced this pull request Jun 17, 2026
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>
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 11 commits June 17, 2026 14:47
…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>
@wpfleger96 wpfleger96 force-pushed the duncan/desktop-list-virtualization branch from dd603ea to 1c43644 Compare June 17, 2026 18:47
wpfleger96 pushed a commit that referenced this pull request Jun 17, 2026
@wpfleger96

Copy link
Copy Markdown
Collaborator Author

Desktop list virtualization — screenshot evidence

Captured from the seeded Playwright spec (desktop/tests/e2e/virtualization-screenshots.spec.ts), each shot assertion-gated and green in CI.

Pulse windowed feed — sticky composer while scrolled

PulseView windowed feed scrolled mid-list with the composer staying pinned at the top, proving the translateY(start − scrollMargin) offset holds under a non-zero scrollTop.

01-pulse-sticky-composer

Forum deep-link to an offscreen reply

ForumThreadPanel deep-linking to the 25th reply — offscreen at open — scrolled into the viewport, confirming a never-painted contain-intrinsic-size row renders correctly when targeted.

02-forum-deeplink-offscreen

Members sidebar — both sticky titles alive in search

MembersSidebar in search mode with both sticky section titles ("Members" and "Not in this channel") rendered simultaneously under content-visibility.

03-members-both-sticky-titles

Drag-and-drop section reorder

Verified by the green dragTo assertion in the spec: section DOM order flips [Priority, Archive] → [Archive, Priority] with rows staying committed under content-visibility. No screenshot — the reorder is not visually distinguishable in a static frame, so the assertion carries the proof.

@wpfleger96 wpfleger96 enabled auto-merge (squash) June 17, 2026 18:51
@wpfleger96 wpfleger96 merged commit a4fbebb into main Jun 17, 2026
26 checks passed
@wpfleger96 wpfleger96 deleted the duncan/desktop-list-virtualization branch June 17, 2026 19:01
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