Skip to content

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
JetBrains:mainfrom
Mark-Life:perf/composer-stable-props
Open

Andrey Markin (Mark-Life) wants to merge 8 commits into
JetBrains:mainfrom
Mark-Life:perf/composer-stable-props

Conversation

@Mark-Life

@Mark-Life Andrey Markin (Mark-Life) commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

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

Composer and the chat header re-render on every stream event, even though nothing they show changes. During a 26k-char streamed answer, Composer renders 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: ChatView rebuilt recentPrompts from turns on every event, so Composer got a new array each time. ChatView now keeps the previous array while its contents are equal.
  • Chat header: useChatTodos wrote a ref during render, so the React Compiler skipped the hook. It returned a new plan object on every render, which re-rendered ChatHeader and 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: stable recentPrompts.
  • chat/useChatTodos.ts: ref synced in useInsertionEffect; reloadPlan uses .catch().
  • apps/web/SPEC.md: useChatTodos leaves the list of compiler bail-outs (62 → 61).

Screenshots

Before vs after: Composer and chat header re-renders during a stream

Same 26k-char stream, CPU 4x, react-scan outlines, mid-stream. Before: Composer ×102, the send-button Popper ×103, and the plan popover at the top. After: none of them re-render.

Before After
before after

Checklist

  • Fast gates pass: bun run lint, bun run typecheck (17/17), bun run test (web 1,411 pass). compiler:census: 408 compiled, 61 bail-outs, no new ones
  • E2E suite passes for app-affecting changes (bun run e2e on this branch, top of the stack): 3 failures, none from this stack. 00-jbcentral.spec.ts:20 fails the same way on main. activity-breadcrumbs.spec.ts:240 fails 1 in 12 runs on both main and this branch. layout.spec.ts:1061 passed 3/3 when re-run alone
  • Before/after screenshots are included for frontend changes, or marked not applicable
  • Relevant SPEC.md / top-level specs updated to reflect any boundary, contract, or behavior change
  • I have read the Contributing guide and agree to the Code of Conduct

bun run perf:render --runs 1 --cpu 4, 3 interleaved rounds, median [min–max], base #618 vs this branch:

scenario Composer renders popover subtree renders self ms of these total render ms
chat-streaming 268 → 2 7711 → 73 192 → 4 1535 → 1555
long-stream 175 → 2 5167 → 23 155 → 3 1459 → 1435

Render 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.

@danyaberezun
danyaberezun added this pull request to the merge queue Oct 4, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 4, 2026
@danyaberezun
danyaberezun added this pull request to the merge queue Oct 4, 2026
@danyaberezun
danyaberezun removed this pull request from the merge queue due to a manual request Oct 4, 2026
@Mark-Life
Andrey Markin (Mark-Life) marked this pull request as draft October 5, 2026 08:43
@Mark-Life
Andrey Markin (Mark-Life) marked this pull request as ready for review October 5, 2026 09:27

@jetbrains-air jetbrains-air Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — ready to merge.

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>

@jetbrains-air jetbrains-air Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — ready to merge.

This branch has not been deployed

No deployments
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.

2 participants