From 6ab12fdb61823ca0bbabb740db18ac7ded26c82a Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Mon, 10 Aug 2026 13:50:20 +0000 Subject: [PATCH] fix(ui): stop sustained Shiki re-highlight of unchanged markdown Content-address the markdown HTML cache and memoize Shiki worker/client results so remounts and long sessions no longer re-tokenize stable code blocks (openchamber/openchamber#2769). Co-authored-by: Serhii Dziupin --- .../components/chat/MarkdownRendererImpl.tsx | 8 +- .../chat/markdown/highlightResultCache.ts | 97 ++++++++++++ .../chat/markdown/markdown-shiki.worker.ts | 47 +++++- .../chat/markdown/markdown-worker.ts | 114 ++++++++++++--- .../components/chat/markdown/markdownCore.ts | 55 ++++--- .../markdownHighlightingRepro.test.ts | 138 ++++++++++++++++++ 6 files changed, 423 insertions(+), 36 deletions(-) create mode 100644 packages/ui/src/components/chat/markdown/highlightResultCache.ts create mode 100644 packages/ui/src/components/chat/markdown/markdownHighlightingRepro.test.ts diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index 0a8bef4c..c5294f10 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -1117,12 +1117,18 @@ const SimpleMarkdownRendererImpl: React.FC<{ const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]); const ctx = useDecorateContext(currentTheme, false, undefined, mermaidControls); + // Content-addressed render cache (see markdownCore) ignores this string for + // lookup; keep a stable per-content key so effect deps stay aligned with text. + const cacheKey = React.useMemo( + () => `simple:${variant}:${renderedContent.length}:${renderedContent.slice(0, 64)}`, + [variant, renderedContent], + ); useMorphdomMarkdown({ containerRef, text: renderedContent, streaming: false, - cacheKey: `simple:${variant}`, + cacheKey, syntaxVars, ctx, }); diff --git a/packages/ui/src/components/chat/markdown/highlightResultCache.ts b/packages/ui/src/components/chat/markdown/highlightResultCache.ts new file mode 100644 index 00000000..1f5fa2cb --- /dev/null +++ b/packages/ui/src/components/chat/markdown/highlightResultCache.ts @@ -0,0 +1,97 @@ +// Bounded LRU for Shiki highlight results. +// +// Used on both the main-thread worker client and inside the markdown-shiki +// worker so unchanged code is never re-tokenized. Keys are exact +// (lang + source [+ theme]); eviction is by entry count and approximate byte +// size so long sessions cannot grow unbounded. + +export type HighlightResultCacheOptions = { + maxEntries: number; + maxBytes: number; +}; + +const utf8Bytes = (value: string): number => { + // Prefer TextEncoder when available (browser / modern Bun); fall back to + // UTF-16 length × 2 as a conservative upper bound for older runtimes. + if (typeof TextEncoder !== 'undefined') { + return new TextEncoder().encode(value).length; + } + return value.length * 2; +}; + +export class HighlightResultCache { + private readonly maxEntries: number; + private readonly maxBytes: number; + private readonly sizeOf: (key: string, value: T) => number; + private readonly map = new Map(); + private totalBytes = 0; + + constructor( + options: HighlightResultCacheOptions, + sizeOf: (key: string, value: T) => number, + ) { + this.maxEntries = Math.max(1, options.maxEntries); + this.maxBytes = Math.max(1, options.maxBytes); + this.sizeOf = sizeOf; + } + + get size(): number { + return this.map.size; + } + + get bytes(): number { + return this.totalBytes; + } + + get(key: string): T | undefined { + const value = this.map.get(key); + if (value === undefined) return undefined; + // Refresh LRU order. + this.map.delete(key); + this.map.set(key, value); + return value; + } + + set(key: string, value: T): void { + const existing = this.map.get(key); + if (existing !== undefined) { + this.totalBytes -= this.sizeOf(key, existing); + this.map.delete(key); + } + + const entrySize = this.sizeOf(key, value); + while ( + this.map.size > 0 + && (this.map.size >= this.maxEntries || this.totalBytes + entrySize > this.maxBytes) + ) { + const oldest = this.map.keys().next().value; + if (oldest === undefined) break; + const oldestValue = this.map.get(oldest); + if (oldestValue !== undefined) { + this.totalBytes -= this.sizeOf(oldest, oldestValue); + } + this.map.delete(oldest); + // Always allow a single oversized entry so huge files still cache once. + if (this.map.size === 0) break; + } + + this.map.set(key, value); + this.totalBytes += entrySize; + } + + clear(): void { + this.map.clear(); + this.totalBytes = 0; + } +} + +/** Byte size for string→string highlight HTML caches (key + value). */ +export const stringPairSize = (key: string, value: string): number => + utf8Bytes(key) + utf8Bytes(value); + +/** Byte size for string→string[] line caches. */ +export const stringLinesSize = (key: string, value: string[]): number => { + let total = utf8Bytes(key); + for (const line of value) total += utf8Bytes(line); + return total; +}; diff --git a/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts b/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts index a06b81fc..7e19b73c 100644 --- a/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts +++ b/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts @@ -1,8 +1,13 @@ /// import { bundledLanguages, createHighlighter, type BundledLanguage, type ThemedToken } from 'shiki'; +import { + HighlightResultCache, + stringLinesSize, + stringPairSize, +} from './highlightResultCache'; import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdownShikiThemeDefinition'; -import type { MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol'; +import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol'; // Shiki FontStyle bitmask (from @shikijs/types). Inlined to avoid an extra import. const FONT_STYLE_ITALIC = 1; @@ -32,6 +37,25 @@ let highlighter: ReturnType | undefined; // Serialize work so language loading / tokenization never overlaps. let queue = Promise.resolve(); +// Memoize tokenization by exact source. Without this, every remount / cache +// miss on the main thread re-runs codeToHtml for unchanged content and pins a +// core (openchamber/openchamber#2769). +const WORKER_CACHE_MAX_ENTRIES = 1500; +const WORKER_CACHE_MAX_BYTES = 16 * 1024 * 1024; + +const htmlCache = new HighlightResultCache( + { maxEntries: WORKER_CACHE_MAX_ENTRIES, maxBytes: WORKER_CACHE_MAX_BYTES }, + stringPairSize, +); +const linesCache = new HighlightResultCache( + { maxEntries: WORKER_CACHE_MAX_ENTRIES, maxBytes: WORKER_CACHE_MAX_BYTES }, + stringLinesSize, +); +const tokensCache = new HighlightResultCache( + { maxEntries: WORKER_CACHE_MAX_ENTRIES, maxBytes: WORKER_CACHE_MAX_BYTES }, + (key, value) => stringPairSize(key, JSON.stringify(value)), +); + const ensureHighlighter = (): ReturnType => { highlighter ??= createHighlighter({ // Cast: the theme is a CSS-variable TextMate theme; Shiki accepts the shape. @@ -74,6 +98,12 @@ const resolveLanguage = async (instance: Instance, requested: string): Promise): Promise { try { + const cacheKey = `${request.lang}\0${request.code}`; + const cached = htmlCache.get(cacheKey); + if (cached !== undefined) { + post({ type: 'highlight', id: request.id, html: cached }); + return; + } const instance = await ensureHighlighter(); const lang = await resolveLanguage(instance, request.lang); const html = instance.codeToHtml(request.code, { @@ -81,6 +111,7 @@ async function highlight(request: Extract): Promise { try { + const cacheKey = `${request.themeName}\0${request.lang}\0${request.code}`; + const cached = tokensCache.get(cacheKey); + if (cached !== undefined) { + post({ type: 'highlightTokens', id: request.id, lines: cached }); + return; + } const instance = await ensureHighlighter(); if (request.theme && !instance.getLoadedThemes().includes(request.themeName)) { // Cast: a resolved TextMate theme object from the app theme registry. @@ -102,6 +139,7 @@ async function highlightTokens(request: Extract line.map((token) => [token.content.length, token.color ?? '', token.fontStyle ?? 0] as [number, string, number]), ); + tokensCache.set(cacheKey, lines); post({ type: 'highlightTokens', id: request.id, lines }); } catch (error) { post({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error) }); @@ -110,6 +148,12 @@ async function highlightTokens(request: Extract): Promise { try { + const cacheKey = `${request.lang}\0${request.code}`; + const cached = linesCache.get(cacheKey); + if (cached !== undefined) { + post({ type: 'highlightLines', id: request.id, lines: cached }); + return; + } const instance = await ensureHighlighter(); const lang = await resolveLanguage(instance, request.lang); const { tokens } = instance.codeToTokens(request.code, { @@ -117,6 +161,7 @@ async function highlightLines(request: Extract line.map(tokenSpan).join('')); + linesCache.set(cacheKey, lines); post({ type: 'highlightLines', id: request.id, lines }); } catch (error) { post({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error) }); diff --git a/packages/ui/src/components/chat/markdown/markdown-worker.ts b/packages/ui/src/components/chat/markdown/markdown-worker.ts index 85d061bd..913e6511 100644 --- a/packages/ui/src/components/chat/markdown/markdown-worker.ts +++ b/packages/ui/src/components/chat/markdown/markdown-worker.ts @@ -1,4 +1,9 @@ import MarkdownShikiWorkerUrl from './markdown-shiki.worker.ts?worker&url'; +import { + HighlightResultCache, + stringLinesSize, + stringPairSize, +} from './highlightResultCache'; import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol'; // Main-thread client for the markdown Shiki worker. Moves syntax tokenization @@ -6,9 +11,36 @@ import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } // ready-to-splice Shiki HTML. On any failure (no worker support, worker crash, // tokenization error) the promise resolves to `null` and the caller keeps the // escaped plain-text code — highlighting never falls back onto the main thread. +// +// Results are memoized by exact source (+ lang / theme). Unchanged content must +// not re-enter the worker — that was the sustained ~40 msg/s re-highlight load +// in openchamber/openchamber#2769. In-flight requests with the same key coalesce +// so remount storms share one round-trip. type PendingResolver = (response: MarkdownWorkerResponse | null) => void; +type CachedHighlight = + | { type: 'highlight'; html: string } + | { type: 'highlightLines'; lines: string[] } + | { type: 'highlightTokens'; lines: MarkdownTokenRun[][] }; + +const CLIENT_CACHE_MAX_ENTRIES = 2000; +const CLIENT_CACHE_MAX_BYTES = 24 * 1024 * 1024; + +const cachedHighlightSize = (key: string, value: CachedHighlight): number => { + if (value.type === 'highlight') return stringPairSize(key, value.html); + if (value.type === 'highlightLines') return stringLinesSize(key, value.lines); + // Token runs: approximate as JSON length of the lines payload. + return stringPairSize(key, JSON.stringify(value.lines)); +}; + +const resultCache = new HighlightResultCache( + { maxEntries: CLIENT_CACHE_MAX_ENTRIES, maxBytes: CLIENT_CACHE_MAX_BYTES }, + cachedHighlightSize, +); + +const inflight = new Map>(); + let worker: Worker | undefined; let nextId = 0; const pending = new Map(); @@ -20,6 +52,8 @@ const failAll = (): void => { pending.forEach((resolve) => resolve(null)); pending.clear(); sentThemes.clear(); + // Drop in-flight waiters; cached results remain valid (pure fn of inputs). + inflight.clear(); worker?.terminate(); worker = undefined; }; @@ -55,13 +89,42 @@ const request = (payload: (id: number) => MarkdownWorkerRequest): Promise Promise, +): Promise => { + const existing = inflight.get(key); + if (existing) return existing; + const pendingRequest = run().finally(() => { + inflight.delete(key); + }); + inflight.set(key, pendingRequest); + return pendingRequest; +}; + +/** Test-only: clear client-side highlight memoization. */ +export const resetMarkdownWorkerClientCacheForTests = (): void => { + resultCache.clear(); + inflight.clear(); +}; + /** * Highlight a complete code block in the worker. Resolves to Shiki `
` HTML,
  * or `null` if highlighting is unavailable or failed (caller keeps plain code).
  */
 export const highlightCodeInWorker = async (code: string, lang: string): Promise => {
-  const response = await request((id) => ({ type: 'highlight', id, code, lang }));
-  return response?.type === 'highlight' ? response.html : null;
+  const key = `highlight:${lang}:${code}`;
+  const cached = resultCache.get(key);
+  if (cached?.type === 'highlight') return cached.html;
+
+  const result = await coalesce(key, async () => {
+    const response = await request((id) => ({ type: 'highlight', id, code, lang }));
+    if (response?.type !== 'highlight') return null;
+    const entry: CachedHighlight = { type: 'highlight', html: response.html };
+    resultCache.set(key, entry);
+    return entry;
+  });
+  return result?.type === 'highlight' ? result.html : null;
 };
 
 /**
@@ -70,8 +133,18 @@ export const highlightCodeInWorker = async (code: string, lang: string): Promise
  * round-trip instead of one per line. Resolves to `null` on failure.
  */
 export const highlightLinesInWorker = async (code: string, lang: string): Promise => {
-  const response = await request((id) => ({ type: 'highlightLines', id, code, lang }));
-  return response?.type === 'highlightLines' ? response.lines : null;
+  const key = `highlightLines:${lang}:${code}`;
+  const cached = resultCache.get(key);
+  if (cached?.type === 'highlightLines') return cached.lines;
+
+  const result = await coalesce(key, async () => {
+    const response = await request((id) => ({ type: 'highlightLines', id, code, lang }));
+    if (response?.type !== 'highlightLines') return null;
+    const entry: CachedHighlight = { type: 'highlightLines', lines: response.lines };
+    resultCache.set(key, entry);
+    return entry;
+  });
+  return result?.type === 'highlightLines' ? result.lines : null;
 };
 
 /**
@@ -86,18 +159,25 @@ export const highlightTokensInWorker = async (
   themeName: string,
   theme: unknown,
 ): Promise => {
-  const needsTheme = !sentThemes.has(themeName);
-  const response = await request((id) => ({
-    type: 'highlightTokens',
-    id,
-    code,
-    lang,
-    themeName,
-    ...(needsTheme ? { theme } : {}),
-  }));
-  if (response?.type === 'highlightTokens') {
+  const key = `highlightTokens:${themeName}:${lang}:${code}`;
+  const cached = resultCache.get(key);
+  if (cached?.type === 'highlightTokens') return cached.lines;
+
+  const result = await coalesce(key, async () => {
+    const needsTheme = !sentThemes.has(themeName);
+    const response = await request((id) => ({
+      type: 'highlightTokens',
+      id,
+      code,
+      lang,
+      themeName,
+      ...(needsTheme ? { theme } : {}),
+    }));
+    if (response?.type !== 'highlightTokens') return null;
     sentThemes.add(themeName);
-    return response.lines;
-  }
-  return null;
+    const entry: CachedHighlight = { type: 'highlightTokens', lines: response.lines };
+    resultCache.set(key, entry);
+    return entry;
+  });
+  return result?.type === 'highlightTokens' ? result.lines : null;
 };
diff --git a/packages/ui/src/components/chat/markdown/markdownCore.ts b/packages/ui/src/components/chat/markdown/markdownCore.ts
index ad906d99..ef998e57 100644
--- a/packages/ui/src/components/chat/markdown/markdownCore.ts
+++ b/packages/ui/src/components/chat/markdown/markdownCore.ts
@@ -4,6 +4,7 @@ import katex from 'katex';
 import DOMPurify from 'dompurify';
 import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks';
 import { isVSCodeRuntime } from '@/lib/desktop';
+import { HighlightResultCache, stringPairSize } from './highlightResultCache';
 import { highlightCodeInWorker } from './markdown-worker';
 import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
 
@@ -317,11 +318,23 @@ const sanitize = (html: string): string => {
 
 
 // ---------------------------------------------------------------------------
-// Per-block HTML cache (LRU, mirrors OpenCode's checksum cache)
+// Per-block HTML cache (content-addressed LRU)
 // ---------------------------------------------------------------------------
+//
+// Keyed by content hash + mode + highlight flag — NOT by renderer instance id.
+// `SimpleMarkdownRenderer` historically used a shared `simple:${variant}` key,
+// so every same-variant instance fought over one cache slot and re-highlighted
+// unchanged content on every pass (openchamber/openchamber#2769). Content
+// addressing makes identical blocks share one entry and stops that thrash.
+// Bounds are high enough for long sessions; byte cap keeps memory bounded.
 
-const CACHE_MAX = 240;
-const htmlCache = new Map();
+const HTML_CACHE_MAX_ENTRIES = 2000;
+const HTML_CACHE_MAX_BYTES = 24 * 1024 * 1024;
+
+const htmlCache = new HighlightResultCache(
+  { maxEntries: HTML_CACHE_MAX_ENTRIES, maxBytes: HTML_CACHE_MAX_BYTES },
+  stringPairSize,
+);
 
 // FNV-1a 32-bit hash of the block content.
 const hash = (value: string): string => {
@@ -333,12 +346,16 @@ const hash = (value: string): string => {
   return (h >>> 0).toString(36);
 };
 
-const touch = (key: string, entry: { hash: string; html: string }): void => {
-  htmlCache.delete(key);
-  htmlCache.set(key, entry);
-  if (htmlCache.size <= CACHE_MAX) return;
-  const oldest = htmlCache.keys().next().value;
-  if (oldest) htmlCache.delete(oldest);
+/** Content-addressed cache key for a markdown block (exported for tests). */
+export const markdownBlockCacheKey = (
+  contentHash: string,
+  mode: MarkdownBlock['mode'],
+  highlight: boolean,
+): string => `${contentHash}:${mode}:${highlight ? 1 : 0}`;
+
+/** Test-only: clear the render HTML cache between cases. */
+export const resetMarkdownHtmlCacheForTests = (): void => {
+  htmlCache.clear();
 };
 
 const parseBlock = async (block: MarkdownBlock): Promise => {
@@ -377,27 +394,31 @@ export type RenderedBlock = {
  * splits into blocks, caches per-block, heals incomplete syntax. Returning
  * blocks (instead of one joined string) lets the renderer re-morph only the
  * block that changed, keeping per-step streaming cost ~O(last block).
+ *
+ * `cacheKey` is retained for call-site compatibility / debugging; the HTML
+ * cache itself is content-addressed so distinct renderers with identical
+ * blocks share results and cannot evict each other by identity collision.
  */
 export const renderMarkdownBlocks = async (
   text: string,
   streaming: boolean,
   cacheKey: string,
 ): Promise => {
+  // Retained for call-site compatibility / debugging; lookup is content-addressed.
+  void cacheKey;
   if (!text) return [];
 
   const blocks = streamBlocks(text, streaming);
   return Promise.all(
-    blocks.map(async (block, index) => {
+    blocks.map(async (block) => {
       const contentHash = hash(block.raw);
-      const id = `${contentHash}:${block.mode}:${block.highlight ? 1 : 0}`;
-      const key = `${cacheKey}:${index}:${block.mode}`;
-      const cached = htmlCache.get(key);
-      if (cached && cached.hash === contentHash) {
-        touch(key, cached);
-        return { id, html: cached.html };
+      const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight);
+      const cached = htmlCache.get(id);
+      if (cached !== undefined) {
+        return { id, html: cached };
       }
       const html = await parseBlock(block);
-      touch(key, { hash: contentHash, html });
+      htmlCache.set(id, html);
       return { id, html };
     }),
   );
diff --git a/packages/ui/src/components/chat/markdown/markdownHighlightingRepro.test.ts b/packages/ui/src/components/chat/markdown/markdownHighlightingRepro.test.ts
new file mode 100644
index 00000000..82cbb614
--- /dev/null
+++ b/packages/ui/src/components/chat/markdown/markdownHighlightingRepro.test.ts
@@ -0,0 +1,138 @@
+/**
+ * Regression tests for https://github.com/openchamber/openchamber/issues/2769
+ *
+ * Sustained Shiki worker CPU came from re-tokenizing unchanged content:
+ *  1. `htmlCache` keyed by renderer identity (`simple:${variant}`) so
+ *     same-variant instances evicted each other every pass.
+ *  2. LRU capped at 240 entries, so long sessions missed 100% on every pass.
+ *  3. Worker/client had no result memoization.
+ *
+ * These tests assert the fixed contracts: content-addressed caching, room for
+ * long sessions, and bounded LRU behavior.
+ */
+import { beforeEach, describe, expect, mock, test } from 'bun:test';
+
+import { HighlightResultCache, stringPairSize } from './highlightResultCache';
+
+let highlightCalls = 0;
+
+const highlightCodeInWorkerMock = mock(async (code: string, lang: string) => {
+  highlightCalls += 1;
+  return `
${code}
`; +}); + +mock.module('./markdown-worker', () => ({ + highlightCodeInWorker: highlightCodeInWorkerMock, + highlightLinesInWorker: mock(async () => null), + highlightTokensInWorker: mock(async () => null), + resetMarkdownWorkerClientCacheForTests: mock(() => undefined), +})); + +const { + renderMarkdownBlocks, + resetMarkdownHtmlCacheForTests, + markdownBlockCacheKey, +} = await import('./markdownCore'); + +const { resetMarkdownWorkerClientCacheForTests } = await import('./markdown-worker'); + +beforeEach(() => { + resetMarkdownHtmlCacheForTests(); + resetMarkdownWorkerClientCacheForTests(); + highlightCalls = 0; +}); + +describe('HighlightResultCache', () => { + test('returns cached values for identical keys and refreshes LRU order', () => { + const cache = new HighlightResultCache( + { maxEntries: 2, maxBytes: 10_000 }, + stringPairSize, + ); + cache.set('a', 'one'); + cache.set('b', 'two'); + expect(cache.get('a')).toBe('one'); + // Touch `a` so `b` is oldest; inserting `c` should evict `b`. + cache.set('c', 'three'); + expect(cache.get('b')).toEqual(undefined); + expect(cache.get('a')).toBe('one'); + expect(cache.get('c')).toBe('three'); + }); + + test('evicts by byte budget while still caching a single oversized entry', () => { + const cache = new HighlightResultCache( + { maxEntries: 10, maxBytes: 64 }, + stringPairSize, + ); + cache.set('small', 'x'); + cache.set('huge', 'y'.repeat(200)); + expect(cache.get('huge')).toBe('y'.repeat(200)); + // Oversized insert cleared prior entries to make room. + expect(cache.size).toBe(1); + }); +}); + +describe('markdownCore content-addressed htmlCache (#2769)', () => { + test('two same-variant SimpleMarkdown-style keys do not re-highlight unchanged content', async () => { + const toolOutputA = '```ts\nconst a = 1;\n```'; + const toolOutputB = '```ts\nconst b = 2;\n```'; + + // First pass: cold miss for each distinct block. + await renderMarkdownBlocks(toolOutputA, false, 'simple:tool'); + await renderMarkdownBlocks(toolOutputB, false, 'simple:tool'); + const coldCalls = highlightCalls; + expect(coldCalls).toBeGreaterThan(0); + + // 100 more passes with the legacy shared `simple:tool` identity keys — + // must not produce additional worker calls. + for (let pass = 0; pass < 100; pass += 1) { + await renderMarkdownBlocks(toolOutputA, false, 'simple:tool'); + await renderMarkdownBlocks(toolOutputB, false, 'simple:tool'); + } + + expect(highlightCalls).toBe(coldCalls); + }); + + test('long sessions (working set > former 240 cap) stay warm across re-render passes', async () => { + const parts = Array.from({ length: 600 }, (_, i) => ({ + key: `markdown-part-part_${i}`, + content: `\`\`\`ts\nconst value_${i} = ${i};\n\`\`\``, + })); + + for (const part of parts) { + await renderMarkdownBlocks(part.content, false, part.key); + } + const afterCold = highlightCalls; + expect(afterCold).toBe(parts.length); + + for (let pass = 0; pass < 5; pass += 1) { + for (const part of parts) { + await renderMarkdownBlocks(part.content, false, part.key); + } + } + + // Unchanged content must not re-enter the worker. + expect(highlightCalls).toBe(afterCold); + }); + + test('content changes invalidate only the changed block', async () => { + const stable = '```ts\nconst stable = true;\n```'; + const changing = '```ts\nconst n = 1;\n```'; + + await renderMarkdownBlocks(stable, false, 'a'); + await renderMarkdownBlocks(changing, false, 'b'); + const afterFirst = highlightCalls; + + await renderMarkdownBlocks(stable, false, 'a'); + await renderMarkdownBlocks('```ts\nconst n = 2;\n```', false, 'b'); + expect(highlightCalls).toBe(afterFirst + 1); + + await renderMarkdownBlocks(stable, false, 'a'); + expect(highlightCalls).toBe(afterFirst + 1); + }); + + test('block cache keys are content-addressed (mode + highlight + hash)', () => { + expect(markdownBlockCacheKey('abc', 'full', true)).toBe('abc:full:1'); + expect(markdownBlockCacheKey('abc', 'live', false)).toBe('abc:live:0'); + expect(markdownBlockCacheKey('abc', 'full', true)).not.toBe(markdownBlockCacheKey('abc', 'full', false)); + }); +});