Skip to content

fix(webapp): virtualize MessageFeed to fix slow dialog open (release/v1.46.1)#548

Closed
conradkoh wants to merge 17 commits into
release/v1.46.1from
fix/virtualize-message-feed
Closed

fix(webapp): virtualize MessageFeed to fix slow dialog open (release/v1.46.1)#548
conradkoh wants to merge 17 commits into
release/v1.46.1from
fix/virtualize-message-feed

Conversation

@conradkoh
Copy link
Copy Markdown
Owner

Root Cause

MessageFeed.tsx renders every message as a DOM node via displayMessages.map(...) with no windowing. Per-message DOM is heavy: TaskHeader + TaskProgress + MessageItem (header + Markdown content + attachments × Markdown + footer with action buttons). Easily 100+ nodes per message → tens of thousands of nodes for an active chatroom.

Radix Dialog/AlertDialog/Popover on open runs an O(N) aria-hidden sibling walk via aria-hidden-walk and applies RemoveScroll; both scale with total DOM node count.

Fix

Replace the unbounded displayMessages.map(...) render with a windowed list powered by @tanstack/react-virtual. Only messages near the viewport are mounted to the DOM, so total node count stays bounded (~tens) instead of scaling with chat history length (thousands).

Trade-off

TaskHeader's position: sticky no longer works while inside the absolutely-positioned virtual row. Header becomes an inline section divider instead. Sticky header behavior can be added later via a rangeExtractor.

Verification

  • pnpm --filter webapp typecheck → clean
  • pnpm --filter webapp test → 63 test files, 606 tests, all passing
  • pnpm --filter webapp lint → clean

Manual Smoke Test Plan

  1. Open a chatroom with many messages, confirm only a window of items is in the DOM (devtools elements panel — should see ~10–40 message divs, not thousands)
  2. Scroll up/down — smooth, no jumps
  3. Click "Load older messages" — appends, scroll position preserved
  4. Open any dialog (e.g. agent settings modal) — should now open instantly even with large history
  5. New incoming message — list updates and (if pinned) auto-scrolls to bottom

Refs backlog ps7etzv4zd1e2ewr02g6e5et0d87b0wm.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 24, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
chatroom Ready Ready Preview, Comment May 25, 2026 5:55pm

conradkoh and others added 9 commits May 25, 2026 18:25
…ge chat history

Replace the unbounded `displayMessages.map(...)` render with a windowed
list powered by @tanstack/react-virtual. Only messages near the viewport
are mounted to the DOM, so total node count stays bounded (~tens) instead
of scaling with chat history length (thousands).

This fixes the slow dialog open reported in backlog ps7etzv4zd1e2ewr02g6e5et0d87b0wm
— Radix's aria-hidden sibling walk and RemoveScroll setup are both O(DOM size).
Also improves scroll perf, memory usage, and re-render cost on edit/theme toggle.

Trade-off: TaskHeader's `position: sticky` no longer works while inside
the absolutely-positioned virtual row. Documented for a follow-up.

Refs backlog ps7etzv4zd1e2ewr02g6e5et0d87b0wm.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
Use a message-ID anchor instead of scrollHeight delta to preserve scroll
position when loading older messages. The virtualizer's estimate size makes
scrollHeight unreliable. Capture topmost visible message _id before pagination,
then scrollToIndex after messages grow.
Purging collapses virtualizer total size, invalidating scrollTop and
causing scroll jumps. Remove the purge block from handleScroll and stop
destructuring purgeOldMessages from useMessages (the hook definition is
untouched).
Replace the fixed 200px estimateSize with a running average of the last
50 measured row heights. Prevents scrollbar thumb resizing and improves
scroll-position stability when rows range from 60 to 800+ px.
snapImmediate() sets scrollTop = scrollHeight before the virtualizer has
measured the new row, causing a jiggle. After the snap, wait one
animation frame then scrollToIndex the last message with align:'end'.
Wrap load-more button, 'Beginning of conversation', and loading spinner
in a ResizeObserver-tracked banner div. Pass measured height as
paddingStart to useVirtualizer so the virtual list doesn't shift when
banner visibility toggles.
Compute the latest visible user message from virtualizer.scrollOffset
and render its TaskHeader as a sticky bar above the virtualized list.
Suppress the duplicate header inside the virtualized row. Replaces the
broken CSS position:sticky which doesn't work in absolutely-positioned
virtualizer rows.
The previous useEffect compared against prevMessageCountRef, which is
always equal to displayMessages.length by the time effects run because
useLayoutEffect updates it first. Use a separate ref updated within this
effect so the 'grew' comparison works correctly.
…infinite re-render

Inline arrow refs create a new function identity every render, causing
React to call cleanup(null) then re-invoke measureElement on each DOM
node. When estimateSize uses a running average that drifts, each
measureElement triggers setState, which re-renders, and the loop never
converges → 'Maximum update depth exceeded'.

Fix: pass virtualizer.measureElement as the ref directly. Move per-row
size sampling into a useEffect that reads virtualizer.getVirtualItems()
to avoid setState as a side-effect of measureElement.

Regression test: VirtualizerRefStability.test.tsx verifies the correct
ref pattern. The full loop requires browser layout and is documented
as a manual verification case in the test file's it.todo.
…atch

Move the theme init script from a <script> tag inside ThemeProvider (which
triggers React's 'Encountered a script tag' warning) to next/script with
strategy='beforeInteractive' in layout.tsx.

Remove the mounted gate in ThemeProvider that caused server/client to
render different trees (hydration mismatch). Always render the provider
tree; the init script sets window.__theme before hydration so the SSR
output is consistent.
Replace feedRef.scrollHeight (driven by virtualizer estimates, too small
on first render) with virtualizer.getTotalSize() for the auto-load check.
Gate on measuredSizesByIdRef having at least one entry so the check uses
real measured heights, preventing runaway pagination that loads the
entire history immediately.

Move the effect after useVirtualizer so it has access to both
virtualizer and measuredSizesByIdRef.
The auto-load effect (scrollHeight <= clientHeight) is obsolete in
virtualized mode. Even after gating on first-row-measurement in commit
8f1f0ea, the running-average estimate can still be small enough for
short messages to fall under viewport height, causing runaway pagination.

The 'Load older messages' button + scroll-near-top trigger cover all real
use cases. The user's manual scroll to the top is the intended trigger.
@conradkoh
Copy link
Copy Markdown
Owner Author

Superseded by greenfield reimplementation targeting release/v1.50.0 (linear event timeline, new virtualized panel, no sticky headers). Closing per team decision.

@conradkoh conradkoh closed this May 29, 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