Skip to content

🏎️ perf: Re-render Only the Tail Rows When a Message Is Sent - #16572

Merged
danny-avila merged 1 commit into
devfrom
danny-avila/message-rendering-lag-9549f7
Sep 30, 2026
Merged

danny-avila merged 1 commit into
devfrom
danny-avila/message-rendering-lag-9549f7

Conversation

@danny-avila

Copy link
Copy Markdown
Collaborator

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: isSubmitting flips, latestMessageId and latestMessageDepth move to the new placeholder, then move again when the server's ids arrive. The memoized row context (useMemoizedChatContext) listed all of those, plus a handleContinue rebound every turn, as dependencies of the object it hands every row. So every change gave every row a new chatContext, broke each row's ContentRender/MessageRender memo, 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. getMessageTimestamp made each one slower by constructing two Intl formatters (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:

Thread Enter → user row painted Main-thread blocking in the first 1.5 s
48 messages, 329 tool calls (a real agent conversation) 91 ms → 63 ms 147 ms (2 tasks) → 64 ms (1 task)
246 messages (synthetic) 248 ms → 132 ms 610 ms (3 tasks) → 132 ms (1 task)

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:

export function isSameTailRelation(prev: TailRelationProps, next: TailRelationProps): boolean {
  return (
    (prev.latestMessageId == null) === (next.latestMessageId == null) &&
    (prev.latestMessageId === prev.message?.messageId) ===
      (next.latestMessageId === next.message?.messageId) &&
    (prev.latestMessageDepth === prev.message?.depth) ===
      (next.latestMessageDepth === next.message?.depth)
  );
}

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 Fork now reads through a getter at click time.

Large commits after pressing Enter on the 246-message thread (production build, component renders per commit):

before                                    after
#0   +164 ms  12,236  Enter (sync)        #0   +82 ms  6,572  Enter (sync): toolbars + tail rows
#8   +424 ms  12,100  server ids          #8  +321 ms    838  server ids: tail rows
#16  +634 ms  11,908  response id         —

The hover toolbar used to learn about the send through props threaded from the row, and through useOptionalMessagesOperations. That hook read the combined MessagesViewContext, whose value changes on every submission and tail move. MessagesViewProvider now also provides the stable operations and a boolean isSubmitting as separate contexts. HoverButtons subscribes to those two, so it re-renders when a send starts or settles, and for no other context change. ShareMessagesProvider provides the operations context too.

Type of change

  • Performance improvement
  • Tests / tooling / CI

Testing

Measured with the new e2e/benchmarks-tree/submit-lag.perf.spec.ts against 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.

npx playwright test -c e2e/playwright.config.tree-perf-prod.ts submit-lag
npx playwright test -c e2e/playwright.config.tree-perf.ts submit-lag

The mock e2e specs for hover actions, thread renderers, the message tree, chat and edit layout pass against the built client.

Tested environments/configuration:

  • Browser: headless Chromium (Playwright), production and dev builds
  • Mock e2e stack (fake model, in-memory Mongo)

Automated tests:

  • ContentRender.tail.spec.tsx renders 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: isSameTailRelation cases, and timestamp formatters reused per locale and clock format.
  • Focused client suites (27 suites, 364 tests) and npx tsc --noEmit in client.

Screenshots / recordings

No user-facing change.

Risk / compatibility

HoverButtons no longer takes an isSubmitting prop; it reads the new boolean context, which defaults to false outside a messages view. Fork takes getLatestMessageId instead of latestMessageId. useMessagesOperations and useOptionalMessagesOperations read the new operations context, which both MessagesViewProvider and ShareMessagesProvider provide. A component that provides MessagesViewContext by 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.

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
danny-avila force-pushed the danny-avila/message-rendering-lag-9549f7 branch from 22a2e03 to 9c979e4 Compare September 30, 2026 14:01
@danny-avila
danny-avila marked this pull request as ready for review September 30, 2026 14:08
@danny-avila
danny-avila merged commit b341f93 into dev Sep 30, 2026
29 checks passed
@danny-avila
danny-avila deleted the danny-avila/message-rendering-lag-9549f7 branch September 30, 2026 14:19
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