Skip to content

perf(chat): cut per-message re-renders and O(window) work in the open chat - #1803

Open
f-liva wants to merge 6 commits into
slopus:mainfrom
f-liva:perf/chat-render-path
Open

f-liva wants to merge 6 commits into
slopus:mainfrom
f-liva:perf/chat-render-path

Conversation

@f-liva

@f-liva f-liva commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Summary

On a web tab that stays open for hours on a chat with thousands of messages, every message applied to the open session re-rendered far more than the row it touched. SessionViewLoaded keyed its usageData memo on the session object identity, which applyMessages mints fresh on every batch once the session has usage, so AgentInput and everything below it re-rendered per message (sources/-session/SessionView.tsx). ChatListInternal and NewerEnd subscribed to the whole session object through useSession while reading three booleans, so activeAt heartbeats, usage updates and the 2 s draft saves while typing re-ran the whole list pipeline and FlashList (sources/components/ChatList.tsx). After scrolling to the top once, oldestRenderedId never moved back, so windowing, grouping, copy-text and list building ran over the entire history on every later update. buildAgentTurnCopyTextByMessageId re-joined the text of every completed turn on each of those runs (sources/utils/agentTurnCopy.ts), and FlashList received a fresh contentContainerStyle, onLayout, header and footer on every render.

This PR keys the usage memo on primitives, subscribes the list to exactly the three flags it reads (useShallow), shrinks the render window back to INITIAL_WINDOW when the reader is back at the newest message, caches each turn's copy text by final message id (bounded, wiped at 2000 entries), memoizes the FlashList props on their real inputs, and drops a per-render console.log in ToolView that fired for every errored tool row.

Changes

  • Key SessionViewLoaded's usageData memo on the usage fields and subscribe to hasMessages/isLoaded booleans instead of the messages array
  • Subscribe ChatListInternal and NewerEnd to thinking, pending-permission and controlledByUser via a useShallow selector instead of the whole session
  • Reset the render window to INITIAL_WINDOW in scrollToBottom and when a scroll event lands on the newest message
  • Cache buildAgentTurnCopyTextByMessageId output per final message id while the turn's message identities are unchanged
  • Memoize FlashList contentContainerStyle, onLayout, ListHeaderComponent and ListFooterComponent
  • Drop the per-render console.log of the tool result for errored tool rows in ToolView

Test plan

  • pnpm typecheck (clean)
  • pnpm exec vitest run sources/components/ChatList.test.ts sources/utils/agentTurnCopy.test.ts sources/hooks/useGroupedMessages.test.ts (25 passed; agentTurnCopy.test.ts "reuses the joined text of a turn whose messages are unchanged" fails without the cache)
  • Ran on a self-hosted web deployment: scrolled a multi-thousand-message chat to the top and back to the bottom without jumps or reflow; window shrank back once at the newest message; typing in the composer and incoming messages no longer re-render the list; copy-turn text unchanged

🤖 Generated with Claude Code

Federico Liva and others added 6 commits September 17, 2026 12:41
Once a session had usage, every applyMessages minted a new session object
with a fresh latestUsage, and SessionViewLoaded keyed its usageData memo
on that object identity, so AgentInput (and everything below it) re-rendered
per message for as long as the tab stayed open. The same tree also subscribed
to the whole messages array just to know whether any exist and are loaded.
Key the memo on the usage fields and subscribe to the two booleans only.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
ChatListInternal and NewerEnd used useSession(sessionId) but only read
thinking, whether a permission request is pending, and controlledByUser.
Every other field change on the open session (activeAt heartbeats, usage,
draft saves every 2 s while typing) re-rendered the whole list pipeline and
FlashList. A useShallow selector returning exactly those booleans makes the
list indifferent to the rest of the object.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
…ewest message

oldestRenderedId only ever moved older and was reset only on session change,
so after scrolling to the top of a long chat the window stayed at thousands
of messages for the rest of the tab's life, and every later update of that
session ran windowing, grouping, copy-text and list building over all of
them. Reset the window in scrollToBottom and whenever a scroll event lands
exactly on the newest message; the pin effect re-pins INITIAL_WINDOW and the
dropped rows sit past the far end of the inverted list.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
buildAgentTurnCopyTextByMessageId runs on every change to the rendered window
— every message of the open session — and re-joined the full text of every
completed turn into strings identical to the previous ones, all garbage.
Cache the joined text per final message id and reuse it while the turn's
message identities are unchanged (store messages are immutable), which also
keeps the string identity MessageView's memo compares. Bounded by a wipe at
2000 turns; a wipe costs one rebuild.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
contentContainerStyle, onLayout, ListHeaderComponent and ListFooterComponent
were created fresh on every ChatListInternal render, so FlashList saw a prop
change on each of them every time the list re-rendered. Memoize the style,
the layout handler and the two end components on their real inputs.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
Every render of every tool row whose result is a tool-use error logged the
full result object; in a long-lived tab with big chats that is a steady
stream of console entries (and retained objects in DevTools) for no benefit.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
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