Merge pull request #2800 from openchamber/feat/shiki-re-highlighting-performance-dd3a

fix(ui): stop sustained Shiki re-highlight of unchanged markdown (#2769)
This commit is contained in:
Serhii Dziupin
2026-08-17 17:15:07 +03:00
committed by GitHub
7 changed files with 578 additions and 74 deletions
@@ -835,7 +835,6 @@ const useMorphdomMarkdown = ({
containerRef,
text,
streaming,
cacheKey,
imageMode = 'inline',
syntaxVars,
ctx,
@@ -843,7 +842,6 @@ const useMorphdomMarkdown = ({
containerRef: React.RefObject<HTMLDivElement | null>;
text: string;
streaming: boolean;
cacheKey: string;
imageMode?: MarkdownImageMode;
syntaxVars: Record<string, string>;
ctx: DecorateContext;
@@ -908,7 +906,7 @@ const useMorphdomMarkdown = ({
const target = container.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
let active = true;
void renderMarkdownBlocks(text, streaming, cacheKey, imageMode).then((blocks) => {
void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
if (!active) return;
const existing = Array.from(target.children) as HTMLElement[];
@@ -959,7 +957,7 @@ const useMorphdomMarkdown = ({
return () => {
active = false;
};
}, [containerRef, text, streaming, cacheKey, imageMode, ctx, refreshMermaidViewers]);
}, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers]);
React.useEffect(() => {
const container = containerRef.current;
@@ -1040,13 +1038,13 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
// Identity for the fade-in wrapper: a new part/message restarts the animation.
const fadeKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
useMorphdomMarkdown({
containerRef,
text: content,
streaming: live,
cacheKey,
imageMode: variant === 'assistant' ? 'label' : 'inline',
syntaxVars,
ctx,
@@ -1060,7 +1058,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
if (isAnimated) {
return (
<FadeInOnReveal key={cacheKey} skipAnimation={skipFadeIn}>
<FadeInOnReveal key={fadeKey} skipAnimation={skipFadeIn}>
{markdownContent}
</FadeInOnReveal>
);
@@ -1137,7 +1135,6 @@ const SimpleMarkdownRendererImpl: React.FC<{
containerRef,
text: renderedContent,
streaming: false,
cacheKey: `simple:${variant}`,
syntaxVars,
ctx,
});
@@ -0,0 +1,125 @@
// Bounded LRU for rendered markdown / Shiki highlight results.
//
// Used by `markdownCore` (per-block HTML) and by the main-thread markdown
// worker client (highlight results) so unchanged content is never re-rendered
// or re-tokenized. Keys are short content fingerprints (not the full source) so
// cache maps do not duplicate large strings. Entry byte sizes are recorded once
// at insert time — get/evict never re-walk the payload.
export type HighlightResultCacheOptions = {
maxEntries: number;
maxBytes: number;
};
type CacheEntry<T> = {
value: T;
bytes: number;
};
/** UTF-16 storage estimate for a JS string (chars × 2). Avoids TextEncoder allocs. */
export const utf16Bytes = (value: string): number => value.length * 2;
/** Final avalanche so near-identical sources do not land in adjacent buckets. */
const mix32 = (hash: number): number => {
let h = hash;
h ^= h >>> 16;
h = Math.imul(h, 0x85ebca6b);
h ^= h >>> 13;
return h >>> 0;
};
/**
* Short stable fingerprint for cache keys: length + two independent 32-bit
* multiplicative hashes (~64 bits of key space).
*
* These caches are content-addressed and global, so a collision does not merely
* mis-color a block — the cache returns a *different* block's rendered HTML and
* the user is shown source they never wrote. One 32-bit hash is not enough for
* that failure mode: a few thousand same-length entries reach a birthday
* collision probability worth caring about, and the result would be
* undiagnosable in the field. Two multiplies per character are free next to
* Shiki tokenization.
*/
export const contentFingerprint = (value: string): string => {
let h1 = 0x811c9dc5;
let h2 = 0xc2b2ae35;
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i);
h1 = Math.imul(h1 ^ code, 0x01000193);
h2 = Math.imul(h2 ^ code, 0x27220a95);
}
return `${value.length.toString(36)}_${mix32(h1).toString(36)}_${mix32(h2).toString(36)}`;
};
/** Approximate byte cost of token-run lines without JSON.stringify. */
export const estimateTokenRunsBytes = (
lines: ReadonlyArray<ReadonlyArray<readonly [number, string, number]>>,
): number => {
let total = 0;
for (const line of lines) {
total += 4;
for (const run of line) {
total += 8 + utf16Bytes(run[1]);
}
}
return total;
};
export class HighlightResultCache<T> {
private readonly maxEntries: number;
private readonly maxBytes: number;
private readonly map = new Map<string, CacheEntry<T>>();
private totalBytes = 0;
constructor(options: HighlightResultCacheOptions) {
this.maxEntries = Math.max(1, options.maxEntries);
this.maxBytes = Math.max(1, options.maxBytes);
}
get size(): number {
return this.map.size;
}
get bytes(): number {
return this.totalBytes;
}
get(key: string): T | undefined {
const entry = this.map.get(key);
if (entry === undefined) return undefined;
// Refresh LRU order without recomputing size.
this.map.delete(key);
this.map.set(key, entry);
return entry.value;
}
set(key: string, value: T, bytes: number): void {
const existing = this.map.get(key);
if (existing !== undefined) {
this.totalBytes -= existing.bytes;
this.map.delete(key);
}
const entryBytes = Math.max(0, bytes);
while (
this.map.size > 0
&& (this.map.size >= this.maxEntries || this.totalBytes + entryBytes > this.maxBytes)
) {
const oldest = this.map.keys().next().value;
if (oldest === undefined) break;
const oldestEntry = this.map.get(oldest);
if (oldestEntry !== undefined) this.totalBytes -= oldestEntry.bytes;
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, bytes: entryBytes });
this.totalBytes += entryBytes;
}
clear(): void {
this.map.clear();
this.totalBytes = 0;
}
}
@@ -1,4 +1,10 @@
import MarkdownShikiWorkerUrl from './markdown-shiki.worker.ts?worker&url';
import {
contentFingerprint,
estimateTokenRunsBytes,
HighlightResultCache,
utf16Bytes,
} from './highlightResultCache';
import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol';
// Main-thread client for the markdown Shiki worker. Moves syntax tokenization
@@ -6,9 +12,39 @@ 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 content fingerprint (+ 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. Cache keys are
// fingerprints (not full source) so large files are not duplicated in the Map.
//
// This module is the only sender to the worker, so memoizing here is sufficient
// and the worker itself stays stateless apart from the Shiki instance. A second
// cache inside the worker would only duplicate these payloads in another heap.
//
// `highlight` / `highlightLines` results are theme-independent: the worker
// tokenizes with the CSS-variable `MARKDOWN_SHIKI_THEME`, so a theme switch
// repaints via CSS and must not invalidate these entries. Only
// `highlightTokens` resolves concrete colors, so only its key carries a theme.
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 resultCache = new HighlightResultCache<CachedHighlight>({
maxEntries: CLIENT_CACHE_MAX_ENTRIES,
maxBytes: CLIENT_CACHE_MAX_BYTES,
});
const inflight = new Map<string, Promise<CachedHighlight | null>>();
let worker: Worker | undefined;
let nextId = 0;
const pending = new Map<number, PendingResolver>();
@@ -16,10 +52,23 @@ const pending = new Map<number, PendingResolver>();
// repeat tokenization sends only the name (not the whole theme object) again.
const sentThemes = new Set<string>();
const entryBytes = (key: string, value: CachedHighlight): number => {
const keyBytes = utf16Bytes(key);
if (value.type === 'highlight') return keyBytes + utf16Bytes(value.html);
if (value.type === 'highlightLines') {
let total = keyBytes;
for (const line of value.lines) total += utf16Bytes(line);
return total;
}
return keyBytes + estimateTokenRunsBytes(value.lines);
};
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 +104,47 @@ 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;
};
const cacheKeyFor = (kind: string, lang: string, code: string, themeName?: string): string => {
const fp = contentFingerprint(code);
return themeName === undefined ? `${kind}:${lang}:${fp}` : `${kind}:${themeName}:${lang}:${fp}`;
};
/** 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 = cacheKeyFor('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, entryBytes(key, entry));
return entry;
});
return result?.type === 'highlight' ? result.html : null;
};
/**
@@ -70,8 +153,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 = cacheKeyFor('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, entryBytes(key, entry));
return entry;
});
return result?.type === 'highlightLines' ? result.lines : null;
};
/**
@@ -86,18 +179,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 = cacheKeyFor('highlightTokens', lang, code, themeName);
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, entryBytes(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 { contentFingerprint, HighlightResultCache, utf16Bytes } from './highlightResultCache';
import { highlightCodeInWorker } from './markdown-worker';
import { escapeRawMarkdownHtml, isLocalFileUrl, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
@@ -415,32 +416,37 @@ const highlightCodeBlocks = async (html: string): Promise<string> => {
const lineLimit = isVSCodeRuntime() ? VSCODE_CODE_HIGHLIGHT_LINE_LIMIT : CODE_HIGHLIGHT_LINE_LIMIT;
let result = html;
for (const match of matches) {
const [full, rawLang, escapedCode] = match;
const requested = (rawLang || 'text').toLowerCase();
// Leave mermaid fences untouched so the decorate pass can render them as
// diagrams (highlighting would strip the `language-mermaid` class).
if (requested === 'mermaid') continue;
// Highlight all eligible fences concurrently — sequential await was O(n)
// worker round-trips for messages with multiple code blocks.
const replacements = await Promise.all(
matches.map(async (match) => {
const [full, rawLang, escapedCode] = match;
const requested = (rawLang || 'text').toLowerCase();
// Leave mermaid fences untouched so the decorate pass can render them as
// diagrams (highlighting would strip the `language-mermaid` class).
if (requested === 'mermaid') return null;
const code = unescapeHtml(escapedCode ?? '');
const code = unescapeHtml(escapedCode ?? '');
// Oversized block: skip highlight, keep plain code but stamp the language.
if (exceedsLineLimit(code, lineLimit)) {
result = result.replace(full, () => full.replace('<pre', `<pre data-md-lang="${requested}"`));
continue;
}
// Oversized block: skip highlight, keep plain code but stamp the language.
if (exceedsLineLimit(code, lineLimit)) {
return { full, next: full.replace('<pre', `<pre data-md-lang="${requested}"`) };
}
// Tokenize off the main thread. On failure the worker resolves to null and
// we keep the original escaped <pre><code> (no main-thread highlight).
const highlighted = await highlightCodeInWorker(code, requested);
if (highlighted) {
// Tokenize off the main thread. On failure the worker resolves to null and
// we keep the original escaped <pre><code> (no main-thread highlight).
const highlighted = await highlightCodeInWorker(code, requested);
if (!highlighted) return null;
// Stamp the language so the decorate pass can show a header label.
const stamped = highlighted.replace(/^<pre/, `<pre data-md-lang="${requested}"`);
result = result.replace(full, () => stamped);
}
}
return { full, next: highlighted.replace(/^<pre/, `<pre data-md-lang="${requested}"`) };
}),
);
let result = html;
for (const replacement of replacements) {
if (!replacement) continue;
result = result.replace(replacement.full, () => replacement.next);
}
return result;
};
@@ -483,29 +489,60 @@ 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 + image mode — 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.
//
// `full` (settled) and `live` (trailing, still streaming) blocks get separate
// caches. A live block's content changes on every stream step, so under one
// shared content-addressed cache each step would insert a new entry and a long
// streaming message would evict the settled blocks this fix exists to keep
// warm. The live cache is small on purpose: it only has to absorb repeat
// renders of the *same* step.
const CACHE_MAX = 240;
const htmlCache = new Map<string, { hash: string; html: string }>();
const FULL_CACHE_MAX_ENTRIES = 2000;
const FULL_CACHE_MAX_BYTES = 24 * 1024 * 1024;
const LIVE_CACHE_MAX_ENTRIES = 32;
const LIVE_CACHE_MAX_BYTES = 2 * 1024 * 1024;
// FNV-1a 32-bit hash of the block content.
const hash = (value: string): string => {
let h = 0x811c9dc5;
for (let i = 0; i < value.length; i += 1) {
h ^= value.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return (h >>> 0).toString(36);
const fullBlockCache = new HighlightResultCache<string>({
maxEntries: FULL_CACHE_MAX_ENTRIES,
maxBytes: FULL_CACHE_MAX_BYTES,
});
const liveBlockCache = new HighlightResultCache<string>({
maxEntries: LIVE_CACHE_MAX_ENTRIES,
maxBytes: LIVE_CACHE_MAX_BYTES,
});
const cacheForMode = (mode: MarkdownBlock['mode']): HighlightResultCache<string> =>
(mode === 'live' ? liveBlockCache : fullBlockCache);
/** Content-addressed cache key for a markdown block. */
const markdownBlockCacheKey = (
contentHash: string,
mode: MarkdownBlock['mode'],
highlight: boolean,
imageMode: MarkdownImageMode,
): string => `${contentHash}:${mode}:${highlight ? 1 : 0}:${imageMode}`;
/** Test-only: clear the render HTML caches between cases. */
export const resetMarkdownHtmlCacheForTests = (): void => {
fullBlockCache.clear();
liveBlockCache.clear();
};
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);
};
/** Test-only: entry counts per block cache, for churn/eviction assertions. */
export const __markdownBlockCacheSizesForTests = (): { full: number; live: number } => ({
full: fullBlockCache.size,
live: liveBlockCache.size,
});
const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): Promise<string> => {
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
@@ -545,28 +582,29 @@ 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).
*
* Lookup is content-addressed: distinct renderers holding identical blocks
* share one entry and cannot evict each other by identity collision.
*/
export const renderMarkdownBlocks = async (
text: string,
streaming: boolean,
cacheKey: string,
imageMode: MarkdownImageMode = 'inline',
): Promise<RenderedBlock[]> => {
if (!text) return [];
const blocks = streamBlocks(text, streaming);
return Promise.all(
blocks.map(async (block, index) => {
const contentHash = hash(block.raw);
const id = `${contentHash}:${block.mode}:${block.highlight ? 1 : 0}:${imageMode}`;
const key = `${cacheKey}:${index}:${block.mode}:${imageMode}`;
const cached = htmlCache.get(key);
if (cached && cached.hash === contentHash) {
touch(key, cached);
return { id, html: cached.html };
blocks.map(async (block) => {
const contentHash = contentFingerprint(block.raw);
const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight, imageMode);
const cache = cacheForMode(block.mode);
const cached = cache.get(id);
if (cached !== undefined) {
return { id, html: cached };
}
const html = await parseBlock(block, imageMode);
touch(key, { hash: contentHash, html });
cache.set(id, html, utf16Bytes(id) + utf16Bytes(html));
return { id, html };
}),
);
@@ -0,0 +1,242 @@
/**
* 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, bounded LRU behavior, and fingerprint-key helpers.
*/
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import {
contentFingerprint,
estimateTokenRunsBytes,
HighlightResultCache,
utf16Bytes,
} from './highlightResultCache';
let highlightCalls = 0;
let highlightInflight = 0;
let highlightMaxInflight = 0;
const highlightCodeInWorkerMock = mock(async (code: string, lang: string) => {
highlightCalls += 1;
highlightInflight += 1;
highlightMaxInflight = Math.max(highlightMaxInflight, highlightInflight);
await Promise.resolve();
highlightInflight -= 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,
__markdownBlockCacheSizesForTests,
} = await import('./markdownCore');
const { resetMarkdownWorkerClientCacheForTests } = await import('./markdown-worker');
beforeEach(() => {
resetMarkdownHtmlCacheForTests();
resetMarkdownWorkerClientCacheForTests();
highlightCalls = 0;
highlightInflight = 0;
highlightMaxInflight = 0;
});
describe('HighlightResultCache', () => {
test('returns cached values for identical keys and refreshes LRU order', () => {
const cache = new HighlightResultCache<string>({ maxEntries: 2, maxBytes: 10_000 });
cache.set('a', 'one', utf16Bytes('a') + utf16Bytes('one'));
cache.set('b', 'two', utf16Bytes('b') + utf16Bytes('two'));
expect(cache.get('a')).toBe('one');
// Touch `a` so `b` is oldest; inserting `c` should evict `b`.
cache.set('c', 'three', utf16Bytes('c') + utf16Bytes('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 });
cache.set('small', 'x', utf16Bytes('small') + utf16Bytes('x'));
cache.set('huge', 'y'.repeat(200), utf16Bytes('huge') + utf16Bytes('y'.repeat(200)));
expect(cache.get('huge')).toBe('y'.repeat(200));
// Oversized insert cleared prior entries to make room.
expect(cache.size).toBe(1);
});
test('contentFingerprint is stable and length-qualified', () => {
expect(contentFingerprint('const x = 1')).toBe(contentFingerprint('const x = 1'));
expect(contentFingerprint('const x = 1')).not.toBe(contentFingerprint('const x = 2'));
expect(contentFingerprint('ab')).not.toBe(contentFingerprint('abc'));
});
test('contentFingerprint stays collision-free across a realistic session', () => {
// A collision here does not mis-color a block — it returns a *different*
// block's HTML, showing the user source they never wrote. Keep enough key
// space that a session-sized working set never collides.
const seen = new Map<string, string>();
for (let i = 0; i < 20_000; i += 1) {
// Same-length, near-identical sources are the realistic worst case:
// repeated tool output differing by a few characters.
const source = `const value_${String(i).padStart(6, '0')} = ${String(i).padStart(6, '0')};`;
const fingerprint = contentFingerprint(source);
expect(seen.get(fingerprint) ?? source).toBe(source);
seen.set(fingerprint, source);
}
expect(seen.size).toBe(20_000);
});
test('estimateTokenRunsBytes avoids JSON and stays positive', () => {
const lines: Array<Array<[number, string, number]>> = [
[[3, '#fff', 0], [1, '', 1]],
[[8, 'var(--md-syntax-keyword)', 0]],
];
expect(estimateTokenRunsBytes(lines)).toBeGreaterThan(0);
});
});
describe('markdownCore content-addressed htmlCache (#2769)', () => {
test('repeat renders of unchanged content never re-enter the worker', 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);
await renderMarkdownBlocks(toolOutputB, false);
const coldCalls = highlightCalls;
expect(coldCalls).toBeGreaterThan(0);
// 100 more passes. Renderers used to pass a shared `simple:${variant}`
// identity key here and evict each other every pass; lookup is now
// content-addressed, so no additional worker calls may happen.
for (let pass = 0; pass < 100; pass += 1) {
await renderMarkdownBlocks(toolOutputA, false);
await renderMarkdownBlocks(toolOutputB, false);
}
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) => ({
content: `\`\`\`ts\nconst value_${i} = ${i};\n\`\`\``,
}));
for (const part of parts) {
await renderMarkdownBlocks(part.content, false);
}
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);
}
}
// 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);
await renderMarkdownBlocks(changing, false);
const afterFirst = highlightCalls;
await renderMarkdownBlocks(stable, false);
await renderMarkdownBlocks('```ts\nconst n = 2;\n```', false);
expect(highlightCalls).toBe(afterFirst + 1);
await renderMarkdownBlocks(stable, false);
expect(highlightCalls).toBe(afterFirst + 1);
});
test('image mode is part of the cache identity, not shared across modes', async () => {
const source = '![diagram](https://example.com/a.png)';
const [inline] = await renderMarkdownBlocks(source, false, 'inline');
expect(__markdownBlockCacheSizesForTests().full).toBe(1);
// Same source, different rendering: content addressing must not let the
// first-rendered mode answer for both.
const [label] = await renderMarkdownBlocks(source, false, 'label');
expect(inline?.id).not.toBe(label?.id);
expect(__markdownBlockCacheSizesForTests().full).toBe(2);
// Re-rendering a mode already seen stays a cache hit.
const [inlineAgain] = await renderMarkdownBlocks(source, false, 'inline');
expect(inlineAgain?.id).toBe(inline?.id);
expect(__markdownBlockCacheSizesForTests().full).toBe(2);
});
test('streaming a message does not evict settled blocks (live cache is separate)', async () => {
const settled = Array.from(
{ length: 40 },
(_, i) => `\`\`\`ts\nconst settled_${i} = ${i};\n\`\`\``,
);
for (const block of settled) {
await renderMarkdownBlocks(block, false);
}
const settledEntries = __markdownBlockCacheSizesForTests().full;
expect(settledEntries).toBe(settled.length);
const afterSettled = highlightCalls;
// Stream a message: every step is new content for the trailing live block,
// so a single shared content-addressed cache would insert one entry per
// step and evict the settled working set this fix exists to keep warm.
let streamed = '';
for (let step = 0; step < 150; step += 1) {
streamed += `word_${step} `;
await renderMarkdownBlocks(streamed, true);
}
const sizes = __markdownBlockCacheSizesForTests();
expect(sizes.live).toBeLessThanOrEqual(32);
expect(sizes.full).toBe(settledEntries);
for (const block of settled) {
await renderMarkdownBlocks(block, false);
}
expect(highlightCalls).toBe(afterSettled);
});
test('a repeated streaming step is served from the live cache', async () => {
const step = 'partial answer text';
const [first] = await renderMarkdownBlocks(step, true);
const [second] = await renderMarkdownBlocks(step, true);
expect(second?.id).toBe(first?.id);
expect(__markdownBlockCacheSizesForTests()).toEqual({ full: 0, live: 1 });
});
test('multiple code fences in one document highlight concurrently', async () => {
const multi = [
'```ts\nconst a = 1;\n```',
'',
'```ts\nconst b = 2;\n```',
'',
'```ts\nconst c = 3;\n```',
].join('\n');
await renderMarkdownBlocks(multi, false);
expect(highlightCalls).toBe(3);
// Sequential awaits would keep max inflight at 1.
expect(highlightMaxInflight).toBeGreaterThan(1);
});
});
+1
View File
@@ -19,6 +19,7 @@ declare module "bun:test" {
toBeGreaterThan(expected: number): void;
toBeGreaterThanOrEqual(expected: number): void;
toBeLessThan(expected: number): void;
toBeLessThanOrEqual(expected: number): void;
toHaveLength(expected: number): void;
toBeInstanceOf(expected: unknown): void;
not: {