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
@@ -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;
};