Skip to content

perf(mothership): virtualize chat transcript and isolate input from stream re-renders#5019

Merged
waleedlatif1 merged 4 commits into
stagingfrom
worktree-perf-chat-transcript
Jun 13, 2026
Merged

perf(mothership): virtualize chat transcript and isolate input from stream re-renders#5019
waleedlatif1 merged 4 commits into
stagingfrom
worktree-perf-chat-transcript

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Problem

Long Mothership chats got progressively slower to open and to type in. The transcript rendered every message into the DOM with no windowing — a custom rAF "progressive list" hook only smeared the mount cost across frames without capping it. The input toolbar also re-rendered on every streamed token because the parent transcript re-renders each chunk.

Measured on a cloned production chat scaled to several sizes (harness in scripts/perf/):

n=1032 messages before after
time to fully rendered 26.3s 1.7s
main-thread blocked 21.4s 0.8s
DOM nodes 52,247 1,403
typing-while-streaming (max keystroke) 104ms 26ms

Load cost is now flat regardless of transcript length (n=129 ≈ n=1032).

Changes

  1. Virtualize the message list with @tanstack/react-virtual (useVirtualizer) using dynamic measureElement, stable per-row keys (getItemKey), overscan: 6, and a tuned size estimate. Only the visible window mounts. This is the approach in TanStack's official chat guidance.
  2. Remove useProgressiveList — windowing supersedes rAF-batched incremental mounting (it never capped the DOM-node ceiling). Dead hook deleted.
  3. Memoize UserInput + stabilize its callbacks with useCallback (in MothershipChat and home.tsx) so streaming ticks no longer re-render the entire input toolbar. Verified every prop is stable.
  4. Auto-scroll: keep the existing useAutoScroll for streaming stick-to-bottom (it reads the virtualizer's real scrollHeight), plus a per-chat scrollToIndex(last, {align:'end'}) for initial positioning before paint.

Validation

  • Typecheck ✅, Biome ✅, check:api-validation ✅.
  • Per-component profiling (react-scan): during streaming MothershipChat re-renders ~11× (was 28+) and the markdown renderer dropped out of the hot path entirely (was 247 renders / 792ms).
  • Initial scroll lands at the latest message at every size (screenshots).
  • The streaming render path (useSmoothText / Streamdown) is untouched.

Note on streaming validation

The streaming render path is unchanged and append-follow is verified under virtualization. A live LLM stream could not be exercised locally (the copilot agent backend is unreachable from the dev machine) — continuous tail-follow rests on the unchanged useAutoScroll reading the virtualizer's correct scrollHeight, and should be confirmed on the staging deploy.

Harness

scripts/perf/ adds a headless-Chromium load tester (chat-load-perf.mjs, with optional react-scan + send/stream phases), a scale seeder (seed-chat-scale.mjs), and a streaming-follow validator (stream-validate.mjs). See scripts/perf/README.md.

🤖 Generated with Claude Code

…tream re-renders

Long chats rendered every message into the DOM with no windowing — a custom
rAF "progressive list" only smeared the mount cost across frames without
capping it. At ~1000 messages this was 52k DOM nodes and a 21s main-thread
block on open, and the input toolbar re-rendered on every streamed token.

- Virtualize the message list with @tanstack/react-virtual using dynamic
  measureElement, stable per-row keys, and a tuned size estimate. Only the
  visible window mounts, so load cost is now flat regardless of transcript
  length. Remove the now-redundant useProgressiveList hook.
- Memoize UserInput and stabilize its callbacks (useCallback in MothershipChat
  and home) so streaming ticks no longer re-render the entire input toolbar.
- Keep the existing useAutoScroll for streaming stick-to-bottom (it reads the
  virtualizer's real scrollHeight) and add a per-chat scrollToIndex for initial
  positioning before paint.

Measured on a cloned 1032-message chat: time-to-rendered 26.3s -> 1.7s,
main-thread blocked 21.4s -> 0.8s, DOM nodes 52k -> 1.4k, typing-while-
streaming p-max 104ms -> 26ms. Adds scripts/perf/ harness used to validate.
@vercel

vercel Bot commented Jun 13, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jun 13, 2026 2:06am

Request Review

@cursor

cursor Bot commented Jun 13, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Scroll and virtualization behavior (initial position, chat switch, pending id, pinned streaming tail) is easy to get wrong in long transcripts; streaming stickiness still depends on unchanged useAutoScroll reading correct scroll height.

Overview
Long Mothership transcripts no longer mount every message in the DOM. MothershipChat switches from rAF-batched useProgressiveList to @tanstack/react-virtual, rendering only the visible window with dynamic measureElement, role-based height estimates, overscan, and stable row keys so streaming assistant turns keep smooth-text state when ids change. A custom rangeExtractor always includes the last row so the live stream row is not unmounted mid-chunk.

Initial scroll is reworked: per-chat scrollToIndex(last, { align: 'end' }) before paint (with an UNSCROLLED sentinel and logic to avoid snapping when a pending chat gets an id), while useAutoScroll still handles stick-to-bottom during streaming via a merged scroll-container ref.

UserInput is wrapped in memo, and MothershipChat / home.tsx stabilize submit, stop, and queue handlers with useCallback so input does not re-render on every stream tick. use-progressive-list is removed entirely.

Reviewed by Cursor Bugbot for commit 73bea30. Configure here.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@greptile-apps

greptile-apps Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces rAF-batched progressive rendering with @tanstack/react-virtual windowing for the Mothership chat transcript and memoizes UserInput to decouple it from streaming re-renders. The result is flat O(1) mount cost regardless of transcript length, with measured improvement from 26s → 1.7s initial render and 104ms → 26ms keystroke latency on large chats.

  • Virtualizer setup (mothership-chat.tsx): useVirtualizer with dynamic measureElement, a custom rangeExtractor that keeps the last (live/streaming) row always mounted, and a UNSCROLLED sentinel ref that fires an initial scrollToIndex once per chat without snapping back on pending→persisted transitions.
  • Input memoization (user-input.tsx, home.tsx, mothership-chat.tsx): UserInput is wrapped in React.memo; all callback props in home.tsx and MothershipChat are stabilised with useCallback, and messageQueue reads are moved to a ref so the queue handlers don't need messageQueue in their dep arrays.
  • Hook removal (use-progressive-list.ts): the rAF-batched incremental-mount hook is deleted; windowing subsumes its role and caps the DOM-node ceiling that the hook never addressed.

Confidence Score: 5/5

Safe to merge. The scroll guards, virtualizer key scheme, and memo stabilisation are all correct; streaming tail-follow relies on the unchanged useAutoScroll, which correctly reads the virtualizer-driven scrollHeight.

All changed paths — virtualizer setup, initial-scroll sentinel logic, pending→persisted chat id transition, rangeExtractor last-row pin, and UserInput memo — were traced through their edge cases and behave as intended. The useAutoScroll MutationObserver continues to observe the correct scroll element and detects both characterData updates and childList mutations. No incorrect state transitions, stale closures, or missing guards were found.

No files require special attention. Live-stream follow-through on staging is the remaining validation gap, but it rests on the unchanged useAutoScroll logic.

Important Files Changed

Filename Overview
apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx Core change — replaces progressive-list rendering with TanStack Virtual windowing. Virtualizer setup, scroll guards, rangeExtractor pin, and initial-scroll sentinel logic are all correct and well-documented.
apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx Wraps the existing forwardRef component in React.memo to prevent re-renders during streaming. Minimal, surgical change with no functional impact.
apps/sim/app/workspace/[workspaceId]/home/home.tsx Converts handleSubmit and handleStopGeneration to useCallback to provide stable references for the memoized UserInput. Dependencies are correctly specified.
apps/sim/hooks/use-progressive-list.ts Deleted — superseded by virtualizer windowing, which caps DOM-node count absolutely rather than merely spreading mount cost across frames.

Sequence Diagram

sequenceDiagram
    participant Home as home.tsx
    participant MC as MothershipChat
    participant VZ as useVirtualizer
    participant AS as useAutoScroll
    participant DOM as ScrollElement

    Home->>MC: messages[], chatId, isSending
    MC->>AS: useAutoScroll(isStreamActive)
    AS-->>MC: autoScrollRef
    MC->>VZ: useVirtualizer(count, estimateSize, rangeExtractor)
    VZ-->>MC: virtualizer instance

    MC->>DOM: attach via setScrollElement callback ref
    DOM-->>AS: "containerRef.current = el"
    DOM-->>VZ: getScrollElement returns el

    MC->>VZ: useLayoutEffect scrollToIndex(lastIndex, end)
    VZ->>DOM: "scrollTop = estimated offset of last item"

    loop Streaming token arrives
        AS->>DOM: MutationObserver fires on characterData
        AS->>DOM: "if sticky then scrollTop = scrollHeight"
    end

    loop User scrolls up
        AS->>AS: detach sticky
        VZ->>DOM: unmount out-of-view rows
        VZ->>DOM: keep lastIndex mounted via rangeExtractor
    end
Loading

Reviews (7): Last reviewed commit: "fix(mothership): don't re-scroll when a ..." | Re-trigger Greptile

Comment thread scripts/perf/chat-load-perf.mjs Outdated
Comment thread scripts/perf/stream-validate.mjs Outdated
@greptile-apps

greptile-apps Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces the rAF-based progressive rendering hook with @tanstack/react-virtual (useVirtualizer) and memoizes UserInput + stabilizes its callbacks, cutting DOM nodes from ~52K to ~1.4K and time-to-render from 26 s to 1.7 s on a 1 032-message transcript.

  • Virtualized message list: each chat view gets a correctly sized sizer div (getTotalSize()) with absolute-positioned virtual items; rows are measured precisely via measureElement and keyed stably so streaming placeholders survive message-ID transitions.
  • Input isolation: UserInput is wrapped in memo(); handleSubmit, handleStopGeneration, and the queue callbacks are converted to useCallback with verified stable deps, so streaming ticks no longer re-render the toolbar.
  • Scroll logic simplified: scrollOnMount is dropped from useAutoScroll and replaced with a one-shot virtualizer.scrollToIndex(last, {align:'end'}) in useLayoutEffect, keyed on chatId so it re-fires on chat switches without re-firing within the same chat during streaming.

Confidence Score: 4/5

The core virtualization and memoization changes are well-structured and the streaming auto-scroll path is preserved through the existing useAutoScroll hook. The main outstanding risk is streaming scroll follow, which the author explicitly flags as needing staging confirmation since the backend was unreachable locally.

The virtualization implementation follows TanStack Virtual's recommended chat pattern, key stability is carefully handled for streaming placeholders, and callback stabilization in home.tsx is correct (getCurrentRequestId and stopGeneration are both stable useCallbacks). The dev scripts have a SQL injection pattern and a hardcoded email that would break the tool for other developers, but these don't affect the production runtime. The one runtime concern — empty AssistantMessageRow still occupying rowGap height in the virtual wrapper — is an edge case cosmetic issue. Streaming scroll follow is the highest-risk untested path.

The streaming scroll interaction between useAutoScroll's MutationObserver and the virtualizer's ResizeObserver-driven sizer height updates in mothership-chat.tsx should get close attention on the staging deploy. The perf scripts in scripts/perf/ need the hardcoded email fixed before other team members can use them.

Security Review

  • SQL injection in dev scripts (scripts/perf/chat-load-perf.mjs, stream-validate.mjs, seed-chat-scale.mjs): the --email and --source CLI arguments are interpolated directly into SQL strings executed via psql. These are local-only tools targeting the developer's own database, so exploitability is low, but the pattern is unsafe and should use parameterized queries or at minimum input sanitization.

Important Files Changed

Filename Overview
apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx Core change: replaces progressive-list rendering with TanStack useVirtualizer (dynamic measureElement, overscan: 6, stable getItemKey). Scroll setup is split between a callback ref (for useAutoScroll) and scrollElementRef (for the virtualizer's getScrollElement). Initial positioning is via virtualizer.scrollToIndex in useLayoutEffect, keyed on chatId. One minor issue: AssistantMessageRow returning null still occupies the pb-6 wrapper height inside each virtual item.
apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx Wrapped with memo() to prevent re-renders on every streaming tick. The internal implementation is unchanged; the named export now points to the memoized wrapper.
apps/sim/app/workspace/[workspaceId]/home/home.tsx Converted handleStopGeneration and handleSubmit to stable useCallback references. Both getCurrentRequestId and stopGeneration are stable (the former is a useCallback([], []) in useChat), so handleStopGeneration is genuinely stable across re-renders.
apps/sim/hooks/use-progressive-list.ts File deleted. The rAF-based progressive rendering hook is superseded by the virtualizer which caps DOM nodes at the visible window rather than just spreading mount cost across frames.
scripts/perf/chat-load-perf.mjs New headless-Chromium performance harness. Hardcodes the author's email as the default (waleed@sim.ai), making it non-functional for other developers without --email. Both the email and chatId parameters are directly interpolated into SQL strings passed to psql, creating an injection pattern.
scripts/perf/seed-chat-scale.mjs Seeds synthetic chats by cycling messages from a source chat. SOURCE chatId is interpolated directly into the SQL. The SQL logic itself is correct; cleanup instruction is documented.
scripts/perf/stream-validate.mjs Streaming correctness probe that asserts monotonic growth and scroll-pinning. Same hardcoded email default and SQL injection pattern as chat-load-perf.mjs.

Sequence Diagram

sequenceDiagram
    participant App as MothershipChat
    participant VR as useVirtualizer
    participant AS as useAutoScroll
    participant DOM as ScrollContainer (DOM)

    App->>DOM: "setScrollElement(el) — sets both scrollElementRef & autoScrollRef"
    App->>VR: getScrollElement() → scrollElementRef.current
    Note over App,VR: useLayoutEffect fires once per chatId
    App->>VR: "scrollToIndex(last, {align:'end'})"
    VR->>DOM: "el.scrollTop = estimatedBottom"

    loop Streaming tokens arrive
        App->>DOM: React renders updated AssistantMessageRow text
        DOM-->>AS: MutationObserver (characterData) fires
        AS->>DOM: "el.scrollTop = el.scrollHeight (sticky scroll)"
        DOM-->>VR: ResizeObserver (item height changed)
        VR->>DOM: updates sizer style.height (getTotalSize)
    end

    Note over App,AS: stream ends → useEffect cleanup
    AS->>DOM: final scrollToBottom() at true scrollHeight
Loading

Comments Outside Diff (3)

  1. scripts/perf/chat-load-perf.mjs, line 631-637 (link)

    P2 security SQL injection in psql query

    The EMAIL value (from --email CLI arg) is directly interpolated into the SQL string passed to psql. A value like ' OR '1'='1 would alter the query logic. seed-chat-scale.mjs has the same pattern with the SOURCE chatId. Even for a local dev tool that talks to a local database, parameterized queries prevent accidental breakage and keep the pattern safe: psql supports positional params via the -v flag, or you can use a separate prepare/execute approach. The same issue exists in stream-validate.mjs around the equivalent mintSessionCookie query.

  2. scripts/perf/chat-load-perf.mjs, line 611 (link)

    P2 Hardcoded author email bakes in a single-user default

    waleed@sim.ai is the PR author's personal email. Any other team member running this script without passing --email will get "No live session for waleed@sim.ai" and the script will exit with an error. Consider removing the default entirely (let it throw a clear usage error), or document that --email is required. The same default appears in stream-validate.mjs.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

  3. apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx, line 340-375 (link)

    P2 Empty AssistantMessageRow now occupies pb-6/pb-4 vertical space

    AssistantMessageRow returns null when an assistant message has no renderable content and is not streaming. Previously that collapsed to zero height. Now every virtual item, including those returning null, lives inside a wrapper div with styles.rowGap (pb-6 = 24 px in mothership-view), so such messages leave a visible blank gap in the virtual list. This is an edge case (a finalised assistant turn with no content), but any such messages in existing chats will produce unexpected whitespace. A small guard — checking renderable content before rendering — or moving the null-return logic up to the virtual item's own render and conditionally omitting the wrapper would fix it.

Reviews (2): Last reviewed commit: "perf(mothership): virtualize chat transc..." | Re-trigger Greptile

… gap, per-role estimate

- Pending-chat initial scroll (Cursor, high): seed the scrolled-chat guard with
  a unique sentinel so a not-yet-persisted chat (undefined chatId) with messages
  still scrolls to bottom instead of being treated as already-scrolled.
- Streaming-row flash (review of virtualization): pin the last row in the
  rendered window via rangeExtractor so scrolling it out of the overscan window
  and back mid-stream can't unmount/remount it and re-fire the reveal fade.
- Empty assistant row gap (Greptile): move the row gap from the virtual-item
  wrapper into the row content so a null-rendering (finalised, empty) assistant
  turn collapses to zero height instead of leaving a pb-6 blank slot.
- Per-role row-height estimate instead of a single blended constant, so the
  scrollbar drifts less as off-screen rows resolve.
- Drop the scripts/perf harness from the PR.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

Addressed all review findings in 6d94ee8:

  • Pending-chat initial scroll (Cursor, High) — seed the scroll guard with a unique UNSCROLLED sentinel so an id-less chat with messages still scrolls to bottom.
  • Empty assistant-row gap (Greptile, outside-diff) — moved the row gap from the virtual-item wrapper into the row content, so a null-rendering finalised assistant turn collapses to zero height instead of leaving a pb-6 blank slot.
  • Streaming-row flash (raised in review of the virtualization) — pin the last/live row in the rendered window via rangeExtractor so scrolling it out of the overscan window and back mid-stream can't unmount/remount it and re-fire the smooth-text reveal fade. Verified empirically: scrolled a 1032-message chat to the very top and confirmed the last row stays mounted.
  • Per-role row-height estimate instead of a single blended constant, to reduce scrollbar drift.
  • SQL-injection / hardcoded-email findings (Greptile, P2) — moot: the scripts/perf/ harness was removed from this PR.

Perf is unchanged by these fixes (n=1032: ~1.7s to rendered, ~0.8s main-thread blocked, 1.4k DOM nodes). Typecheck, Biome, and check:api-validation all green.

@greptile review
@cursor review

Restoring pt-3/pt-2 on the user row keeps the exact inter-row rhythm from the
old space-y-6 layout: assistant→user gaps stay 24+12px and user→assistant stay
24px, instead of becoming uniform when the gap moved to per-row pb.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

Pushed 4fc3ad7 — restored the user-row top spacing (pt-3/pt-2) so the virtualized layout's inter-row rhythm exactly matches the original space-y-6 spacing. This is the final state.

@greptile review
@cursor review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

The per-chat initial-scroll guard treated undefined→persisted-id as a chat
switch and scrolled to the bottom again, yanking the viewport down if the user
had scrolled up mid-stream. Treat that transition as the same conversation:
adopt the id without re-scrolling. Genuine chat switches (id→different id) still
re-scroll.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

Pushed 73bea30 — the per-chat scroll guard no longer re-scrolls when a pending chat persists its id (same conversation); genuine chat switches still re-scroll. Typecheck/Biome green; existing-chat load still lands at the bottom.

@greptile review
@cursor review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

Latest commit is 73bea30 (pending-chat scroll-guard fix). All prior findings from both bots are fixed and their threads resolved; PR is MERGEABLE/CLEAN with 0 open threads. Requesting a fresh pass on the current HEAD:

@greptile review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 73bea30. Configure here.

@waleedlatif1 waleedlatif1 merged commit 005fa10 into staging Jun 13, 2026
15 checks passed
@waleedlatif1 waleedlatif1 deleted the worktree-perf-chat-transcript branch June 13, 2026 06:06
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