Speed up thread rendering while content streams - #3591
Merged
SawyerHood merged 5 commits intoSep 12, 2026
Merged
SawyerHood merged 5 commits into
SawyerHood merged 5 commits into
Conversation
Add a streaming render benchmark that runs production builds against a large golden database, drives headless Chrome over CDP, and records main-thread, frame, latency, network and server CPU metrics, plus a deterministic bench-stream provider plugin that grows long thread histories and streams Markdown at a fixed rate. Speed up the streaming path end to end without visible behavior changes: - Give the timeline scroll grid an explicit minmax(auto,1fr) row so the page container query no longer restyles every row on each layout. - Keep highlighted code markup stable, cache highlights, precheck thread mentions, and reuse empty candidate arrays. - Commit timeline updates once per data change and skip fetch-state re-renders once rows exist. - Cache table of contents labels per row. - Parse settled Markdown pieces once while a message streams. - Hint the parented timeline boundary query onto the turn index, skip search segment parsing for delta rows, and look up completed turns directly on ingest. - Serve latest timeline pages incrementally with a per-thread event rewrite generation, a grouping context cache, a stored event decode cache, and a latest-page selection memo. Co-Authored-By: Claude Code <noreply@anthropic.com>
…cript Co-Authored-By: Claude Code <noreply@anthropic.com>
- Benchmark runner: drop unused CDP session tracking, Chrome relaunch logic, collector diagnostics, experiment flags, and dead exports; share one process-group teardown; derive the Chrome version from the iteration browser. - Bench stream provider: consolidate bridge routing and tests. - App and server: share Markdown fence helpers, drop wrappers and single-use exports, and consolidate the cache, memo, and incremental Markdown tests around shared fixtures. Co-Authored-By: Claude Code <noreply@anthropic.com>
Re-add assertions and cases whose bugs no remaining test caught, each proven against a mutation of the code under test. Co-Authored-By: Claude Code <noreply@anthropic.com>
Delete the benchmark runner and the bench stream provider plugin. Move the Markdown fixture documents the incremental Markdown tests use into the app test fixtures. Co-Authored-By: Claude Code <noreply@anthropic.com>
SawyerHood
deleted the
bb/benchmark-thread-rendering-streaming-content-thr_d7746tynyd
branch
September 12, 2026 21:00
SawyerHood
added a commit
that referenced
this pull request
Sep 15, 2026
## Human comments ## What was wrong Since #3325, `mergeLoadedTimelineWithLatest` replaces every loaded row whenever the latest page's `historySnapshot` changes, and the snapshot includes `maxSeq`, so each appended event unloads all older pages. When the previous conversation group is larger than the 1,500-event latest-page budget, it exists only on an older page. While a thread streams, each event removes it, the "Loading older messages" sentinel scrolls back into view, auto-load fetches it again, and the next event removes it again. The controller also discarded an older page still in flight whenever the snapshot moved. Separately, a page's older cursor used the group's first displayed row. Rows recorded after a request but displayed above its message, such as "Provider environment resolved", formed their own group with a higher sequence, so the first older request re-served the current group and merging it moved that row below the reply. ## What changed - `packages/client-core` `mergeLoadedTimelineWithLatest`: when a new latest snapshot reaches the loaded tip, loaded older pages stay and only the rows the latest page covers are replaced. It still rebuilds from the latest page when the pages do not line up, when they share no rows and leave a gap, or when the server reports a later change to a row the page omitted. On a cursor tie it keeps the walk's cursor. `prependOlderTimelineRows` keeps the loaded order when an older page repeats loaded rows. - `useThreadTimelineController`: an in-flight older page is applied when its request cursor is still the loaded `olderCursor`, instead of requiring an unchanged snapshot. - `apps/server` pagination: new `timelinePage.olderRowsSourceSeqEnd`, the greatest `sourceSeqEnd` among rows the snapshot projected before the page's returned rows but omitted (`null` when none). That covers older conversation groups, owned groups a size cut left out, and the leaves a content cut dropped from the page's oldest group, so a command that completes before a content cut still triggers a rebuild. A row that keeps changing before a content cut therefore rebuilds on each refresh. Leading rows recorded after a group's message now join that group, and its cursor is the message row. - `packages/server-contract`: the field is optional in `timelinePageMetadataSchema`, matching the other snapshot fields. No daemon wire change, so `HOST_DAEMON_PROTOCOL_VERSION` is unchanged. - `docs/timeline-pagination.md` describes the new rule. This relaxes #3325's "a new latest snapshot replaces loaded rows" behavior for refreshes that only append to newer rows; a later change to a row the page omitted still replaces them. - One #3325 assertion changed in `timeline-event-budget.test.ts`. After a late append that sorts into the newest group, the merge must now equal the full canonical timeline rather than the latest page alone. ## How you verified - New client-core merge tests: a streaming refresh keeps older pages, a tie keeps the walk cursor, a content-cut running turn keeps its head, a group starting right after the tip appends, no overlap with a gap rebuilds, a change to an omitted row rebuilds, and a repeated group keeps the loaded order. 5 of these fail against main's merge code. - New controller test: an in-flight older page applies across refreshes that advance the snapshot, and older rows survive later refreshes. - New server unit tests: the cursor anchors at the message row, and the rebuild value covers context-only older groups, owned groups a size cut left out, and rows before a content cut. - Endpoint tests: walked history stays equal to the canonical timeline after an append to the newest group. A late command completion reports a rebuild both in an older group and before a content cut in the newest group; after the rebuild and reloading older pages, the rows equal the canonical timeline. The content-cut case fails with the previous pagination code. - `pnpm exec turbo run typecheck` for `@bb/server-contract`, `@bb/client-core`, `@bb/server` and `@bb/app`, and earlier for `@bb/cli` and `@bb/mobile`. - `pnpm exec turbo run test` for `@bb/client-core`, `@bb/server-contract`, `@bb/cli`, and `@bb/server -- test/services/threads test/provider-corpus test/routes`. The provider corpus correctness walk skipped because no corpus was available. - App Vitest run directly for `src/components/thread/timeline`, `src/views/thread-detail` and `src/hooks/queries`, because the PWA icon check fails in this checkout. - Browser, isolated source instance with `BB_FF_TIMELINE_WINDOW_EVENT_BUDGET=20` and real Claude Code turns sent from the composer. A MutationObserver recorded whether the previous assistant message stayed in the DOM while a follow-up streamed. With main's client merge it left and returned 8 times within about 4 seconds, and "Loading older messages" showed in 19 samples. With this change it never left over a 30-second turn. - Replayed a real affected thread's events through the server at each of 49 consecutive sequences, feeding both clients. With main's client the previous message dropped on 49 of 49 refreshes; with this change, 0. The rebuild value stayed `null` throughout. - The browser check ran before rebasing onto #3591, and the replay before the omitted-rows change. Tests and typechecks were rerun after both. 🤖 Generated with [Claude Code](https://claude.com/claude-code) > AGENT GENERATED --------- Co-authored-by: Claude Code <noreply@anthropic.com>
danielbachhuber
added a commit
to danielbachhuber/bb
that referenced
this pull request
Sep 15, 2026
- markdown-preview.test.tsx: the only marked conflict, and it was adjacency in the import block. This branch added `onTestFinished` to the vitest import; main added `highlightMarkdownCode` on the next line. Both imports stay. - markdown-preview.tsx merged cleanly and needed no follow-up. Main's changes there came from the incremental streaming render (get-bb#3591) and the timeline lightbox work (get-bb#3489, get-bb#3596); none of them touch `MarkdownAnchor` or `MarkdownListItem`, so the fragment-link handling and the forwarded `<li id>` survive as written. - The incremental renderer does not undercut this fix. It treats `footnoteDefinition` and `footnoteReference` as global constructs (markdown-incremental-pieces.ts), so a message with footnotes is not split across pieces, and every piece renders inside the same `[data-markdown-preview]` root the destination lookup scopes to.
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.
Human comments
What was wrong
While an agent answer streams into a long thread, every newline-gated text update triggers a timeline refetch, React commits, a Markdown re-render and a layout, and on a large database that path was expensive at every layer. Profiling production builds found four root causes. First, the
@container/pagescroll port sat in an implicitautogrid row, so every layout evaluated the page container twice and the100cqwtable breakouts restyled every timeline row: about 11.5 s of a 21 s stream at 1× CPU. Second, Markdown re-parsed the whole message and rebuilt highlighted code DOM on every update. Third, the timeline controller committed extra intermediate renders per update. Fourth, the server rebuilt the latest timeline page from scratch on each refetch (event queries, grouping context, JSON decode), and daemon event ingest did avoidable work.What changed
Client
bottom-anchored-scroll-body.tsx: explicitgrid-rows-[minmax(auto,1fr)]on the timeline scroll grid. Geometry is unchanged.dangerouslySetInnerHTMLobject for highlighted code (DOM nodes added per stream went from 15k to 2k on the default scenario), an LRU for highlights, a thread-mention precheck, and stable empty candidate arrays.useThreadTimelineController: no re-render on fetch start or end once rows exist, one consistent commit per data update, and a stable side-chat send-to-main callback (useSendSideChatMessageToMain).ThreadTableOfContents: per-row label cache.markdown-incremental-pieces.ts(the block memoization technique from Streamdown): assistant messages parse each settled piece once. Reused pieces are re-cloned so components re-render on the same schedule as before, cold mounts parse the whole message once, and messages with footnotes or definitions fall back to single-document parsing. Message directive mounts now come from context. Markdown fence and list scanning helpers are shared withstreaming-markdown-split.ts.Server and database
packages/db/src/data/events.ts:INDEXED BYhint for the parented-boundaryEXISTS(4 to 5.6× faster on tool-heavy threads).HOST_DAEMON_PROTOCOL_VERSIONstays the same.PRAGMA data_version), a grouping-context cache, an exact stored-event decode cache, and a latest-page selection memo that is reused only at the latest sequence of an active thread.docs/debugging-and-qa.md: documents the newtimeline-streaming-memo.test.tscorpus gate.About 2k of the added lines are product code, 5.7k are tests, and 1.7k are Markdown fixture documents used by the incremental Markdown tests.
Not in this PR (each would change visible behavior and needs a decision): snapping timeline growth while streaming, desktop
content-visibilityfor offscreen rows, skipping refetches on ticks with no visible text change, paced refetches or server-side frame coalescing, excluding delta rows from the event budget, and one Markdown container across the settled/tail seam.How you verified
Benchmark: measured with a streaming render benchmark that ran production builds on a seeded database (1,200 threads, ~400k events) plus 150- and 600-turn threads, driving headless Chrome for Testing 149 at 1440×900. Runs interleaved base and candidate builds; medians of 5 iterations at 1× and 3 at 4×, measured on the previous base
1a7c008643. The final message text hash and timeline geometry hash matched the base in every iteration of every scenario. The harness is not part of this PR; it is in this PR's first commit (f16f089991) if anyone wants to rerun it.Style recalculation dropped 79–86% at 1×. Retained JS heap after GC is 0.5–4.8 MB higher (bounded piece cache and highlight LRU).
Behavior
loadOlderTimelineRowsidentity, a timeline controller provider, and an API key verification cache.267938526dand compared against this branch withrow-snapshots.test.ts: 340 threads, 106,510 rows, 0 diffs. The newtimeline-streaming-memo.test.tsappends streaming rows tick by tick to every corpus thread and requires each warm latest-page build to equal a build on a fresh connection with empty caches: 340 of 340 threads pass.Commands (on
267938526dwith this branch)pnpm exec turbo run typecheck lint --filter=@bb/app --filter=@bb/server --filter=@bb/db: pass.oxfmt --checkon changed files: pass.pnpm exec turbo run test --concurrency=1 --filter=@bb/app --filter=@bb/server --filter=@bb/db:@bb/app526 files (4,556 tests),@bb/server2,637 tests,@bb/db514 tests. All pass.pnpm install --frozen-lockfile: pass; the lockfile is unchanged from main.