Skip to content

perf(transcript): tail-first thread switch + parsed-markdown cache (#1475) - #1489

Merged
currentbits merged 5 commits into
mainfrom
coder/perf-h-switch-big-thread-switch-frame-8d0baa
Oct 6, 2026
Merged

currentbits merged 5 commits into
mainfrom
coder/perf-h-switch-big-thread-switch-frame-8d0baa

Conversation

@currentbits

Copy link
Copy Markdown
Owner

Part of #1475, round 3. In the end-to-end measurement (docs/perf/2026-10-06-end-to-end.md), the big-thread switch was the one renderer item that barely moved (−8%; still one ~135 ms frame). The switch-frame breakdown is in docs/perf/2026-10-06-switch-frame.md.

Frame breakdown before (headless Chromium, real 3.3 MB thread):

  • Markdown parse: 33 ms on a re-switch, 51 ms on a first open. Two 35 KB answers sit far above the screen.
  • Pin/layout: 10–13 ms.
  • React: 8–10 ms.

What changed

  • Parsed-markdown cache (Markdown.tsx): an LRU of the parsed element tree, keyed by text, with a 400k-char budget. The still-streaming tail chunk is not cached. This is safe because react-markdown's default Markdown is synchronous and hook-free, and the parse is a pure function of the text.
  • Tail-first switch (ThreadView.tsx):
    • The first render mounts only the last ~8k chars, about a screen.
    • Deferred answers are pre-parsed one per idle slot (100 ms timeout); then the full window mounts in startTransition.
    • A tail that doesn't fill the pane mounts in full before paint.
    • Thread view drops autoscroll on thread switch and permission prompts #607 pinning is kept: useStickToBottom re-pins when entries mount above a stuck view.
    • Show earlier and jump-to-message mount the full window at once.
  • Fix after my review (bb72da3): the fill-in effect runs once per switch, not once per streaming push. It no longer does a forced layout read per push, and the idle chain no longer starves while a stream is running.

Measured (worker, headless Chromium, median of 5; the ratios carry over to the app, the absolute times don't):

Before After
Big thread, reopened 55 ms (longest frame 53) 11 ms (longest frame 19)
Big thread, first open 78 ms (73) 16 ms (22)
Small thread 11.6 ms 7.5 ms

Verified independently

  • typecheck exits 0 and vite build passes.
  • test:renderer passes 2750 of 2750.
  • The new tests fail on main: tail-first plus pinned, and streaming pushes during the tail phase (exactly one layout read and one idle chain).

Known trade-off: for roughly the first 100 ms after a switch, answers above the first screen aren't mounted yet, so find-in-page or an immediate scroll-up sees less. Show earlier mounts everything.

🤖 Generated with Claude Code

Willem and others added 5 commits October 6, 2026 11:46
Switching back to a big thread re-parsed every mounted answer: micromark,
mdast and hast were ~33 ms of the ~75 ms switch frame on the real 3.3 MB
thread. react-markdown's parse is a pure function of the text, so its
element tree is now kept in an LRU bounded at 400k chars (a few tail
windows) and mounted again on the next switch. The live tail of a
streaming reply is not cached, so streaming can't evict settled parses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first render of a thread now mounts only the last 8k chars of message
text, about a screen. On the real 3.3 MB thread the 120-entry tail
window holds two 34-36 KB answers far above the fold, and parsing them was
most of the first-open frame. After paint, the deferred answers are
parsed into the markdown cache one per idle slot, then the rest of the
window mounts from it in a transition.

- A tail that doesn't fill the pane mounts everything before paint, so
  nothing visibly grows.
- useStickToBottom pins before paint when entries mount above a stuck
  view (#607); plain appends still skip the layout read.
- Show earlier and jump-to-message mount the full window at once (#564).
- A tool run cut by the old start re-keys when its head mounts; it is
  now seeded as seen so it doesn't replay its entrance animation.

The window-bounds SSR test now mounts, since SSR never runs the effect
that fills the window in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tail-first layout effect had no deps, so on a streaming thread every
thread:updated push (~5/s) re-ran it: a forced scrollHeight read per push,
the layout cost #1482 removed, and a restarted idle chain that spent one
slot per already-parsed answer and could starve until the stream ended.

It now runs once per tail-only phase (keyed on the thread) and reads the
deferred range through a ref. The idle chain skips cached answers without
spending a slot. A test streams five pushes into the tail-only phase and
asserts zero layout reads, one idle chain, then the full window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-06T10:13:24.864228Z d94f718 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d94f718c01

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +3449 to +3452
onClick={() => {
fullWindowThread.current = threadId;
showEarlier();
}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Force a render when revealing the deferred window

When a thread has fewer than TRANSCRIPT_WINDOW entries but exceeds the first-paint character budget, windowStart is already 0 and only the derived start hides earlier entries. Clicking this button mutates the ref, but showEarlier() then applies extendWindowStart(0), so React bails out of the unchanged state update and the hidden entries remain absent until the idle preparse chain eventually completes. Trigger the full-window tick here (or otherwise update state) so “Show earlier” reveals the entries immediately, especially when a busy renderer delays several idle callbacks.

Useful? React with 👍 / 👎.

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