From 1a74ef4ad86ce9826407712682e9cfdfdf914496 Mon Sep 17 00:00:00 2001 From: mintaka Date: Sun, 23 Aug 2026 22:08:00 -0400 Subject: [PATCH] perf(ui): highlight settled code on the leading edge, debounce only streaming ticks (RIG-1422) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A settled (non-streaming) fenced block — every historical message in a channel — used to sit on the plain `
` fallback for HIGHLIGHT_DEBOUNCE_MS
before Shiki colorized it, because the highlight kickoff was gated behind the
trailing debounce from the very first tick. The debounce exists only to collapse
a burst of streaming growth ticks into one tokenize pass; a block that is not
growing pays that latency for nothing.

Fire the FIRST highlight for a fresh block immediately (leading edge) and
debounce only subsequent streaming growth ticks. The distinction cannot be made
per-instance: under `renderingStrategy="reconcile"` solid-markdown rebuilds the
fenced subtree on every growth tick, so `BlockCode` is reconstructed each tick
and a per-instance `firstRun` flag would read true every tick — firing an
immediate highlight per tick and reintroducing the O(n^2) re-tokenize the
debounce collapses. So the leading-edge decision lives in a module-level gate,
`isLeadingEdgeHighlight(lang, code, windowMs)` in highlight-cache.ts (colocated
with the R1 cache — both are cross-instance highlight-scheduling state): a tick
is a growth tick only when its code strictly extends a recent same-lang snapshot
within the debounce window; everything else is a leading edge.

The gate keeps a short, window-pruned LIST of recent `(lang, code)` snapshots,
not a single slot. A message with more than one fence rebuilds every fence in
the same reconcile tick, in document order, so a single slot would be clobbered
by an earlier fence before a later, still-growing fence could read it — the
grower would never match its own prior code and would (mis)fire an immediate
re-tokenize every tick, the exact O(n^2) this gate exists to prevent. Matching a
tick against ANY recent same-lang snapshot lets each fence find its own prior
code regardless of siblings scheduled in between. The list is bounded by the
window (stale entries pruned) with a coarse size cap as a backstop, and uses
`performance.now()` (monotonic) so a wall-clock adjustment cannot reorder
snapshots. A reconstructed instance calls the gate once at construction and
either sets `settled` synchronously (leading edge) or arms the existing 150ms
trailing timer (growth tick). `clearHighlightCache()` also clears the snapshots
so nothing leaks between tests.

Post-swap remeasure was proved unnecessary rather than added: the plain
fallback `pre.code-block` and the resolved `.code-highlight pre` share one CSS
rule block with identical box metrics (padding, border-radius, font,
line-height), the outer block margin is carried identically and the inner
wrapper is margin-zeroed so it is never double-counted, only `background` is
dropped on swap, and Shiki preserves the source line count — so the swap is
height-identical by construction and MessageStream.tsx is untouched.

Tests: a leading-edge contract test (the cache-miss counterpart to the R1
sync-cache test) asserts a settled fence asks the highlighter and paints its
markup WITHOUT ever elapsing the debounce flush. A burst-collapse test asserts a
single fence's within-window growth ticks collapse to one leading pass plus one
trailing pass, not one per tick. A multi-fence regression test asserts a second
streaming fence beside a settled one keeps debouncing (it fails against a
single-slot gate, where the sibling clobber re-tokenizes every tick). A direct
unit test of `isLeadingEdgeHighlight` pins the gate's own invariants without
mocking time — lang must match, identical code is not a growth tick, an
interleaved sibling does not steal a stream's classification, a zero-width
window disables growth detection, `clearHighlightCache()` resets the snapshots,
and a snapshot evicted by the size cap reads as a fresh leading edge. The
existing streaming kickoff-count and stale-resolution tests are unchanged and
green — they flush 200ms (> the 150ms window) between ticks, so each tick still
classifies as a fresh leading edge and fires its own request.

Refs RIG-1422
---
 apps/ui/src/components/MarkdownText.test.tsx | 115 +++++++++++++++++++
 apps/ui/src/components/MarkdownText.tsx      |  28 +++++
 apps/ui/src/markdown/highlight-cache.test.ts |  72 ++++++++++++
 apps/ui/src/markdown/highlight-cache.ts      |  81 ++++++++++++-
 4 files changed, 292 insertions(+), 4 deletions(-)
 create mode 100644 apps/ui/src/markdown/highlight-cache.test.ts

diff --git a/apps/ui/src/components/MarkdownText.test.tsx b/apps/ui/src/components/MarkdownText.test.tsx
index 60c416d9..74880e19 100644
--- a/apps/ui/src/components/MarkdownText.test.tsx
+++ b/apps/ui/src/components/MarkdownText.test.tsx
@@ -505,6 +505,121 @@ describe("MarkdownText — code highlighting with plain fallback", () => {
 		// The appended prose rendered too (the grow actually took effect).
 		expect(container.textContent).toContain("more prose below");
 	});
+
+	// RIG-1422: leading-edge first highlight. A settled (non-streaming) block —
+	// every historical message in a channel — must colorize on FIRST paint, not
+	// sit on the plain `
` fallback for HIGHLIGHT_DEBOUNCE_MS. This is the
+	// cache-MISS counterpart to the R1 test above (which proves the sync-cache
+	// paint): here the (lang, code) pair is NOT cached (afterEach cleared it), so
+	// the highlight runs the async path — but on the leading edge, kicked
+	// immediately rather than gated behind the 150ms trailing timer. The proof is
+	// that the highlighter is asked, and its markup paints, WITHOUT ever elapsing
+	// `flushHighlightDebounce()`. A debounced-from-the-first-tick implementation
+	// would leave `asked` empty until the 150ms window passed.
+	test("a settled fence highlights on first paint, not gated behind the debounce (leading edge, cache miss)", async () => {
+		const HIGHLIGHTED =
+			'
const x = 1;
'; + const asked: string[] = []; + mock.module("../markdown/highlighter", () => ({ + ...realHighlighter, + highlightToHtml: (code: string, lang: string) => { + asked.push(code); + return Promise.resolve( + lang === "ts" && code.includes("const x = 1;") ? HIGHLIGHTED : null, + ); + }, + })); + const { container } = render(() => ( + + )); + // The highlight is kicked on the leading edge: the highlighter was asked + // synchronously at render, with NO debounce window elapsed. (A trailing-only + // debounce would have asked nothing yet.) + flush(); + expect(asked.length).toBe(1); + expect(asked[0]).toContain("const x = 1;"); + // And the resolved markup paints — still without ever elapsing the debounce + // flush, so no plain-fallback frame is gated behind the 150ms timer. + await waitFor( + () => expect(container.querySelector('[data-le="hit"]')).not.toBeNull(), + { timeout: HIGHLIGHT_WAIT_MS }, + ); + expect(container.querySelector("pre.code-block")).toBeNull(); + }); + + // RIG-1422: the leading-edge gate must NOT reintroduce the per-tick re-tokenize + // the debounce exists to collapse. A single fence grown across ticks that + // arrive inside the debounce window (no `flushHighlightDebounce` between them) + // must issue ONE leading-edge pass, then collapse the whole growth burst into + // exactly ONE trailing pass — not one per tick. + test("a fence's within-window growth ticks collapse to one trailing highlight", async () => { + const asked: string[] = []; + mock.module("../markdown/highlighter", () => ({ + ...realHighlighter, + // Never resolves: we count HOW MANY times the highlighter is asked, not + // what it returns. + highlightToHtml: (code: string) => { + asked.push(code); + return new Promise(() => {}); + }, + })); + const [text, setText] = createSignal("```ts\nL0\n```"); + render(() => ); + flush(); + // Leading edge: the fresh fence is asked once, immediately. + expect(asked.length).toBe(1); + // Three growth ticks inside the window (flush() is synchronous, far under + // HIGHLIGHT_DEBOUNCE_MS) — each is a growth tick, debounced, so no new ask. + setText("```ts\nL0\nL1\n```"); + flush(); + setText("```ts\nL0\nL1\nL2\n```"); + flush(); + setText("```ts\nL0\nL1\nL2\nL3\n```"); + flush(); + expect(asked.length).toBe(1); + // Once the window elapses, the burst collapses to exactly ONE trailing pass + // carrying the latest text — not one pass per tick. + await flushHighlightDebounce(); + expect(asked.length).toBe(2); + expect(asked[1]).toContain("L3"); + }); + + // RIG-1422 regression: the leading-edge gate is a MODULE-LEVEL record, so a + // message with more than one fence rebuilds every fence in the same reconcile + // tick. A naive single-slot record lets an earlier fence clobber the slot + // before a later, still-growing fence reads it, so the grower never matches + // its OWN prior code and (mis)fires an immediate re-tokenize every tick — the + // exact O(n²) the debounce collapses. The gate must match a growing fence + // against its own recent snapshot regardless of siblings scheduled between. + test("a second streaming fence beside a settled one still debounces (no per-tick re-tokenize)", async () => { + const asked: string[] = []; + mock.module("../markdown/highlighter", () => ({ + ...realHighlighter, + highlightToHtml: (code: string) => { + asked.push(code); + return new Promise(() => {}); + }, + })); + // F1 is settled; F2 streams. Count only F2's asks (its "GROW" marker) so + // F1's own per-tick reconstruction (harmless — it hits the R1 cache in + // production) does not pollute the assertion. + const F1 = "```ts\nconst settled = 1;\n```"; + const grows = () => asked.filter((c) => c.includes("GROW")).length; + const [text, setText] = createSignal(`${F1}\n\n\`\`\`ts\nGROW\n\`\`\``); + render(() => ); + flush(); + // F2's leading edge: asked once. + expect(grows()).toBe(1); + // Grow ONLY F2, within the window. Under a single-slot gate F1 clobbers the + // slot each tick, so F2 reads as a leading edge and re-asks every tick + // (grows() climbs). The window-list gate matches F2 against its own prior + // snapshot → growth tick → debounced → no new ask. + setText(`${F1}\n\n\`\`\`ts\nGROW\nMORE\n\`\`\``); + flush(); + setText(`${F1}\n\n\`\`\`ts\nGROW\nMORE\nEVEN\n\`\`\``); + flush(); + expect(grows()).toBe(1); + }); }); describe("MarkdownText — link safety", () => { diff --git a/apps/ui/src/components/MarkdownText.tsx b/apps/ui/src/components/MarkdownText.tsx index 4a593629..d4ca3ebc 100644 --- a/apps/ui/src/components/MarkdownText.tsx +++ b/apps/ui/src/components/MarkdownText.tsx @@ -12,6 +12,7 @@ import { import type { Account } from "../comms-stub"; import { getCachedHighlight, + isLeadingEdgeHighlight, setCachedHighlight, } from "../markdown/highlight-cache"; import { highlightToHtml } from "../markdown/highlighter"; @@ -137,9 +138,36 @@ function BlockCode(props: { code: string; codeClass?: string }) { // setTimeout/cleanup cycle in the (untracked) apply phase so it only re-runs // when a tracked source changes. Solid 2's two-arg createEffect returns its // cleanup from the apply phase. + // + // Leading-edge first, trailing-debounce the rest (RIG-1422): a settled + // (non-streaming) block — every historical message in a channel — must + // colorize on first paint, not sit on the plain `
` fallback for
+	// HIGHLIGHT_DEBOUNCE_MS while a timer it never needed elapses. So a FRESH
+	// block pushes `[code, lang]` into `settled` immediately (no timer); only a
+	// streaming GROWTH tick arms the trailing timer, collapsing a burst into one
+	// pass exactly as before.
+	//
+	// Why the leading-edge test lives OUTSIDE this instance: under
+	// `renderingStrategy="reconcile"` solid-markdown REBUILDS the fenced subtree
+	// on every growth tick, so `BlockCode` is reconstructed each tick (a fresh
+	// closure — verified: the component body re-runs per tick). A per-instance
+	// `firstRun` flag would therefore read true on every tick and fire an
+	// immediate highlight for each, reintroducing the O(n²) re-tokenize the
+	// debounce exists to collapse. `isLeadingEdgeHighlight` is module-level so a
+	// reconstructed instance can tell a fresh block (immediate) from a growth tick
+	// of a stream still inside its debounce window (keep debouncing).
+	const immediate = isLeadingEdgeHighlight(
+		lang(),
+		props.code,
+		HIGHLIGHT_DEBOUNCE_MS,
+	);
 	createEffect(
 		() => [props.code, lang()] as const,
 		([nextCode, nextLang]) => {
+			if (immediate) {
+				setSettled([nextCode, nextLang] as const);
+				return;
+			}
 			const t = setTimeout(
 				() => setSettled([nextCode, nextLang] as const),
 				HIGHLIGHT_DEBOUNCE_MS,
diff --git a/apps/ui/src/markdown/highlight-cache.test.ts b/apps/ui/src/markdown/highlight-cache.test.ts
new file mode 100644
index 00000000..15337875
--- /dev/null
+++ b/apps/ui/src/markdown/highlight-cache.test.ts
@@ -0,0 +1,72 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import { clearHighlightCache, isLeadingEdgeHighlight } from "./highlight-cache";
+
+// A window large enough that no snapshot is ever time-pruned within a single
+// test — so these cases isolate the classification logic (lang match, strict
+// extension, newest-first scan, size cap) from wall-clock timing. The `windowMs
+// = 0` case below deliberately exercises the time-window boundary instead.
+const WINDOW = 100_000;
+
+describe("isLeadingEdgeHighlight", () => {
+	afterEach(() => {
+		clearHighlightCache();
+	});
+
+	test("a fresh (lang, code) highlights on the leading edge", () => {
+		expect(isLeadingEdgeHighlight("ts", "const a", WINDOW)).toBe(true);
+	});
+
+	test("re-scheduling the identical (lang, code) is a leading edge, not a growth tick", () => {
+		expect(isLeadingEdgeHighlight("ts", "const a", WINDOW)).toBe(true);
+		// identical code is not a *strict* extension of its own prior snapshot.
+		expect(isLeadingEdgeHighlight("ts", "const a", WINDOW)).toBe(true);
+	});
+
+	test("code that strictly extends a recent same-lang snapshot is a debounced growth tick", () => {
+		expect(isLeadingEdgeHighlight("ts", "const a", WINDOW)).toBe(true);
+		expect(isLeadingEdgeHighlight("ts", "const a = 1", WINDOW)).toBe(false);
+	});
+
+	test("a strict extension in a different language is a leading edge (lang must match)", () => {
+		expect(isLeadingEdgeHighlight("ts", "const a", WINDOW)).toBe(true);
+		expect(isLeadingEdgeHighlight("py", "const a = 1", WINDOW)).toBe(true);
+	});
+
+	test("an interleaved sibling fence does not steal a stream's growth classification", () => {
+		// Two fences scheduled in one reconcile tick (document order), then both
+		// grow in the next — the exact multi-fence case the single-slot gate got
+		// wrong. Each grower must match its OWN prior snapshot despite the sibling
+		// snapshot appended in between.
+		expect(isLeadingEdgeHighlight("ts", "x", WINDOW)).toBe(true); // F1 v1
+		expect(isLeadingEdgeHighlight("ts", "a", WINDOW)).toBe(true); // F2 v1
+		expect(isLeadingEdgeHighlight("ts", "xy", WINDOW)).toBe(false); // F1 v2 — extends "x"
+		expect(isLeadingEdgeHighlight("ts", "ab", WINDOW)).toBe(false); // F2 v2 — extends "a"
+	});
+
+	test("windowMs = 0 disables growth detection — every tick is a leading edge", () => {
+		expect(isLeadingEdgeHighlight("ts", "const a", 0)).toBe(true);
+		// A strict extension, but a zero-width window admits no recent snapshot.
+		expect(isLeadingEdgeHighlight("ts", "const a = 1", 0)).toBe(true);
+	});
+
+	test("clearHighlightCache resets the snapshots so a later extension reads as fresh", () => {
+		expect(isLeadingEdgeHighlight("ts", "const a", WINDOW)).toBe(true);
+		clearHighlightCache();
+		// Without the reset this would classify as a growth tick (false).
+		expect(isLeadingEdgeHighlight("ts", "const a = 1", WINDOW)).toBe(true);
+	});
+
+	test("the snapshot list is size-capped — extending a snapshot evicted by the cap reads as a leading edge", () => {
+		// Push MAX_RECENT + 1 distinct, non-prefix-related snapshots inside one
+		// window (trailing "." keeps e.g. "f1." from being a prefix of "f10.").
+		// The size cap keeps the newest 64, so the oldest ("f0.") is evicted.
+		const CAP = 64;
+		for (let i = 0; i <= CAP; i++) {
+			expect(isLeadingEdgeHighlight("ts", `f${i}.`, WINDOW)).toBe(true);
+		}
+		// Extending the evicted oldest: no surviving snapshot matches → leading edge.
+		expect(isLeadingEdgeHighlight("ts", "f0.x", WINDOW)).toBe(true);
+		// Extending a snapshot still within the cap: a growth tick.
+		expect(isLeadingEdgeHighlight("ts", `f${CAP}.x`, WINDOW)).toBe(false);
+	});
+});
diff --git a/apps/ui/src/markdown/highlight-cache.ts b/apps/ui/src/markdown/highlight-cache.ts
index 3c98318f..63755e70 100644
--- a/apps/ui/src/markdown/highlight-cache.ts
+++ b/apps/ui/src/markdown/highlight-cache.ts
@@ -48,10 +48,83 @@ export function setCachedHighlight(
 	cache.set(key(lang, code), html);
 }
 
-/** Empty the cache. The cache is module-level and persists across component
- *  instances by design (that persistence is what R1 buys); a test that asserts
- *  on the highlight path must clear it between cases so a prior render's entry
- *  does not seed a later one. */
+/** The recent `(lang, code)` snapshots blocks have scheduled a highlight for, in
+ *  schedule order (oldest first) — the cross-instance thread the leading-edge
+ *  gate needs (RIG-1422).
+ *
+ *  `BlockCode` is reconstructed on every streaming growth tick (solid-markdown
+ *  rebuilds the fenced subtree under `renderingStrategy="reconcile"`), so a
+ *  per-instance "first run" flag reads true every tick and cannot tell a fresh
+ *  settled block from a growth tick. A module-level record can — but a SINGLE
+ *  slot is wrong: a message with more than one fence rebuilds every fence in the
+ *  same reconcile tick, in document order, so an earlier fence clobbers the slot
+ *  before a later, still-growing fence reads it; the grower never sees its own
+ *  prior code and (mis)fires an immediate re-tokenize every tick — the exact
+ *  O(n²) the debounce exists to collapse. A short list, matched against ANY
+ *  recent same-lang snapshot, lets each fence find its own prior code regardless
+ *  of siblings scheduled in between. Bounded by the debounce window (stale
+ *  entries pruned) with a coarse size cap as a backstop. */
+const recentScheduled: { lang: string; code: string; at: number }[] = [];
+const MAX_RECENT = 64;
+
+/** Whether `(lang, code)` should highlight on the LEADING edge — immediately,
+ *  no debounce — vs. be debounced as a streaming growth tick. Leading edge for a
+ *  fresh block and for a batch of distinct settled blocks (a history load, where
+ *  no two are a prefix of each other); debounced only when this code strictly
+ *  extends a recent same-lang snapshot within `windowMs` (an active stream).
+ *
+ *  Records `(lang, code)` as a recent snapshot as a side effect, so later calls
+ *  see it. `windowMs` guards against a stale snapshot: two blocks far apart in
+ *  time that happen to be prefix-related are independent, not a stream. A
+ *  settled block that coincidentally extends a sibling settled block rendered in
+ *  the same paint is misread as a growth tick (debounced ~one window, then
+ *  colorized) — a rare one-time timing shift, never a wrong render. Uses
+ *  `performance.now()` (monotonic) so a wall-clock adjustment can't reorder
+ *  snapshots. */
+export function isLeadingEdgeHighlight(
+	lang: string,
+	code: string,
+	windowMs: number,
+): boolean {
+	const now = performance.now();
+	// Scan newest-first: an active stream matches its own latest prior snapshot
+	// even when sibling fences were scheduled in between. Snapshots are appended
+	// in (monotonic) time order, so once one is older than the window every
+	// earlier one is too — stop there.
+	let isGrowthTick = false;
+	for (let i = recentScheduled.length - 1; i >= 0; i--) {
+		const prev = recentScheduled[i];
+		if (now - prev.at >= windowMs) break;
+		if (
+			prev.lang === lang &&
+			code !== prev.code &&
+			code.startsWith(prev.code)
+		) {
+			isGrowthTick = true;
+			break;
+		}
+	}
+	recentScheduled.push({ lang, code, at: now });
+	// Prune snapshots older than the window (a prefix of the array, by time
+	// order); cap the length as a coarse backstop against a dense burst.
+	const cutoff = now - windowMs;
+	let stale = 0;
+	while (stale < recentScheduled.length && recentScheduled[stale].at <= cutoff)
+		stale++;
+	if (stale > 0) recentScheduled.splice(0, stale);
+	if (recentScheduled.length > MAX_RECENT)
+		recentScheduled.splice(0, recentScheduled.length - MAX_RECENT);
+	return !isGrowthTick;
+}
+
+/** Empty the cache AND clear the leading-edge snapshots. The cache is
+ *  module-level and persists across component instances by design (that
+ *  persistence is what R1 buys); a test that asserts on the highlight path must
+ *  clear it between cases so a prior render's entry does not seed a later one —
+ *  and must reset the leading-edge snapshots (RIG-1422) too, so a prior test's
+ *  recently-scheduled code cannot make a later test's fresh block read as a
+ *  growth tick. */
 export function clearHighlightCache(): void {
 	cache.clear();
+	recentScheduled.length = 0;
 }