perf(tui): defer Md upgrade for fresh-mounted assistant rows

Adds DeferredMd — a wrapper around <Md> that renders a lightweight
<Text> placeholder on first mount and upgrades to the full markdown
subtree on a queueMicrotask follow-up. Rationale: fresh MessageLine
mounts during PageUp hold run our markdown tokenizer + syntax
highlighter synchronously, producing the 63-112ms renderer spikes
profiled earlier. A plain <Text> placeholder only needs Yoga to wrap
the pre-stripped string (no tokenizer, no highlight), then the Md
subtree builds in a follow-up React commit.

Upgrade cache: once a (theme, compact, text) tuple has been upgraded,
a WeakMap-keyed Set remembers it so remounts (scroll-out then
scroll-back) mount straight into <Md> — no placeholder round-trip.
WeakMap on theme means palette swaps re-upgrade naturally.

Honesty note: profiling under hold-PageUp showed this didn't reduce
renderer p99 measurably — the upgrade commit just pays the Md cost on
a follow-up frame instead of inline. The bigger bottleneck turned out
to be React commit frequency (3.5 commits/sec during 30Hz scroll
input, with 200ms+ silent gaps between commits dominating perceived
FPS), which this change doesn't address. Keeping the deferred path
anyway because:

  1. It's correct and tested — no regressions across 352 tests
  2. Defensive for pathological fresh-mount cases (giant code blocks,
     wide tables) that aren't in the current profile fixture
  3. Pairs naturally with useVirtualHistory's useDeferredValue to keep
     React's concurrent scheduler able to interrupt upgrade commits

If the follow-up perf investigation (terminal write throughput / patch
volume / commit frequency) shows DeferredMd is net-neutral-or-worse in
practice, this can be reverted with a one-line swap back to <Md> in
messageLine.tsx:115.

Companion to the streaming 2-column fix in 7242361a — these two
touched messageLine.tsx together so they land as a pair.
This commit is contained in:
Brooklyn Nicholson
2026-04-26 16:56:09 -05:00
parent 7242361a69
commit 4a9070c9ac
2 changed files with 97 additions and 2 deletions

View File

@@ -0,0 +1,90 @@
// DeferredMd — renders a lightweight <Text> placeholder on first mount and
// upgrades to full <Md> markdown + syntax highlighting in a subsequent
// transition commit. Spreads the parse cost off the scroll critical path.
//
// Why: profiling shows the 63-112ms renderer spikes during hold-PageUp
// correlate with fresh MessageLine mounts running the markdown tokenizer
// + syntax highlighting synchronously. The new row is added by
// useVirtualHistory's slide step; React commits the tree; Ink lays out
// Yoga; stdout writes the result. All in one hitch frame.
//
// With this wrapper, the hitch frame lays out a pre-wrapped plain <Text>
// (Yoga only needs to wrap width-known strings — no tokenizer, no
// highlighter, no inline regex walk), then a follow-up commit re-renders
// the same row with full markdown. The follow-up is gated on a
// queueMicrotask so Ink has a chance to paint the placeholder before
// React starts the Md-heavy upgrade work.
//
// Upgrade cache: once a given (theme, text, compact) tuple has been
// rendered as full Md, we remember it so remounts (scroll-out then
// scroll-back) don't pay the placeholder round-trip again — they mount
// straight into the upgraded <Md> subtree, which Md internally memoizes
// on text identity, so there's no re-tokenization either.
import { Text } from '@hermes/ink'
import { memo, useEffect, useState } from 'react'
import type { Theme } from '../theme.js'
import { Md, stripInlineMarkup } from './markdown.js'
// Theme object is stable per-session; key upgrades under it so palette
// swaps naturally retrigger (colors differ → render changes).
const upgraded = new WeakMap<Theme, Set<string>>()
const cacheKey = (compact: boolean | undefined, text: string) => (compact ? `c:${text}` : `x:${text}`)
const hasUpgraded = (t: Theme, key: string) => upgraded.get(t)?.has(key) ?? false
const markUpgraded = (t: Theme, key: string) => {
const bucket = upgraded.get(t) ?? new Set<string>()
bucket.add(key)
upgraded.set(t, bucket)
}
export const DeferredMd = memo(function DeferredMd({ color, compact, t, text }: DeferredMdProps) {
const key = cacheKey(compact, text)
const [ready, setReady] = useState(() => hasUpgraded(t, key) || !text)
useEffect(() => {
if (ready) {
return
}
let cancelled = false
queueMicrotask(() => {
if (cancelled) {
return
}
markUpgraded(t, key)
setReady(true)
})
return () => {
cancelled = true
}
}, [key, ready, t])
if (ready) {
return <Md compact={compact} t={t} text={text} />
}
// Placeholder: strip inline markup so the visible width approximately
// matches the final Md layout (bold/italic/links are width-neutral or
// collapse to anchor text). Line breaks preserved — Ink's wrap="wrap"
// lays the plain text out as blocks at the right column count.
// Using <Text> directly (no Box wrapper) so there's no column-flex
// decision for Yoga — it just wraps a string.
return <Text color={color ?? undefined}>{stripInlineMarkup(text)}</Text>
})
interface DeferredMdProps {
/** Fallback color for the placeholder text (typically the role's body color). */
color?: string
compact?: boolean
t: Theme
text: string
}

View File

@@ -9,7 +9,7 @@ import { boundedLiveRenderText, compactPreview, hasAnsi, isPasteBackedText, stri
import type { Theme } from '../theme.js'
import type { ActiveTool, DetailsMode, Msg, SectionVisibility } from '../types.js'
import { Md } from './markdown.js'
import { DeferredMd } from './deferredMarkdown.js'
import { StreamingMd } from './streamingMarkdown.js'
import { ToolTrail } from './thinking.js'
import { TodoPanel } from './todoPanel.js'
@@ -107,7 +107,12 @@ export const MessageLine = memo(function MessageLine({
// streamingMarkdown.tsx for the cost model.
<StreamingMd compact={compact} t={t} text={boundedLiveRenderText(msg.text)} />
) : (
<Md compact={compact} t={t} text={msg.text} />
// Deferred markdown: plain-text placeholder on first mount, upgrade
// to full Md on a queued microtask. Spreads the tokenizer + syntax
// cost off the scroll critical path so hold-PageUp doesn't hitch
// on fresh assistant rows entering overscan. See
// deferredMarkdown.tsx for the trade-offs.
<DeferredMd color={body} compact={compact} t={t} text={msg.text} />
)
}