Skip to content

Remove re-render amplifiers across slots, contexts, banners and the prompt area (C6-C13, D2, D4, E9, E11, E12, H8) - #1898

Merged
SawyerHood merged 15 commits into
bb/mobile-perf/plugin-host-slotsfrom
bb/mobile-perf/rerender-amplifiers
Aug 19, 2026
Merged

Remove re-render amplifiers across slots, contexts, banners and the prompt area (C6-C13, D2, D4, E9, E11, E12, H8)#1898
SawyerHood merged 15 commits into
bb/mobile-perf/plugin-host-slotsfrom
bb/mobile-perf/rerender-amplifiers

Conversation

@SawyerHood

@SawyerHood SawyerHood commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

What was wrong

Several client-side amplifiers made phones re-render far more than the data changed:

  • The plugin slot store rebuilt all 14 slot arrays per plugin activation and emitted once per bundle at boot (~18 app-wide passes; every markdown body re-parsed once directives existed).
  • useNavigate() under <BrowserRouter> rebuilds per pathname, so the thread-actions context, the fork handler in the timeline static context, and every sidebar row (useThreadRowSplitDrag, useCreateThreadInWorktree, environment archive) re-rendered on every navigation.
  • MessageActionBar mounted the hidden desktop tooltip bar on touch phones in addition to the mobile popover.
  • Every ConversationRow re-rendered per new message via the latest-actionable id contexts, and React Compiler skipped it (a ref-callback passed as ref typed the edit object as a ref).
  • Title-mention resource maps were rebuilt twice (AppLayout + ProjectList) per sidebar refetch and provided twice.
  • Sidebar rows subscribed to splitLayoutAtom on compact viewports where splits are off.
  • Draft presence re-read localStorage for every thread per keystroke and per sidebar render.
  • useChildThreadPendingAttention returned a fresh array per render, invalidating the memoized prompt stack.
  • ThreadDetailPromptArea failed React Compiler compilation (try/finally, render-time ref writes, ref-typed closures).
  • Workflow/todo/background cards mounted collapsed bodies with per-row 1 s timers.
  • experimental_useSidebarThreads remapped every entry into a fresh DTO per update.
  • Page header and retained secondary panel re-rendered on every SidebarContext commit for one boolean.

What changed

  • Plugin slot store: per-kind structural sharing, per-plugin flattened slot cache, and a bounded notification batch (150 ms) around reconcile.
  • useRouteNavigate() from RouteNavigationProvider (ref-held navigate, stable identity, no location subscription) used by rows, ThreadActionsProvider, fork handler and plugin sidebar hooks; fork handler reads the thread from a ref.
  • Mobile branch in MessageActionBar (popover or plain inline buttons); desktop unchanged.
  • ConversationRow = thin context reader + memo(ConversationRowContent); InlineMessageEditorHost isolates the ref callback so both compile.
  • buildThreadTitleMentionResources with value retention; one provider (AppLayout); ProjectList reads the context.
  • Split indicator subscribes to a null atom when compact/disabled.
  • Presence store caches bits, re-reads only the changed key, stays silent when presence does not flip.
  • Module-level combine + frozen empty array for child attention.
  • ThreadDetailPromptArea compiler bailouts removed (runWhileFollowUpShortcutSending, useLatestRef, layout-effect ref write, module-level composer-host accessors).
  • Shared AnimatedBody (realize on first expand, retain) for the three cards; useSecondTick shared ticker.
  • WeakMap DTO memo for plugin sidebar threads; SDK docs note the uncapped array and windowing expectation.
  • SidebarShowingContext boolean for useIsSidebarShowing/useOptionalIsSidebarShowing.

How you verified

  • pnpm exec turbo run typecheck --filter=@bb/app --filter=@get-bb/plugin-sdk --filter=@bb/plugin-build: pass.
  • pnpm exec turbo run lint --filter=@bb/app: 0 errors (pre-existing compiler warnings only).
  • pnpm exec turbo run test --filter=@bb/app: 370 files, 2910 passed, 3 skipped. @get-bb/plugin-sdk tests: 103 passed. @bb/plugin-build tests: 20 passed.
  • New tests fail before / pass after: plugin-slots structural sharing + batching, ThreadActionsProvider.navigation (stable context and useRouteNavigate reader across a MemoryRouter navigation), useForkThreadFromMessage identity, MessageActionBar mobile branch, ThreadTimelineRows.row-isolation, ThreadTitleMentions.resources retention, paneContentSplitIndicator compact gating, usePromptDraftStorage presence reads/renders, child-thread-pending-interactions.hook identity, AnimatedBody realize/retain + shared interval, plugin-sidebar-hooks DTO identity, sidebar showing-bit isolation.
  • babel-plugin-react-compiler@1.0.0 run over ThreadDetailPromptArea.tsx and ThreadTimelineRows.tsx: ThreadDetailPromptArea (452 slots), ConversationRow, ConversationRowContent now CompileSuccess.

Fixes: part of the mobile / iOS Safari performance program (verified sweep report in the bb thread; no single issue).

Stack context

Layer 19 of 22 in the bb/mobile-perf/* stack (bottom → top: quick wins first, big rocks last).

  • Prerequisite (layer below): bb/mobile-perf/plugin-host-slots (Shim clsx, tailwind-merge, cva and the shared icon onto plugin host slots (A5, partial) #1897).
  • Next layer: bb/mobile-perf/markdown-streaming-and-containment (Split streaming markdown into settled prefix + live tail; gate mermaid; row containment (C1, C3, C4) #1899).
  • Audit findings addressed: see title IDs. Review: approved-with-fixes; 1 review fix commit(s).
  • Deliberately not done here: D6: already implemented on the base commit dde6cdd (Speed up large composer paste and per-keystroke work on big drafts #1857 memoizes initialEditorContent on [richTextEditing]); verified, no change needed
  • Partial: E12: only the showing-bit context was split out (page header + retained ThreadSecondaryPanel); the full actions/state split of SidebarContext was left as a foll; E11: compact gating only; the optional desktop per-content derived-atom refinement and the SplitThreadArea reconcile short-circuit were not done
  • Reviewer notes / follow-ups: C6 batching: while a slot batch is open (<=150 ms) a component that re-renders for unrelated reasons reads the fresh snapshot via getPluginSlotSnapshot() while un-rendered subscrib | E12 is a partial split (only the sidebar-showing boolean got its own context); the actions/state split from the audit remains a follow-up as the implementer stated. | C7 test is a hook-identity test rather than the Profiler-through-ThreadDetailView test the audit suggested; the row-isolation test in ThreadTimelineRows.row-isolation.test.tsx cove | apps/app/.ladle is not covered by tsc/lint; the RouteNavigationProvider addition there was checked by inspection only.
  • Wire/contract: None. No server/daemon wire, host RPC, or protocol changes; HOST_DAEMON_PROTOCOL_VERSION untouched. Public plugin SDK: only doc comments on the existing experimental_useSidebarThreads member (bundled-types regenerated); no new API members. Internal app contract: PluginFrontendReconcileDeps gained a required beginSlotBatch dep (test doubles updated).

AGENT GENERATED: by Claude Opus 5

@SawyerHood SawyerHood changed the title bb/mobile perf/rerender amplifiers Remove re-render amplifiers across slots, contexts, banners and the prompt area (C6-C13, D2, D4, E9, E11, E12, H8) Aug 19, 2026
@SawyerHood
SawyerHood force-pushed the bb/mobile-perf/rerender-amplifiers branch 2 times, most recently from 55319c2 to ff0b31b Compare August 19, 2026 07:50
@SawyerHood
SawyerHood marked this pull request as ready for review August 19, 2026 08:04
@SawyerHood
SawyerHood force-pushed the bb/mobile-perf/rerender-amplifiers branch from ff0b31b to 2e9c195 Compare August 19, 2026 15:59
SawyerHood and others added 15 commits August 19, 2026 16:10
Root cause: `buildSnapshot()` allocated fresh arrays for all 14 slot kinds
on every plugin registration, and the reconcile loop emitted once per
plugin bundle as it resolved. Each emit gave the timeline a new
`messageDirectives` array (new directive registry, every mounted markdown
body re-parsed and directive cards remounted), a new `messageActions`
array (static context invalidated for every row) and re-rendered all
`usePluginSlots()` sites; with ~18 plugins that is ~18 app-wide passes
during boot on a phone.

Change: flatten each plugin's registrations once per `set` call so slot
objects keep identity, rebuild kinds with structural sharing (a kind whose
slot sequence is unchanged keeps its previous array; an unchanged store
keeps the previous snapshot and does not notify), and hold notifications
in a batch for the duration of a reconcile run with a bounded 150 ms
flush window so a slow bundle cannot keep the others' UI off screen.
Reads inside a batch stay consistent (lazy rebuild).

Co-Authored-By: Claude <noreply@anthropic.com>
Root cause: under `<BrowserRouter>` react-router's `useNavigate()` reads
`useLocation()` and rebuilds its function on every pathname change. The
thread-actions context value, the fork handler fed into the timeline
static context, and every sidebar `ThreadRow`/`EnvironmentThreadGroupRow`
(via `useThreadRowSplitDrag`, `useCreateThreadInWorktree` and the
environment archive action) depended on it, so each navigation
re-rendered every mounted row (`memo` cannot skip a context subscription)
and every thread-detail refetch rebuilt `onForkMessage`, re-rendering all
mounted message rows.

Change: `RouteNavigationProvider` (already at the app root) now holds the
live `navigate` in a ref and exposes one stable `useRouteNavigate()` for
absolute app routes; the provider, the row hooks and the plugin sidebar
hook use it instead of `useNavigate`. `useForkThreadFromMessage` reads
the source thread from a ref so its handler identity holds across
refetches. Tests assert the context value and a memoized consumer stay
put across a real MemoryRouter navigation and that the fork handler keeps
identity while reading the latest thread.

Co-Authored-By: Claude <noreply@anthropic.com>
Root cause: `MessageActionBar` always mounted the desktop tooltip bar
(one Radix Tooltip tree per action, hidden by CSS on compact coarse
pointers) and then added the mobile overflow popover on top, so every
message on a phone carried five-plus dead tooltip trees.

Change: branch on the already computed `useMobileOverflowPopover`: on
touch phones render the overflow popover or plain inline buttons (same
classes, no tooltips); the desktop branch is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Root cause: every `ConversationRow` read the latest-actionable
assistant/user message-id contexts, which change on each new message, so
all mounted rows re-rendered their full body per message. React Compiler
also refused to memoize `ConversationRow`: the inline-editor host passes
`inlineMessageEditor.onHostElementChange` as a `ref`, so the compiler
typed the whole object as a ref and flagged the `messageId` read during
render.

Change: `ConversationRow` is now a thin context reader that resolves
`mobileActionDisplay` and renders a memoized `ConversationRowContent`;
the ref-bearing host moved into a tiny `InlineMessageEditorHost`, so
both row components now compile. A test appends a message to a 12-row
timeline and asserts only the two rows whose display flips re-render.

Co-Authored-By: Claude <noreply@anthropic.com>
…te provider

Root cause: AppLayout and ProjectList each rebuilt the section/project
name maps and the thread map on every sidebar refetch (new payload
identity per turn boundary) and each provided its own
`ThreadTitleMentionResources` context, so every ThreadRow, mention pill
and markdown link re-rendered twice per refetch even when no title,
project name or section name changed.

Change: one `buildThreadTitleMentionResources(navigation, previous)`
with value retention (previous maps and per-thread entries are reused
when equal; the entry type narrows to the four fields mentions read),
exposed through `useSidebarThreadTitleMentionResources` in AppLayout;
ProjectList reads the provided context for its alpha comparator instead
of building and providing a second copy.

Co-Authored-By: Claude <noreply@anthropic.com>
Root cause: `usePaneContentSplitIndicator`/`useThreadGroupSplitIndicator`
subscribed every mounted sidebar row to `splitLayoutAtom` and only
returned `NO_INDICATOR` inside `useMemo`, which does not stop jotai from
re-rendering the row when the atom changes. Splits are disabled on
compact viewports but the layout still reconciles per thread navigation,
so phones paid a second full-row render pass per navigation for nothing.

Change: subscribe to a module-level null atom when the indicator cannot
show (compact, or disabled); desktop behavior is unchanged. A test sets
the layout while compact and asserts the hook does not re-render.

Co-Authored-By: Claude <noreply@anthropic.com>
…ender

Root cause: `usePromptDraftInputThreadIds` computed its snapshot by
calling `localStorage.getItem` for every sidebar thread on every render
of the subscribing component and on every keystroke notification, and it
notified the sidebar (a full re-render) even when the edited draft's
presence did not flip.

Change: a per-subscription-set presence store caches the joined bit
string, re-reads only the key that changed on notification, stays silent
when that key's bit is unchanged, and drops its cache on subscribe so a
flip between render and subscription is not missed. localStorage stays
the source of truth (the in-memory draft cache still self-heals against
same-tab external writes), so semantics are unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
…tion

Root cause: `useQueries` without `combine` returns a new results array
every render, so `useChildThreadPendingAttention` rebuilt its map and
returned a fresh array per ThreadDetailView render (also a fresh `[]`
when nothing was pending). ThreadDetailPromptArea memoizes the prompt
stack on that value, so every view render invalidated `promptStack` and
re-rendered the memoized `FollowUpPromptBox`.

Change: module-level `combine` (TanStack structurally shares the result
only for a stable combine reference) mapping to the per-child data
lists, and a frozen shared empty array for the no-attention case. Hook
tests assert identity across re-renders.

Co-Authored-By: Claude <noreply@anthropic.com>
Root cause: babel-plugin-react-compiler bailed out of the ~1600-line
`ThreadDetailPromptArea` body, so none of its ~450 memoizable values were
cached and every draft keystroke re-ran the whole thing. Four separate
causes: `try`/`finally` blocks in the modifier-submit handler ("Handle
TryStatement with a finalizer"), three render-time ref writes
(`ref.current = value`), and composer-host closures whose ternary
returned `ref.current.draft` in one branch and the render-time draft in
the other, which made the compiler type the whole edit object as a ref
value and flag its reads during render.

Change: the sending flag flips inside a module-level
`runWhileFollowUpShortcutSending`; latest-value refs come from a tiny
`useLatestRef` hook (the render-time write lives there); the inline
attachment-error clearer is assigned in a layout effect; and the
queued/sent-message composer-host accessors are module-level functions
that take the ref. Verified with babel-plugin-react-compiler@1.0.0:
`ThreadDetailPromptArea` now reports CompileSuccess (452 slots).

Co-Authored-By: Claude <noreply@anthropic.com>
Prettier pass over the files touched so far, the navigation test drives
the router from a click instead of assigning a module variable during
render (react-hooks/immutability), and the two intentional render-time
ref accesses (`useLatestRef`, the sidebar mention-resource cache) carry
scoped eslint disables with the reason.

Co-Authored-By: Claude <noreply@anthropic.com>
…icker

Root cause: the workflow, todo and background-activity cards mounted
their full bodies while collapsed (agent tree, todo list, one row per
background task each with its own `setInterval` + state update), so a
thread with several background agents paid layout for hidden DOM and ran
N timers ticking out of phase, each a separate render.

Change: the banner's `AnimatedBody` (realize on first expand, retain
afterwards) moves to its own module and the three cards use it, keeping
their existing collapsed border look via `collapsedBorder`. Live
durations read one shared `useSecondTick()` store (a single interval
that starts with the first subscriber and stops with the last). Tests
cover realize/retain and that a collapsed card runs no timer while an
expanded three-row card runs one.

Co-Authored-By: Claude <noreply@anthropic.com>
Root cause: `experimental_useSidebarThreads` remapped every
`ThreadListEntry` into a fresh `PluginSidebarThread` on each sidebar
update, so a memoized plugin row still re-rendered for every other
thread's change even though React Query keeps unchanged entries'
identity.

Change: a WeakMap memo keyed on the entry (and the host-name map
instance it was built with) returns the same DTO while the entry is
unchanged. The SDK contract and the audit entry now state that the array
is uncapped and rows are expected to be windowed; the bundled
declarations are regenerated for the comment. Test asserts DTO identity
for the untouched thread across an update.

Co-Authored-By: Claude <noreply@anthropic.com>
Root cause: `useIsSidebarShowing`/`useOptionalIsSidebarShowing` read the
whole `SidebarContext` value, which changes on every provider commit (a
mobile close is two: the closing flag, then the deferred state flip), so
the page header and the retained ~1000-line `ThreadSecondaryPanel` body
re-rendered on each even though they only need one boolean.

Change: the provider publishes `isSidebarShowing` through a dedicated
boolean context; the two hooks read it. A test flips a suppress flag and
closes the mobile drawer and asserts the reader renders only when the
bit flips. The full context split (actions vs. state) stays a follow-up.

Co-Authored-By: Claude <noreply@anthropic.com>
Render counting via array pushes instead of reassigning a module
variable (react-hooks/globals), a fixed `startedAt` in the banner test
(react-hooks/purity), and one scoped disable block around the deliberate
render-time cache in `useSidebarThreadTitleMentionResources`.

Co-Authored-By: Claude <noreply@anthropic.com>
…tion in Ladle

- plugin-sidebar-hooks: derive the host-name map per hosts payload at module
  level so two useSidebarThreads callers keep the same WeakMap DTO entries
  instead of evicting each other's; test covers two consumers.
- plugin-slots: batch closer never drives the depth below zero after a test
  reset.
- Ladle global provider mounts RouteNavigationProvider so sidebar/thread
  stories that now navigate through useRouteNavigate do not throw on click.

Co-Authored-By: Claude <noreply@anthropic.com>
@SawyerHood
SawyerHood force-pushed the bb/mobile-perf/rerender-amplifiers branch from 2e9c195 to c78cfa9 Compare August 19, 2026 16:13
@SawyerHood
SawyerHood merged commit f8efe3c into main Aug 19, 2026
12 checks passed
@SawyerHood
SawyerHood deleted the bb/mobile-perf/rerender-amplifiers branch August 19, 2026 16:23
SawyerHood added a commit that referenced this pull request Aug 19, 2026
…lots (A5, partial) (#1897)

## What was wrong

Every plugin app bundle carried its own copy of libraries the host
already ships (audit A5). zod (~516 KB raw / ~75 KB gzip) was bundled by
four builtins, tailwind-merge + clsx by thirteen,
class-variance-authority by most, and the shared-ui `Icon` with its ~110
KB hugeicons map by twelve. Phones parsed and evaluated all of it on
every page load. `RUNTIME_SLOT_BY_SPECIFIER` in packages/plugin-build
only shimmed react, the SDK, pierre, the portal radix families, sonner
and vaul.

## What changed

- `bb plugin build` routes `zod`, `clsx`, `tailwind-merge`,
`class-variance-authority` and `@bb/shared-ui/icon` to new
`globalThis.__bbPluginRuntime` slots (`zod`, `clsx`, `tailwindMerge`,
`classVarianceAuthority`, `sharedUiIcon`). shared-ui's own components
import the icon module as `./icon`; the esbuild plugin routes that
import to the same slot when the importer lives in shared-ui's source
tree. A plugin's vendored `icon.tsx` still bundles.
- Shims forward a real default export (`mod.default` when the host
namespace has one), so `import clsx from "clsx"` receives the callable,
not the namespace.
- Host (`apps/app/src/lib/plugin-frontend.ts`) installs the five slots.
Export manifest regenerated (`@bb/shared-ui/icon` exports come from
esbuild metadata like the SDK facade).
- `PLUGIN_SDK_VERSION` 0.4.8 -> 0.4.9. Old bundles keep working (they
carry their own copies); new bundles need the new host slots, so
path/builtin plugins rebuild on the sdkVersion-differs trigger and
scaffolds pin the floor.
- Scaffold generator moves clsx/tailwind-merge/cva to type-only deps
(zod stays a dependency: server.ts bundles it). Guide and
bb-plugin-authoring skill updated. CLI scaffold-dependency test now
applies the shim exemption only to app-side files.
- `apps/app/bundle-budget.json` raised: exposing the whole zod namespace
stops the boot chunk from tree-shaking zod exports the app never calls:
boot payload 1655.7 KB / 449.1 KB brotli -> 1848.4 KB / 481.7 KB (+193
KB raw, +33 KB brotli). The other four slots add under 2 KB. Removing
`zod` from RUNTIME_SLOT_BY_SPECIFIER reverts both sides of the trade.

Builtin app.js after rebuild: 8.87 MB -> 4.01 MB raw (-55%), 1.76 MB ->
0.93 MB gzip (-47%). Per plugin (raw): ask-user-question 784 KB -> 38
KB, automations 1063 -> 315, connect 376 -> 145, custom-instructions 99
-> 9, docs 1640 -> 1419, github 416 -> 185, inline-vis 239 -> 9,
keep-awake 308 -> 79, memory 122 -> 30, provider-retry 249 -> 18,
secrets 644 -> 37, side-chat 241 -> 11, tasks 2380 -> 1638, workflows
300 -> 69.

## How you verified

- New tests in `packages/plugin-build/src/build-plugin-app.test.ts`:
every non-SDK slot has a manifest entry; a bundle importing the five
libraries compiles with no node_modules and, executed against a fake
runtime, yields the callable clsx default, `z`, `twMerge`, `cva`,
`Icon`; shared-ui's relative `./icon` import is shimmed while a plugin's
own `components/ui/icon.tsx` still bundles;
`isSharedUiIconRelativeImport` unit cases.
`apps/app/src/lib/plugin-frontend.test.ts` asserts the new slots and
their identity with the host modules.
- `pnpm exec turbo run typecheck lint` for @bb/plugin-build,
@bb/templates, @get-bb/plugin-sdk, @bb/domain, @bb/app, @bb/cli,
@bb/server: green. `pnpm exec turbo run test` for @bb/plugin-build (24
passed), @bb/templates (44), @bb/plugin-registry (1), plugin-sdk version
test, CLI
plugin-build/plugin-new/packaged-plugin-build/plugin-guide-docs/plugin-dev-loop/docs-
and github-official-plugin-bundle/plugin-scaffold-dependencies tests,
server plugin-app-bundle + plugin-sdk tests, app plugin-frontend test:
all green.
- `pnpm exec turbo run build --filter=@bb/app` + `node
scripts/check-bundle-budget.mjs`: passes with the ratchet.
- Rebuilt all 18 plugin app bundles through buildPluginApp (sizes
above).

Fixes: part of the mobile / iOS Safari performance program (verified
sweep report in the bb thread; no single issue).


## Stack context

Layer 18 of 22 in the `bb/mobile-perf/*` stack (bottom → top: quick wins
first, big rocks last).
- Prerequisite (layer below): `bb/mobile-perf/plugin-boot-defer`
(#1896).
- Next layer: `bb/mobile-perf/rerender-amplifiers` (#1898).
- Audit findings addressed: see title IDs. Review: approved-with-fixes;
1 review fix commit(s).
- Partial: A5 (bundle splitting): not implemented (outdir + esbuild
splitting + served chunks). Slots shipped; splitting plan described in
the report.
- Reviewer notes / follow-ups: A5 'splitting' (outdir + splitting, lazy
definePluginApp components) is not implemented; the branch only covers
the host-slot half of A5, as the implementer reported. | Design
trade-off to be aware of, not a defect: exposing the full zod namespace
on __bbPluginRuntime stops the host boot chunk from tree-shaking zod
(+193 KB raw / +33 KB br on boot | Compatibility note (pre-existing
pattern, same as the earlier @pierre/diffs slot): a plugin app built
with the new bb plugin build throws 'Cannot load "zod"' if loaded by an
older
- Wire/contract: No server<->host-daemon wire change;
HOST_DAEMON_PROTOCOL_VERSION untouched. Plugin runtime contract: five
new slots on globalThis.__bbPluginRuntime (zod, clsx, tailwindMerge,
classVarianceAuthority, sharedUiIcon); RUNTIME_SLOT_BY_SPECIFIER gains
zod, clsx, tailwind-merge, class-variance-authority, @bb/shared-ui/icon;
runtime shims now export `("default" in mod ? mod.default : mod)` as
default. PL

> AGENT GENERATED: by Claude Code (claude-mangosteen-eap)

---------

Co-authored-by: Claude <noreply@anthropic.com>
SawyerHood added a commit that referenced this pull request Aug 19, 2026
…d; row containment (C1, C3, C4) (#1899)

## What was wrong

- The in-progress assistant message re-ran the whole react-markdown
pipeline on every text delta (`MarkdownPreview` memoizes on `content
===`), 2-5 full parses per second of a growing document, 10-30 ms each
on a phone (C3).
- Mermaid blocks called `mermaid.render` on mount and on every streaming
delta, with no viewport gate and no reuse of a previous SVG (C4).
- Every loaded top-level timeline row stayed in layout/paint; each
style/layout pass on a phone (keyboard, orientation, streaming growth)
walked all mounted rows (C1 step 1).

## What changed

- `streaming-markdown-split.ts`: `splitStreamingMarkdown(text)` returns
a settled prefix and a live tail. The boundary is the last blank line
that is outside an open fenced block / `$$` math block / list
continuation and is followed by a complete line, so the boundary only
moves forward as text streams. `ConversationMessageContent` renders the
streaming assistant row as two memoized `MarkdownPreview` documents
(settled + tail) and falls back to one document when no boundary exists
or when the row is complete. Seam classes restore the `last:mb-0` /
`first:mt-0` margins at the split so completion does not shift layout.
`ThreadTimelineRows` computes the streaming row
(`findStreamingAssistantMessageId`: trailing leaf row while the runtime
runs, descending pending turns and pending delegations) and provides it
through a context; delegation output uses the split while pending.
- `markdown-mermaid-render-cache.ts` + `MarkdownMermaidDiagram`: one
shared IntersectionObserver gates the first render (256px root margin);
source changes after the first render are debounced 300 ms trailing and
keep the previous SVG visible; rendered SVGs are stored in a 32-entry
LRU keyed by (source, theme, palette epoch); a cached diagram paints
synchronously on mount.
- `timeline-row-containment.ts` + `TimelineRowsList`: top-level row
wrappers get `max-md:[content-visibility:auto]
max-md:[contain-intrinsic-block-size:auto_1.25rem]`; conversation rows
add an inline length-based `contain-intrinsic-block-size` estimate
(bucketed). Nested lists keep plain wrappers. Compact-only because paint
containment clips the assistant table breakout on wide layouts.

## How you verified

- New tests: `streaming-markdown-split.test.ts` (fences incl. marker
matching, `$$` blocks and inline `$$x$$`, loose lists / indented
continuation, headings, monotonic boundary while streaming);
`ConversationMessageContent.streaming.test.tsx` (mocks react-markdown to
count parses: only the tail re-parses per delta, boundary moves forward,
completion collapses to one document, open fence stays in the tail);
`ThreadTimelineRows.streaming.test.ts` (streaming row selection incl.
pending turn children, pending delegation childRows, completed turn ->
null); `markdown-mermaid-diagram.render.test.tsx` (no render before
viewport entry, one shared observer, 300 ms debounce keeps previous SVG,
remount served from cache without calling mermaid, LRU eviction);
`ThreadTimelineRows.containment.test.tsx` (top-level wrappers get the
class + conversation rows carry the estimate, nested wrappers do not).
- `pnpm exec turbo run typecheck --filter=@bb/app`: pass. `pnpm exec
turbo run lint --filter=@bb/app`: 0 errors, 147 warnings (same count as
baseline; touched files carry the same 12 pre-existing warnings as
before). `pnpm exec turbo run test --filter=@bb/app --
src/components/thread/timeline src/components/ui/markdown-*
src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx
src/views/thread-detail`: 48 files, 411 tests pass.

## Update (2026-08-19)

`content-visibility: auto` is now armed only where CSS scroll anchoring
exists (`CSS.supports("overflow-anchor", "none")`: Chromium, Firefox).
WebKit has no scroll anchoring, so a replayed size that differs from the
real row size moved the visible content and read as a flash-and-scroll
while a thread settled on iOS. iOS keeps the inert intrinsic-size
estimate; Chromium/Firefox keep the layout/paint savings. Test added for
the WebKit path.

Fixes: part of the mobile / iOS Safari performance program (verified
sweep report in the bb thread; no single issue).


## Stack context

Layer 20 of 22 in the `bb/mobile-perf/*` stack (bottom → top: quick wins
first, big rocks last).
- Prerequisite (layer below): `bb/mobile-perf/rerender-amplifiers`
(#1898).
- Next layer: `bb/mobile-perf/mobile-interactions` (#1900).
- Audit findings addressed: see title IDs. Review: approved-with-fixes;
1 review fix commit(s).
- Reviewer notes / follow-ups: C1 still needs an iOS Simulator pass
(Linux review box; caniuse confirms iOS Safari through 26.3 has no
scroll anchoring). With the fix, rows are laid out once before opting
in, so | C3 (design trade-off, not fixed): plugin message-directive
components and stateful markdown widgets (code-block wrap toggle) that
sit in the live tail remount when the boundary mov | C4: cached SVGs
reuse the first renderer's element ids, so two simultaneously mounted
copies of the same diagram (e.g. split view of the same thread) share
ids; visually identical | C1: paint containment can clip the assistant
table breakout by ~2px inside expanded turn bodies on compact (nested
list is pl-3/pr-2 asymmetric). Negligible.
- Wire/contract: None. No server/daemon wire, route, command or
protocol-version change. New required prop `streaming: boolean` on the
assistant variant of ConversationMessageContent (app-internal component
contract; stories/tests updated).

> AGENT GENERATED: by Claude Code (claude-mangosteen-eap)

---------

Co-authored-by: Claude <noreply@anthropic.com>
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