Repository navigation
🏎️ perf: Re-render Only the Tail Rows When a Message Is Sent - #16572
Merged
Merged
Conversation
Every send moved the thread's tail (isSubmitting, latestMessageId, latestMessageDepth, and a handleContinue rebound per turn), and the memoized row context listed all of them as dependencies, so each move handed every row a new context object and re-rendered the whole thread: three commits of ~12,000 component renders on a 246-message thread, one of them inside the Enter keypress. - Keep the row context object stable; the tail values are ref-backed getters for call-time reads, and rows receive them as props that the row comparator (isSameTailRelation) only reacts to when the row enters or leaves the tail. - Fork reads the split target through a getter at click time. - HoverButtons subscribes to a boolean isSubmitting context and a stable operations context instead of the combined messages-view context; Fork and Feedback are memoized so the per-send toolbar pass stops there. - Make handleContinue referentially stable. - Cache the Intl formatters behind getMessageTimestamp per locale and clock. - Add a submit-lag benchmark (e2e/benchmarks-tree) that reports long tasks, Enter-to-row latency, a CPU profile and per-commit render reasons.
danny-avila
force-pushed
the
danny-avila/message-rendering-lag-9549f7
branch
from
September 30, 2026 14:01
22a2e03 to
9c979e4
Compare
danny-avila
marked this pull request as ready for review
September 30, 2026 14:08
8 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Sending a message in a long conversation froze the page. The Enter keypress blocked the main thread until every message row re-rendered, and two more full-thread re-renders followed as the server acknowledged the turn. Each send moves the thread's tail:
isSubmittingflips,latestMessageIdandlatestMessageDepthmove to the new placeholder, then move again when the server's ids arrive. The memoized row context (useMemoizedChatContext) listed all of those, plus ahandleContinuerebound every turn, as dependencies of the object it hands every row. So every change gave every row a newchatContext, broke each row'sContentRender/MessageRendermemo, and re-rendered its header, timestamps, sibling switch and toolbar, although only the two rows entering or leaving the tail can change. On a 246-message thread that is three commits of ~12,000 component renders each.getMessageTimestampmade each one slower by constructing twoIntlformatters (plus a locale probe) per call, twice per row. In the production build it was the single most expensive function of a send.After this change a send re-renders the rows entering or leaving the tail, plus one pass over each row's hover toolbar, because a send hides every row's rerun control. The toolbar updates on its own subscription, and its fork and feedback controls skip that pass. Timestamp formatters are built once per locale and clock format.
Production build, headless Chromium on a desktop machine, one run each:
How it works
The row context object keeps its identity across sends. The values that move with the tail are ref-backed getters for call-time reads, and the row receives them as props that its comparator only reacts to when this row's relation to the tail changes:
Rows read the tail only by comparing it with their own id and depth, and by whether it is known. So a row the comparator skips would render the same output, except for one value sent to the server: the fork split target, which
Forknow reads through a getter at click time.Large commits after pressing Enter on the 246-message thread (production build, component renders per commit):
The hover toolbar used to learn about the send through props threaded from the row, and through
useOptionalMessagesOperations. That hook read the combinedMessagesViewContext, whose value changes on every submission and tail move.MessagesViewProvidernow also provides the stable operations and a booleanisSubmittingas separate contexts.HoverButtonssubscribes to those two, so it re-renders when a send starts or settles, and for no other context change.ShareMessagesProviderprovides the operations context too.Type of change
Testing
Measured with the new
e2e/benchmarks-tree/submit-lag.perf.spec.tsagainst the mock stack. It seeds a long thread, presses Enter, and reports long tasks, the Enter-to-row latency, a CPU profile of the first 1.5 s, and a per-commit render tally with the props, contexts or hook state that caused each render (a React DevTools hook stub). It can seed an exported conversation instead of the synthetic thread (SUBMIT_LAG_SOURCE); the 48-message row above is an exported agent conversation with 329 tool calls.The mock e2e specs for hover actions, thread renderers, the message tree, chat and edit layout pass against the built client.
Tested environments/configuration:
Automated tests:
ContentRender.tail.spec.tsxrenders a thread through the real row wrappers and asserts that a send, the streaming id hydrating, and the run settling leave settled rows un-rendered. It fails on the base revision.useMemoizedChatContext.spec.tsx: the context object stays stable while the tail and submission state move, and its getters return current values.HoverButtons.spec.tsx: rerun controls hide and return as a send starts and ends, without the row re-rendering.Fork.spec.tsx: the split target is the tail at click time.messages.test.ts:isSameTailRelationcases, and timestamp formatters reused per locale and clock format.npx tsc --noEmitinclient.Screenshots / recordings
No user-facing change.
Risk / compatibility
HoverButtonsno longer takes anisSubmittingprop; it reads the new boolean context, which defaults to false outside a messages view.ForktakesgetLatestMessageIdinstead oflatestMessageId.useMessagesOperationsanduseOptionalMessagesOperationsread the new operations context, which bothMessagesViewProviderandShareMessagesProviderprovide. A component that providesMessagesViewContextby hand would also have to provide the operations context. The legacy assistants row (MessageParts) keeps its existing subscriptions.Streaming still re-renders the chat shell on every chunk (
ChatView,ChatForm, the sidebar provider and the message nav), independent of thread length. That is left for a follow-up.