Repository navigation
perf(web): stop Composer and the chat header re-rendering on every stream event - #619
Open
Andrey Markin (Mark-Life) wants to merge 8 commits into
Open
Andrey Markin (Mark-Life) wants to merge 8 commits into
Andrey Markin (Mark-Life) wants to merge 8 commits into
Conversation
Andrey Markin (Mark-Life)
marked this pull request as ready for review
October 4, 2026 09:07
Andrey Markin (Mark-Life)
requested review from
Olga Lavrichenko (OLavrik),
Rustam Sadykov (SBOne-Kenobi),
danyaberezun and
Rinat S (rsolmano)
as code owners
October 4, 2026 09:07
5 tasks done
danyaberezun
approved these changes
Oct 4, 2026
github-merge-queue
Bot
removed this pull request from the merge queue due to failed status checks
Oct 4, 2026
Andrey Markin (Mark-Life)
marked this pull request as draft
October 5, 2026 08:43
Andrey Markin (Mark-Life)
force-pushed
the
perf/composer-stable-props
branch
from
October 5, 2026 08:47
a272d97 to
fbff875
Compare
Andrey Markin (Mark-Life)
marked this pull request as ready for review
October 5, 2026 09:27
Andrey Markin (Mark-Life)
force-pushed
the
perf/composer-stable-props
branch
from
October 5, 2026 15:17
fbff875 to
fb43f58
Compare
Opt-in scenarios for perf:render, selected with --scenario: - long-stream: one ~26k-char seeded markdown answer in 10-60 char deltas - parallel-agents: 20 chat tabs streaming at once - background-agents: 19 hidden tabs streaming, visible chat idle --cpu N throttles the CPU through CDP during the measured window. Every run now also records long tasks, rAF frame gaps, CDP main-thread time, Markdown renders and self ms per delta, and scenario counters. The output records rootDir, dirty flag and a dirty-tree hash so runs from other worktrees stay comparable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…scenario Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Assistant text re-parsed the whole message on every streamed delta, so cost grew with message length. BlockMarkdown splits the text into top-level blocks with the marked block lexer and renders each block with a memoized react-markdown, so a delta re-parses only the tail. The splitter is incremental: it re-lexes from the last closed block that has two blocks after it. A block closes at a blank line, or at a heading, a rule or a CommonMark-closed fence, so text without blank lines still splits. A link or footnote definition, also inside a list item, renders the message whole. Every fallback (open HTML, raw HTML lines, lazy list lines, indented code, a fence only marked closes, lossy lexer output) merges blocks and never splits them. Blocks are separated by the same newline the whole-document render emits, so the HTML is identical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Check blank lines against the previous token's trailing whitespace, so a long open block splits in linear time. - Let a closed HTML block followed by a blank line end its block, so back-to-back <details> sections split. - Count only ATX headings as closing a block. - Keep a block open on an unended <!--, <?, <!X or <![CDATA[ line, on an open fence inside a list item or quote, and on a fence that has an earlier closing line marked skips. - Re-check a definition that is still streaming its line instead of rendering the rest of the message whole. - Move seededRandom to a dependency-free e2e/perf module so web tests no longer reach @thinkrail/server types. - List the remaining marked/micromark gaps in chat/SPEC.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ream event ChatView rebuilt recentPrompts from turns on every event, so Composer got a new array each time. Keep the previous array while its contents are equal. useChatTodos wrote a ref during render, so the React Compiler skipped it and it returned a new plan object every render, which re-rendered ChatHeader and the plan popover. Sync the ref in an effect and replace the try/catch value block with .catch(), so the hook compiles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Andrey Markin (Mark-Life)
force-pushed
the
perf/composer-stable-props
branch
from
October 5, 2026 15:23
fb43f58 to
c0ac11a
Compare
This branch has not been deployed
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.
Stack (4/5): #616 profiling harness → #617 heavy-load scenarios → #618 streamed markdown blocks → this PR → #626 Shiki worker.
Depends on #618. This PR targets
main, so it also shows the commits of #616–#618. Review only the last commit:fbff8755.Problem
Composerand the chat header re-render on every stream event, even though nothing they show changes. During a 26k-char streamed answer,Composerrenders 175 times. The closed plan popover in the header re-renders its whole Radix subtree, more than 5,000 component renders.Approach
Two causes, each fixed at the source:
Composer:ChatViewrebuiltrecentPromptsfromturnson every event, soComposergot a new array each time.ChatViewnow keeps the previous array while its contents are equal.useChatTodoswrote a ref during render, so the React Compiler skipped the hook. It returned a newplanobject on every render, which re-renderedChatHeaderand the plan popover. The ref now syncs in an effect, and a try/catch around a value became.catch(), so the hook compiles.Radix does not mount closed popover content. A closed popover costs only when its parent re-renders, so fixing the parent is enough.
Changes
chat/ChatView.tsx: stablerecentPrompts.chat/useChatTodos.ts: ref synced inuseInsertionEffect;reloadPlanuses.catch().apps/web/SPEC.md:useChatTodosleaves the list of compiler bail-outs (62 → 61).Screenshots
Same 26k-char stream, CPU 4x, react-scan outlines, mid-stream. Before:
Composer ×102, the send-buttonPopper ×103, and the plan popover at the top. After: none of them re-render.Checklist
bun run lint,bun run typecheck(17/17),bun run test(web 1,411 pass).compiler:census: 408 compiled, 61 bail-outs, no new onesbun run e2eon this branch, top of the stack): 3 failures, none from this stack.00-jbcentral.spec.ts:20fails the same way onmain.activity-breadcrumbs.spec.ts:240fails 1 in 12 runs on bothmainand this branch.layout.spec.ts:1061passed 3/3 when re-run aloneSPEC.md/ top-level specs updated to reflect any boundary, contract, or behavior changebun run perf:render --runs 1 --cpu 4, 3 interleaved rounds, median [min–max], base #618 vs this branch:ComposerrendersRender counts and self time drop well outside the spread. Total render ms does not change beyond noise: commits get slightly cheaper, and React batches fewer of them. Measured before the stack was rebased onto current
main; the change it measures is identical.