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:
co-authored by
Serhii Dziupin
parent
b1d121a47e
commit
6ab12fdb61
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user