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,7 +1,7 @@
import React from 'react';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { File as PierreFile, PatchDiff } from '@pierre/diffs/react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils';
@@ -550,9 +550,8 @@ DialogUnifiedDiff.displayName = 'DialogUnifiedDiff';
const DialogReadContent: React.FC<{
popup: ToolPopupContent;
syntaxTheme: Record<string, React.CSSProperties>;
pierreThemeConfig: PierreThemeConfig;
}> = React.memo(({ popup, syntaxTheme, pierreThemeConfig }) => {
}> = React.memo(({ popup, pierreThemeConfig }) => {
const parsedReadOutput = React.useMemo(() => parseReadToolOutput(popup.content), [popup.content]);
const inputMeta = popup.metadata?.input;
@@ -627,7 +626,6 @@ const DialogReadContent: React.FC<{
<VirtualizedCodeBlock
lines={codeLines}
language={detectedLanguage}
syntaxTheme={syntaxTheme}
maxHeight="70vh"
/>
);
@@ -1055,16 +1053,13 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
</div>
{meta.tool === 'bash' && getInputValue('command') ? (
<div className="tool-input-surface bg-transparent rounded-xl border border-border/20 mx-3">
<SyntaxHighlighter
style={syntaxTheme}
<WorkerHighlightedCode
language="bash"
PreTag="div"
customStyle={toolDisplayStyles.getPopupStyles()}
codeTagProps={DIALOG_CODE_TAG_PROPS}
wrapLongLines
>
{getInputValue('command')!}
</SyntaxHighlighter>
code={getInputValue('command')!}
style={toolDisplayStyles.getPopupStyles()}
codeStyle={DIALOG_CODE_TAG_PROPS.style}
wrap
/>
</div>
) : meta.tool === 'task' && getInputValue('prompt') ? (
<div
@@ -1124,16 +1119,13 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
completed: t('chat.todo.completed'),
cancelled: t('chat.todo.cancelled'),
}) || (
<SyntaxHighlighter
style={syntaxTheme}
<WorkerHighlightedCode
language="json"
PreTag="div"
wrapLongLines
customStyle={toolDisplayStyles.getPopupContainerStyles()}
codeTagProps={DIALOG_CODE_TAG_PROPS}
>
{popup.content}
</SyntaxHighlighter>
code={popup.content}
style={toolDisplayStyles.getPopupContainerStyles()}
codeStyle={DIALOG_CODE_TAG_PROPS.style}
wrap
/>
)
);
}
@@ -1179,22 +1171,19 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
if (tool === 'web-search' || tool === 'websearch' || tool === 'search_web') {
return (
renderWebSearchOutput(popup.content, syntaxTheme) || (
<SyntaxHighlighter
style={syntaxTheme}
<WorkerHighlightedCode
language="text"
PreTag="div"
wrapLongLines
customStyle={toolDisplayStyles.getPopupContainerStyles()}
codeTagProps={DIALOG_CODE_TAG_PROPS}
>
{popup.content}
</SyntaxHighlighter>
code={popup.content}
style={toolDisplayStyles.getPopupContainerStyles()}
codeStyle={DIALOG_CODE_TAG_PROPS.style}
wrap
/>
)
);
}
if (tool === 'read') {
return <DialogReadContent popup={popup} syntaxTheme={syntaxTheme} pierreThemeConfig={pierreThemeConfig} />;
return <DialogReadContent popup={popup} pierreThemeConfig={pierreThemeConfig} />;
}
// JSON tree viewer for generic JSON outputs
@@ -1210,16 +1199,13 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
}
return (
<SyntaxHighlighter
style={syntaxTheme}
<WorkerHighlightedCode
language={popup.language || 'text'}
PreTag="div"
wrapLongLines
customStyle={toolDisplayStyles.getPopupContainerStyles()}
codeTagProps={DIALOG_CODE_TAG_PROPS}
>
{popup.content}
</SyntaxHighlighter>
code={popup.content}
style={toolDisplayStyles.getPopupContainerStyles()}
codeStyle={DIALOG_CODE_TAG_PROPS.style}
wrap
/>
);
})()}
</div>
@@ -7,7 +7,7 @@ import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
import { getToolMetadata } from '@/lib/toolHelpers';
import type { ToolPart as ToolPartType, ToolState as ToolStateUnion } from '@opencode-ai/sdk/v2';
import { toolDisplayStyles } from '@/lib/typography';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -844,7 +844,7 @@ const ToolScrollableTextOutput: React.FC<{
metadata: Record<string, unknown> | undefined;
input: Record<string, unknown> | undefined;
syntaxTheme: { [key: string]: React.CSSProperties };
}> = ({ output, part, metadata, input, syntaxTheme }) => {
}> = ({ output, part, metadata, input }) => {
const { t } = useI18n();
const renderedOutput = getToolOutputText(output, part, metadata);
const outputLanguage = getToolOutputLanguage(output, part, metadata, input);
@@ -910,16 +910,13 @@ const ToolScrollableTextOutput: React.FC<{
/>
) : (
<div className="typography-code pr-12 text-muted-foreground/90">
<SyntaxHighlighter
style={syntaxTheme}
<WorkerHighlightedCode
language="json"
PreTag="div"
customStyle={TOOL_COLLAPSED_CUSTOM_STYLE}
codeTagProps={CODE_TAG_PROPS}
wrapLongLines
>
{renderedOutput}
</SyntaxHighlighter>
code={renderedOutput}
style={TOOL_COLLAPSED_CUSTOM_STYLE}
codeStyle={CODE_TAG_PROPS.style}
wrap
/>
</div>
)}
</div>
@@ -928,16 +925,13 @@ const ToolScrollableTextOutput: React.FC<{
return (
<div className={part.tool === 'bash' ? 'typography-code text-muted-foreground/90' : undefined}>
<SyntaxHighlighter
style={syntaxTheme}
<WorkerHighlightedCode
language={outputLanguage}
PreTag="div"
customStyle={TOOL_COLLAPSED_CUSTOM_STYLE}
codeTagProps={CODE_TAG_PROPS}
wrapLongLines
>
{renderedOutput}
</SyntaxHighlighter>
code={renderedOutput}
style={TOOL_COLLAPSED_CUSTOM_STYLE}
codeStyle={CODE_TAG_PROPS.style}
wrap
/>
</div>
);
};
@@ -1,48 +1,20 @@
/**
* VirtualizedCodeBlock — PERF-007
*
* Replaces per-line <SyntaxHighlighter> with:
* 1. ONE Prism.highlight() call to tokenize all code at once
* Renders large code/read outputs without mounting one highlighter per line:
* 1. ONE worker tokenization of the whole block (off the main thread)
* 2. virtua to only render visible rows
*
* This drops mount cost from O(N * Prism) to O(1 * Prism) + O(visible_rows).
* For a 2000-line file, ~2000 SyntaxHighlighter instances → ~30 plain <div>s.
* Tokenizing the whole block at once also preserves cross-line syntax context
* (multi-line strings/comments) that per-line highlighting loses. Colors resolve
* through the `--md-syntax-*` CSS variables on the container.
*/
import React from 'react';
import { Virtualizer } from 'virtua';
import Prism from 'prismjs';
// Ensure common languages are loaded (react-syntax-highlighter lazy-loads them,
// but we call Prism directly so we need them registered).
import 'prismjs/components/prism-markup';
import 'prismjs/components/prism-markup-templating';
import 'prismjs/components/prism-typescript';
import 'prismjs/components/prism-javascript';
import 'prismjs/components/prism-jsx';
import 'prismjs/components/prism-tsx';
import 'prismjs/components/prism-css';
import 'prismjs/components/prism-json';
import 'prismjs/components/prism-bash';
import 'prismjs/components/prism-python';
import 'prismjs/components/prism-rust';
import 'prismjs/components/prism-go';
import 'prismjs/components/prism-java';
import 'prismjs/components/prism-c';
import 'prismjs/components/prism-cpp';
import 'prismjs/components/prism-csharp';
import 'prismjs/components/prism-ruby';
import 'prismjs/components/prism-yaml';
import 'prismjs/components/prism-toml';
import 'prismjs/components/prism-markdown';
import 'prismjs/components/prism-sql';
import 'prismjs/components/prism-diff';
import 'prismjs/components/prism-docker';
import 'prismjs/components/prism-swift';
import 'prismjs/components/prism-kotlin';
import 'prismjs/components/prism-lua';
import 'prismjs/components/prism-php';
import 'prismjs/components/prism-scss';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme';
import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
// ── Threshold: files smaller than this render without virtualization ──
const VIRTUALIZE_THRESHOLD = 80;
@@ -60,7 +32,6 @@ export interface CodeLine {
interface VirtualizedCodeBlockProps {
lines: CodeLine[];
language: string;
syntaxTheme: Record<string, React.CSSProperties>;
/** Max visible height in CSS (default: 60vh) */
maxHeight?: string;
/** Show line numbers (default: true) */
@@ -69,123 +40,21 @@ interface VirtualizedCodeBlockProps {
lineStyles?: (line: CodeLine) => React.CSSProperties | undefined;
}
const toKebabCase = (value: string): string => value.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
const styleObjectToCss = (style: React.CSSProperties): string => {
return Object.entries(style)
.filter(([, v]) => v !== undefined && v !== null)
.map(([k, v]) => `${toKebabCase(k)}:${String(v)};`)
.join('');
};
const buildSelectorList = (rawKey: string): string[] => {
return rawKey
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.flatMap((selector) => {
if (selector.startsWith('.token')) {
return [`.oc-virtualized-prism ${selector}`];
}
if (selector.startsWith('token.')) {
return [`.oc-virtualized-prism .${selector}`];
}
if (/^[a-z0-9_-]+$/i.test(selector)) {
return [`.oc-virtualized-prism .token.${selector}`];
}
if (selector.includes('token')) {
return [`.oc-virtualized-prism ${selector}`];
}
return [];
});
};
const buildPrismThemeCss = (theme: Record<string, React.CSSProperties>): string => {
const rules: string[] = [];
Object.entries(theme).forEach(([rawKey, style]) => {
const selectors = buildSelectorList(rawKey);
if (selectors.length === 0) {
return;
}
const css = styleObjectToCss(style);
if (!css) {
return;
}
rules.push(`${selectors.join(',')}{${css}}`);
});
return rules.join('\n');
};
const LANGUAGE_ALIASES: Record<string, string> = {
text: 'plain',
plaintext: 'plain',
shell: 'bash',
sh: 'bash',
zsh: 'bash',
patch: 'diff',
dockerfile: 'docker',
js: 'javascript',
ts: 'typescript',
};
const normalizeLanguage = (language: string): string => {
const lower = language.toLowerCase();
return LANGUAGE_ALIASES[lower] ?? lower;
};
const HIGHLIGHT_CACHE_MAX = 5000;
const highlightCache = new Map<string, string>();
const highlightLine = (text: string, language: string): string => {
const normalizedLanguage = normalizeLanguage(language);
const cacheKey = `${normalizedLanguage}\n${text}`;
const cached = highlightCache.get(cacheKey);
if (cached !== undefined) {
return cached;
}
const grammar = Prism.languages[normalizedLanguage] ?? Prism.languages.text;
if (!grammar) {
const escaped = escapeHtml(text);
highlightCache.set(cacheKey, escaped);
return escaped;
}
try {
const highlighted = Prism.highlight(text, grammar, normalizedLanguage);
if (highlightCache.size >= HIGHLIGHT_CACHE_MAX) {
const oldestKey = highlightCache.keys().next().value;
if (typeof oldestKey === 'string') {
highlightCache.delete(oldestKey);
}
}
highlightCache.set(cacheKey, highlighted);
return highlighted;
} catch {
const escaped = escapeHtml(text);
highlightCache.set(cacheKey, escaped);
return escaped;
}
};
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// ── Component ────────────────────────────────────────────────────────
export const VirtualizedCodeBlock: React.FC<VirtualizedCodeBlockProps> = React.memo((props) => {
const {
lines,
language,
syntaxTheme,
maxHeight = '60vh',
showLineNumbers = true,
lineStyles,
} = props;
const prismThemeCss = React.useMemo(() => buildPrismThemeCss(syntaxTheme), [syntaxTheme]);
const { currentTheme } = useThemeSystem();
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
// Tokenize the whole block in one worker call; rows index into the result.
const fullText = React.useMemo(() => lines.map((line) => line.text).join('\n'), [lines]);
const highlighted = useWorkerHighlightedLines(fullText, language);
const shouldVirtualize = lines.length > VIRTUALIZE_THRESHOLD;
@@ -193,15 +62,14 @@ export const VirtualizedCodeBlock: React.FC<VirtualizedCodeBlockProps> = React.m
if (!shouldVirtualize) {
return (
<div
className="typography-code font-mono w-full min-w-0 oc-virtualized-prism"
style={{ maxHeight, overflow: 'auto' }}
className="typography-code font-mono w-full min-w-0"
style={{ ...(syntaxVars as React.CSSProperties), maxHeight, overflow: 'auto' }}
>
{prismThemeCss ? <style>{prismThemeCss}</style> : null}
{lines.map((line, idx) => (
<Row
key={idx}
line={line}
language={language}
html={highlighted?.[idx]}
showLineNumbers={showLineNumbers}
style={lineStyles?.(line)}
/>
@@ -214,8 +82,8 @@ export const VirtualizedCodeBlock: React.FC<VirtualizedCodeBlockProps> = React.m
return (
<VirtualizedRows
lines={lines}
language={language}
prismThemeCss={prismThemeCss}
highlighted={highlighted}
syntaxVars={syntaxVars}
maxHeight={maxHeight}
showLineNumbers={showLineNumbers}
lineStyles={lineStyles}
@@ -228,8 +96,8 @@ VirtualizedCodeBlock.displayName = 'VirtualizedCodeBlock';
// ── Virtualised container (extracted so the hook is top-level) ────────
interface VirtualizedRowsProps {
lines: CodeLine[];
language: string;
prismThemeCss: string;
highlighted: string[] | null;
syntaxVars: Record<string, string>;
maxHeight: string;
showLineNumbers: boolean;
lineStyles?: (line: CodeLine) => React.CSSProperties | undefined;
@@ -237,8 +105,8 @@ interface VirtualizedRowsProps {
const VirtualizedRows: React.FC<VirtualizedRowsProps> = React.memo(({
lines,
language,
prismThemeCss,
highlighted,
syntaxVars,
maxHeight,
showLineNumbers,
lineStyles,
@@ -249,10 +117,9 @@ const VirtualizedRows: React.FC<VirtualizedRowsProps> = React.memo(({
return (
<div
ref={parentRef}
className="typography-code font-mono w-full min-w-0 oc-virtualized-prism"
style={{ height: viewportHeight, maxHeight, overflow: 'auto' }}
className="typography-code font-mono w-full min-w-0"
style={{ ...(syntaxVars as React.CSSProperties), height: viewportHeight, maxHeight, overflow: 'auto' }}
>
{prismThemeCss ? <style>{prismThemeCss}</style> : null}
<Virtualizer
data={lines}
itemSize={ROW_HEIGHT}
@@ -263,7 +130,7 @@ const VirtualizedRows: React.FC<VirtualizedRowsProps> = React.memo(({
<Row
key={index}
line={line}
language={language}
html={highlighted?.[index]}
showLineNumbers={showLineNumbers}
style={lineStyles?.(line)}
/>
@@ -278,14 +145,12 @@ VirtualizedRows.displayName = 'VirtualizedRows';
// ── Single row ───────────────────────────────────────────────────────
interface RowProps {
line: CodeLine;
language: string;
html: string | undefined;
showLineNumbers: boolean;
style?: React.CSSProperties;
}
const Row: React.FC<RowProps> = React.memo(({ line, language, showLineNumbers, style }) => {
const html = React.useMemo(() => highlightLine(line.text, language), [line.text, language]);
const Row: React.FC<RowProps> = React.memo(({ line, html, showLineNumbers, style }) => {
return (
<div
className="typography-code font-mono flex w-full min-w-0"
@@ -304,11 +169,10 @@ const Row: React.FC<RowProps> = React.memo(({ line, language, showLineNumbers, s
<div className="whitespace-pre-wrap break-words text-muted-foreground/70 italic">
{line.text}
</div>
) : html !== undefined ? (
<div className="whitespace-pre" dangerouslySetInnerHTML={{ __html: html }} />
) : (
<div
className="whitespace-pre"
dangerouslySetInnerHTML={{ __html: html }}
/>
<div className="whitespace-pre">{line.text}</div>
)}
</div>
</div>