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 <makeittech@users.noreply.github.com>
This commit is contained in:
Serhii Dziupin
2026-08-10 13:50:20 +00:00
co-authored by Serhii Dziupin
parent b1d121a47e
commit 6ab12fdb61
6 changed files with 423 additions and 36 deletions
@@ -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,
});
@@ -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<T> {
private readonly maxEntries: number;
private readonly maxBytes: number;
private readonly sizeOf: (key: string, value: T) => number;
private readonly map = new Map<string, T>();
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;
};
@@ -1,8 +1,13 @@
/// <reference lib="webworker" />
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<typeof createHighlighter> | 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<string>(
{ maxEntries: WORKER_CACHE_MAX_ENTRIES, maxBytes: WORKER_CACHE_MAX_BYTES },
stringPairSize,
);
const linesCache = new HighlightResultCache<string[]>(
{ maxEntries: WORKER_CACHE_MAX_ENTRIES, maxBytes: WORKER_CACHE_MAX_BYTES },
stringLinesSize,
);
const tokensCache = new HighlightResultCache<MarkdownTokenRun[][]>(
{ maxEntries: WORKER_CACHE_MAX_ENTRIES, maxBytes: WORKER_CACHE_MAX_BYTES },
(key, value) => stringPairSize(key, JSON.stringify(value)),
);
const ensureHighlighter = (): ReturnType<typeof createHighlighter> => {
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<s
async function highlight(request: Extract<MarkdownWorkerRequest, { type: 'highlight' }>): Promise<void> {
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<MarkdownWorkerRequest, { type: 'highli
theme: MARKDOWN_SHIKI_THEME,
tabindex: false,
});
htmlCache.set(cacheKey, html);
post({ type: 'highlight', id: request.id, html });
} catch (error) {
post({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error) });
@@ -89,6 +120,12 @@ async function highlight(request: Extract<MarkdownWorkerRequest, { type: 'highli
async function highlightTokens(request: Extract<MarkdownWorkerRequest, { type: 'highlightTokens' }>): Promise<void> {
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<MarkdownWorkerRequest, { type: '
const lines = tokens.map((line) =>
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<MarkdownWorkerRequest, { type: '
async function highlightLines(request: Extract<MarkdownWorkerRequest, { type: 'highlightLines' }>): Promise<void> {
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<MarkdownWorkerRequest, { type: 'h
theme: MARKDOWN_SHIKI_THEME,
});
const lines = tokens.map((line) => 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) });
@@ -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<CachedHighlight>(
{ maxEntries: CLIENT_CACHE_MAX_ENTRIES, maxBytes: CLIENT_CACHE_MAX_BYTES },
cachedHighlightSize,
);
const inflight = new Map<string, Promise<CachedHighlight | null>>();
let worker: Worker | undefined;
let nextId = 0;
const pending = new Map<number, PendingResolver>();
@@ -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<Markdo
});
};
const coalesce = (
key: string,
run: () => Promise<CachedHighlight | null>,
): Promise<CachedHighlight | null> => {
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 `<pre>` HTML,
* or `null` if highlighting is unavailable or failed (caller keeps plain code).
*/
export const highlightCodeInWorker = async (code: string, lang: string): Promise<string | null> => {
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<string[] | null> => {
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<MarkdownTokenRun[][] | null> => {
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;
};
@@ -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<string, { hash: string; html: string }>();
const HTML_CACHE_MAX_ENTRIES = 2000;
const HTML_CACHE_MAX_BYTES = 24 * 1024 * 1024;
const htmlCache = new HighlightResultCache<string>(
{ 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<string> => {
@@ -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<RenderedBlock[]> => {
// 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 };
}),
);
@@ -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 `<pre data-lang="${lang}"><code>${code}</code></pre>`;
});
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<string>(
{ 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<string>(
{ 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));
});
});