perf(code): replace react-syntax-highlighter and prismjs with the Shiki worker

Route all non-markdown code highlighting through the off-main-thread Shiki
worker, removing react-syntax-highlighter and prismjs entirely.

- Extend the worker with highlightLines: tokenize a whole block once and return
  per-line inner HTML, so per-line layouts (diffs, gutters, virtualization) make
  one worker call instead of one highlighter per line.
- Add shared WorkerHighlightedCode (whole-block) and useWorkerHighlightedLines
  (per-line) primitives. Colors resolve via the --md-syntax-* CSS variables, so
  theme changes never re-highlight.
- Migrate all 12 react-syntax-highlighter call sites: PermissionCard,
  ToolPart, ContextSidebarTab, ToolOutputDialog (whole block) and
  DiffPreview/WritePreview (per line).
- Migrate VirtualizedCodeBlock off prismjs to the worker, keeping virtua
  virtualization; whole-block tokenization also restores cross-line syntax
  context that per-line highlighting lost.
- Drop react-syntax-highlighter (+types) from ui and web, prismjs (+types) from
  ui, and the orphaned create-element type shim.
This commit is contained in:
Bohdan Triapitsyn
2026-06-16 01:07:43 +03:00
parent 464c4ac0ca
commit e41e5bac91
15 changed files with 398 additions and 406 deletions
@@ -1,9 +1,31 @@
/// <reference lib="webworker" />
import { bundledLanguages, createHighlighter, type BundledLanguage } from 'shiki';
import { bundledLanguages, createHighlighter, type BundledLanguage, type ThemedToken } from 'shiki';
import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdownShikiThemeDefinition';
import type { MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol';
// Shiki FontStyle bitmask (from @shikijs/types). Inlined to avoid an extra import.
const FONT_STYLE_ITALIC = 1;
const FONT_STYLE_BOLD = 2;
const FONT_STYLE_UNDERLINE = 4;
const escapeHtml = (value: string): string =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
const tokenSpan = (token: ThemedToken): string => {
const styles: string[] = [];
if (token.color) styles.push(`color:${token.color}`);
const fontStyle = token.fontStyle ?? 0;
if (fontStyle & FONT_STYLE_ITALIC) styles.push('font-style:italic');
if (fontStyle & FONT_STYLE_BOLD) styles.push('font-weight:bold');
if (fontStyle & FONT_STYLE_UNDERLINE) styles.push('text-decoration:underline');
const style = styles.length ? ` style="${styles.join(';')}"` : '';
return `<span${style}>${escapeHtml(token.content)}</span>`;
};
// Single shared highlighter for the worker. Languages load lazily on demand.
let highlighter: ReturnType<typeof createHighlighter> | undefined;
@@ -25,20 +47,31 @@ self.onmessage = (event: MessageEvent<MarkdownWorkerRequest>) => {
ensureHighlighter();
return;
}
queue = queue.then(() => highlight(request)).catch(() => {});
if (request.type === 'highlight') {
queue = queue.then(() => highlight(request)).catch(() => {});
return;
}
queue = queue.then(() => highlightLines(request)).catch(() => {});
};
type Instance = Awaited<ReturnType<typeof createHighlighter>>;
const resolveLanguage = async (instance: Instance, requested: string): Promise<string> => {
let lang = requested in bundledLanguages ? requested : 'text';
if (lang !== 'text' && !instance.getLoadedLanguages().includes(lang)) {
try {
await instance.loadLanguage(bundledLanguages[lang as BundledLanguage]);
} catch {
lang = 'text';
}
}
return lang;
};
async function highlight(request: Extract<MarkdownWorkerRequest, { type: 'highlight' }>): Promise<void> {
try {
const instance = await ensureHighlighter();
let lang = request.lang in bundledLanguages ? request.lang : 'text';
if (lang !== 'text' && !instance.getLoadedLanguages().includes(lang)) {
try {
await instance.loadLanguage(bundledLanguages[lang as BundledLanguage]);
} catch {
lang = 'text';
}
}
const lang = await resolveLanguage(instance, request.lang);
const html = instance.codeToHtml(request.code, {
lang,
theme: MARKDOWN_SHIKI_THEME,
@@ -50,6 +83,21 @@ async function highlight(request: Extract<MarkdownWorkerRequest, { type: 'highli
}
}
async function highlightLines(request: Extract<MarkdownWorkerRequest, { type: 'highlightLines' }>): Promise<void> {
try {
const instance = await ensureHighlighter();
const lang = await resolveLanguage(instance, request.lang);
const { tokens } = instance.codeToTokens(request.code, {
lang: lang as BundledLanguage,
theme: MARKDOWN_SHIKI_THEME,
});
const lines = tokens.map((line) => line.map(tokenSpan).join(''));
post({ type: 'highlightLines', id: request.id, lines });
} catch (error) {
post({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error) });
}
}
function post(response: MarkdownWorkerResponse): void {
self.postMessage(response);
}
@@ -6,8 +6,14 @@
export type MarkdownWorkerRequest =
| { type: 'init' }
| { type: 'highlight'; id: number; code: string; lang: string };
// Highlight a whole block to ready-to-splice Shiki `<pre>` HTML.
| { type: 'highlight'; id: number; code: string; lang: string }
// Highlight a whole block but return per-line inner HTML (one entry per line),
// so per-line layouts (diffs, gutters, virtualization) tokenize in ONE call
// instead of one worker round-trip per line.
| { type: 'highlightLines'; id: number; code: string; lang: string };
export type MarkdownWorkerResponse =
| { type: 'highlight'; id: number; html: string }
| { type: 'highlightLines'; id: number; lines: string[] }
| { type: 'error'; id: number; message: string };
@@ -7,9 +7,11 @@ import type { MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-w
// tokenization error) the promise resolves to `null` and the caller keeps the
// escaped plain-text code — highlighting never falls back onto the main thread.
type PendingResolver = (response: MarkdownWorkerResponse | null) => void;
let worker: Worker | undefined;
let nextId = 0;
const pending = new Map<number, (html: string | null) => void>();
const pending = new Map<number, PendingResolver>();
const failAll = (): void => {
pending.forEach((resolve) => resolve(null));
@@ -30,7 +32,7 @@ const getWorker = (): Worker | undefined => {
const resolve = pending.get(event.data.id);
if (!resolve) return;
pending.delete(event.data.id);
resolve(event.data.type === 'highlight' ? event.data.html : null);
resolve(event.data);
};
worker.onerror = failAll;
worker.onmessageerror = failAll;
@@ -38,16 +40,31 @@ const getWorker = (): Worker | undefined => {
return worker;
};
const request = (payload: (id: number) => MarkdownWorkerRequest): Promise<MarkdownWorkerResponse | null> => {
const instance = getWorker();
if (!instance) return Promise.resolve(null);
const id = ++nextId;
return new Promise<MarkdownWorkerResponse | null>((resolve) => {
pending.set(id, resolve);
instance.postMessage(payload(id));
});
};
/**
* 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 = (code: string, lang: string): Promise<string | null> => {
const instance = getWorker();
if (!instance) return Promise.resolve(null);
const id = ++nextId;
return new Promise<string | null>((resolve) => {
pending.set(id, resolve);
instance.postMessage({ type: 'highlight', id, code, lang } satisfies MarkdownWorkerRequest);
});
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;
};
/**
* Highlight a whole block and return per-line inner HTML (one entry per source
* line). For per-line layouts (diffs, gutters, virtualization) — one worker
* 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;
};