Skip to content

Studio: stop every message part re-rendering on each streaming chunk - #9014

Merged
danielhanchen merged 5 commits into
mainfrom
perf-parts-memo
Aug 17, 2026
Merged

Studio: stop every message part re-rendering on each streaming chunk#9014
danielhanchen merged 5 commits into
mainfrom
perf-parts-memo

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Aug 16, 2026

Copy link
Copy Markdown
Member

A streaming assistant reply rebuilt all of its already-finished parts on every chunk, so the per-chunk cost grew with the length of the reply. This is one of the paths behind reports of Studio getting sluggish during long generations with code cells and text.

Cause

MessagePrimitivePartByIndex is memoized, and its comparator checks the components fields one at a time rather than comparing the object as a whole. From @assistant-ui/core/dist/react/primitives/message/MessageParts.js:

export const MessagePrimitivePartByIndex = memo(({ index, components }) => {
  ...
}, (prev, next) =>
  prev.components?.Text === next.components?.Text &&
  ...
  prev.components?.tools === next.components?.tools &&
  ...
);

Every field thread.tsx passed is a module-level component, so all of them compare equal across renders. The exception was tools, an object literal built inline in the JSX:

<MessagePrimitive.Parts
  components={{
    Text: MarkdownText,
    ...
    tools: {
      by_name: { web_search: WebSearchToolUIConfirmable, ... },
      Fallback: ToolFallbackConfirmable,
    },
  }}
/>

tools got a fresh identity on every render. That single mismatch failed the comparator, so every part of the message re-rendered on every render of AssistantMessage, which during streaming is every chunk.

There is a second effect in the same file, and it is roughly half of what this fixes. MessagePrimitivePartsCompat builds the whole element array inside a useMemo whose dependency list is [messageRanges, components, contentLength]. An inline components invalidated that memo on every render too, rebuilding the entire array independently of the per-part comparator.

Note that hoisting only helps because of how that comparator is written. The MessagePrimitiveParts wrapper builds a new merged object each render regardless, so an identity comparison on the map as a whole would never have held. It is the per-field check that makes stable fields worth having.

Change

One map moves to module scope: ASSISTANT_PART_COMPONENTS, the map handed to <MessagePrimitive.Parts> in AssistantMessage.

Every field in it is already a module-level binding. The five non-tool entries are ES module imports; the eight tool entries are the existing module-scope *Confirmable consts. withToolConfirmation closes over nothing but its Component argument and calls no hooks at wrap time, so the literal already had identical contents on every render and this changes its identity only. Nothing here needed a useMemo.

The other two call sites are deliberately left alone:

  • <MessagePrimitive.Parts /> in UserMessage passes no components prop at all. undefined === undefined satisfies the comparator, so it never had the problem.
  • The components literal on ThreadPrimitive.Messages is left inline. ThreadPrimitiveMessageByIndex is memoized with isComponentsSame, a field-by-field structural compare, so a fresh literal whose fields are stable already compares equal and the memo already bails out. Hoisting there buys nothing, and it would collide with Chat: stop a message delete from re-rendering the whole thread #9042.

Testing

tests/thread-part-components-stable.test.ts, three tests. Each was checked against the tree before this change rather than assumed to discriminate:

test before after
no MessagePrimitive.Parts components map is an inline object literal fails passes
the assistant part components are a single module-scope object fails passes
the upstream memo still compares components.tools by identity passes passes

The third passes on both sides deliberately. It is not a regression guard for this change; it pins the upstream behaviour this fix depends on, so that an assistant-ui upgrade which stops comparing components.tools by identity fails the test and says to re-measure, rather than leaving behind a hoist and a comment that no longer describe what the library does.

The first two were also checked against three further broken trees, since a source-text assertion is easy to write so that it passes on the broken tree too: the literal restored inline, everything hoisted except tools, and the hoisted const spread back into the JSX with {...ASSISTANT_PART_COMPONENTS}. Two of the three tests fail on each.

npm test 3,482 passed, 0 failed. npm run typecheck clean. biome check on thread.tsx is unchanged; the new test file carries 6 diagnostics, all of the kind the rest of tests/ already carries (noNodejsModules, useTopLevelRegex).

Measured

Live generation is not usable for a before-and-after here: the GGUF loads with --spec-type ngram-mod --parallel 4, so two runs of the same prompt at temperature 0 produced 770 and 814 characters. Instead the real SSE stream from unsloth/Qwen3.5-2B-MTP-GGUF:UD-Q4_K_XL at 4096 context was recorded (600 chunks, 145 KB, reasoning plus three python tool calls plus interleaved text) and replayed byte-for-byte, with its original pacing, into a build of each tree. Counters are non-memoized module-scope wrappers identical on both sides.

counter before after
AssistantMessage renders 127 127
total part renders 296 127
renders of parts that were already complete 177 8

Reproduced three times on Chromium, once each on Firefox and WebKit.

One qualification worth stating: the large ratio needs a message with several parts mounted at once, which in practice means tool calls. For a plain reasoning-plus-text reply the reasoning card collapses when it finishes, so only one part is mounted and the before side already does about as little work as the after side. The gain is real for the "code cells and text" case this started from, and close to nothing for a short prose answer.

Rendered output is unchanged. Comparing the assistant message body across the same replay, textContent is identical at 2,506 characters and innerHTML is identical at 74,510 characters once Radix auto-ids are normalized (those also differ between two runs of the same tree). That holds on Chromium, Firefox and WebKit, and under a mid-stream interrupt at chunk 300 (930 characters, identical).

Because the risk in a change like this is over-memoizing rather than under-memoizing, the cases where a part must still update were driven explicitly on the built frontend: a tool call going running to complete, parts appended mid-stream, regenerate, assistant branch switch both ways, user branch switch both ways, editing a user message, editing an assistant response in place, and the tool confirmation gate. All render; none goes stale. A thread written by the previous build also reopens byte-identically on this one.

WebKit here is Playwright's, which is a proxy for the webviews Desktop embeds rather than those webviews. macOS and Windows runners were not used; the change is a JavaScript object hoist with no platform-dependent semantics.

danielhanchen added 2 commits August 16, 2026 15:59
A streaming assistant reply rebuilt all of its already-finished parts on
every chunk, so the per-chunk cost grew with the length of the reply.

MessagePrimitivePartByIndex is memoized, and its comparator checks the
components fields one at a time rather than comparing the object as a
whole:

  prev.components?.Text === next.components?.Text &&
  ...
  prev.components?.tools === next.components?.tools &&

Every field thread.tsx passes is a module-level component, so all of them
compare equal across renders. The exception was tools, an object literal
built inline in the JSX, which meant a fresh identity on every render.
That single mismatch failed the comparator and re-rendered every part of
the message.

Both maps move to module scope. THREAD_MESSAGE_COMPONENTS is declared
after the three components it names, because a module-scope initializer
runs at import time and would otherwise read them in their temporal dead
zone.

The guard test covers the two ways this regresses. It fails on the tree
before this change, and it also pins the upstream assumption: if an
assistant-ui upgrade stops comparing components.tools by identity, the
test fails and says to re-measure rather than leaving behind a hoist and
a comment that no longer describe what the library does.

npm test 2939 passed, 0 failed. typecheck clean. biome adds no new
errors on either file.
The ThreadPrimitive.Messages half of this change is reverted. Hoisting
the map there cannot help, and it collides with #9042 which fixes that
call properly.

Reading the primitive settles it. Given a components map, assistant-ui
builds:

  children: () => <ThreadMessageComponent components={components} />

so the per-message element always carries a props object and never
reaches the propless bail-out in RenderChildrenWithAccessor. A stable
map only lets the outer memo bail out, which is the cheap part. #9042
moves the call to the children form returning one shared propless
element, which does reach the bail-out, and measures 7.44x on a delete
at 300K characters. Its test asserts ThreadPrimitive.Messages carries no
components prop at all, which is the exact opposite of what a hoist
looks like, so the two could not both land.

MessagePrimitive.Parts is unaffected and keeps the fix.
MessagePrimitivePartByIndex compares components field by field, checking
components.tools by identity, so the inline tools literal really did
defeat it on every render. That part is unchanged and still measured.

The test is scoped to MessagePrimitive.Parts and carries the reason
ThreadPrimitive.Messages is excluded, so nobody re-adds the hoist there
on the strength of the same reasoning.

Discrimination: re-inlining the Parts literal fails 2 of the 3 tests.
The third pins the upstream comparator and passes on both trees by
design, as before.

npm test 2,939 passed, 0 failed. typecheck clean.
@danielhanchen

Copy link
Copy Markdown
Member Author

Narrowed this after #9042 landed, and the reason is worth recording rather than leaving in the diff.

The ThreadPrimitive.Messages half is reverted. Hoisting the map there cannot help. Reading the primitive settles it: given a components map, assistant-ui builds

children: () => _jsx(ThreadMessageComponent, { components: components })

so the per-message element always carries a props object and never reaches the propless bail-out in RenderChildrenWithAccessor. A stable map only lets the outer memo bail out, which is the cheap part. #9042 moves that call to the children form returning one shared propless element, which does reach the bail-out, and measures 7.44x on a delete at 300K characters.

The two were also mutually exclusive: #9042 asserts ThreadPrimitive.Messages carries no components prop at all, which is the exact opposite of what a hoist looks like, so whichever merged second would have failed the other's test.

MessagePrimitive.Parts is unaffected and keeps the fix. That one is real: MessagePrimitivePartByIndex compares components field by field, including prev.components?.tools === next.components?.tools, so the inline tools literal defeated the memo on every render and re-rendered every part of a streaming message on every chunk.

The test is now scoped to MessagePrimitive.Parts and carries the reason ThreadPrimitive.Messages is excluded, so the hoist does not get re-added there on the strength of the same reasoning that made me add it in the first place.

Discrimination unchanged in kind: re-inlining the Parts literal fails 2 of the 3 tests. The third pins the upstream comparator and passes on both trees by design, as stated before.

npm test 2,939 passed, 0 failed. npm run typecheck clean.

danielhanchen added 2 commits August 17, 2026 09:42
The upstream comparator is read with readFileSync, so a half-installed or
relocated node_modules surfaces as a bare ENOENT stack inside this test. That
happened once and read as a real regression in the code under test until it was
disbelieved. Wrap the read and say plainly that it is an install problem.

Also make the inline-literal assertion message one template literal, which drops
the useTemplate error the file was adding to biome.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

1 similar comment
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

Reviewed commit: d7fbe7916c

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 974d701fcd

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

danielhanchen pushed a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Aug 17, 2026
danielhanchen pushed a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Aug 17, 2026
@danielhanchen

Copy link
Copy Markdown
Member Author

Before and after, one scene driven against both builds

BEFORE is the merge base 0ac2e799, AFTER is this PR's head 974d701f. Two separate installs, each built from its own tree, driven by a single Playwright scene against a scripted SSE stream, so both receive byte-identical arrivals. The message deliberately carries three parts: a finished text part, a tool call, and a closing text part.

Settled

9014 settled, before and after

Both halves show the tool card in its completed state, with the tick, the argument row {"query":"studio streaming notes"} and Result: 3 notes: streaming, tool cards, themes. Above it the earlier finished text part still reads Here is what I found:, and below it the closing part reads That is the whole answer.

That earlier part is the point of this shot. Hoisting the components map makes an upstream memo start bailing out, so the failure mode to look for is not a visible redesign but a finished part going stale. It has not.

Mid-stream

9014 mid-stream, before and after

The same tool card while the run is still going, showing the running spinner and the collapsed chevron on both sides, with Here is what I found: already rendered above it. Taken together the two shots show the running to complete transition happening on both builds.

The two settled screenshots are byte-identical. Facts agree: reply_sha256 e0899c80... on both, part_count 3 on both, tool_state_midstream running on both, tool_state_settled complete on both, earlier_part_intact true on both.

@danielhanchen
danielhanchen merged commit 559b716 into main Aug 17, 2026
37 checks passed
@danielhanchen
danielhanchen deleted the perf-parts-memo branch August 17, 2026 10:42
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