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
-8
View File
@@ -149,7 +149,6 @@
"@pierre/diffs": "1.3.0-beta.4",
"@replit/codemirror-vim": "^6.3.0",
"@simplewebauthn/browser": "13.3.0",
"@types/react-syntax-highlighter": "^15.5.13",
"@xenova/transformers": "^2.17.2",
"@zumer/snapdom": "^2.12.0",
"beautiful-mermaid": "^1.1.3",
@@ -169,12 +168,10 @@
"morphdom": "^2.7.7",
"motion": "^12.23.24",
"next-themes": "^0.4.6",
"prismjs": "^1.30.0",
"qrcode": "^1.5.4",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-drawio": "1.0.7",
"react-syntax-highlighter": "^15.6.6",
"rehype-katex": "^7.0.1",
"remark-math": "^6.0.0",
"remend": "^1.2.1",
@@ -193,7 +190,6 @@
"@remixicon/react": "^4.7.0",
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "^24.3.1",
"@types/prismjs": "^1.26.6",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
@@ -292,7 +288,6 @@
"@types/node": "^24.3.1",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/supertest": "^7.2.0",
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.21",
@@ -312,7 +307,6 @@
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.6",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"strip-json-comments": "^5.0.3",
@@ -1316,8 +1310,6 @@
"@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="],
"@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="],
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
-4
View File
@@ -44,7 +44,6 @@
"@pierre/diffs": "1.3.0-beta.4",
"@replit/codemirror-vim": "^6.3.0",
"@simplewebauthn/browser": "13.3.0",
"@types/react-syntax-highlighter": "^15.5.13",
"@xenova/transformers": "^2.17.2",
"@zumer/snapdom": "^2.12.0",
"beautiful-mermaid": "^1.1.3",
@@ -64,12 +63,10 @@
"morphdom": "^2.7.7",
"motion": "^12.23.24",
"next-themes": "^0.4.6",
"prismjs": "^1.30.0",
"qrcode": "^1.5.4",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-drawio": "1.0.7",
"react-syntax-highlighter": "^15.6.6",
"rehype-katex": "^7.0.1",
"remark-math": "^6.0.0",
"remend": "^1.2.1",
@@ -87,7 +84,6 @@
"@eslint/js": "^9.33.0",
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "^24.3.1",
"@types/prismjs": "^1.26.6",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
+73 -79
View File
@@ -1,97 +1,101 @@
import React from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { cn } from '@/lib/utils';
import { getLanguageFromExtension } from '@/lib/toolHelpers';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme';
import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
import { parseDiffToUnified } from './message/toolRenderers';
const DIFF_CUSTOM_STYLE: React.CSSProperties = {
margin: 0,
padding: 0,
fontSize: 'inherit',
background: 'transparent',
backgroundColor: 'transparent',
borderRadius: 0,
overflow: 'visible',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
overflowWrap: 'anywhere',
};
const DIFF_CODE_TAG_PROPS = {
style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } as React.CSSProperties,
};
// One highlighted line: swaps in worker-tokenized inner HTML when ready, falls
// back to plain text while loading or on failure.
const CodeLineContent: React.FC<{ content: string; html: string | undefined }> = ({ content, html }) =>
html !== undefined ? (
<span className="whitespace-pre-wrap break-all" dangerouslySetInnerHTML={{ __html: html }} />
) : (
<span className="whitespace-pre-wrap break-all">{content}</span>
);
interface DiffPreviewProps {
diff: string;
syntaxTheme: { [key: string]: React.CSSProperties };
filePath?: string;
}
export const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, syntaxTheme, filePath }) => (
<div className="typography-code px-1 pb-1 pt-0 space-y-0">
{parseDiffToUnified(diff).map((hunk, hunkIdx) => (
<div key={hunkIdx} className="-mx-1 px-1 border-b border-border/20 last:border-b-0">
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground border-b border-border/10 break-words -mx-1">
{`${hunk.file || filePath?.split('/').pop() || 'file'} (line ${hunk.oldStart})`}
</div>
export const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, filePath }) => {
const { currentTheme } = useThemeSystem();
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
const hunks = React.useMemo(() => parseDiffToUnified(diff), [diff]);
const language = getLanguageFromExtension(filePath || hunks[0]?.file) || 'text';
<div>
{hunk.lines.map((line, lineIdx) => (
<div
key={lineIdx}
className={cn(
'typography-code font-mono px-2 py-0.5 flex -mx-2',
line.type === 'context' && 'bg-transparent',
line.type === 'removed' && 'bg-transparent',
line.type === 'added' && 'bg-transparent'
)}
style={
line.type === 'removed'
? { backgroundColor: 'var(--tools-edit-removed-bg)' }
: line.type === 'added'
? { backgroundColor: 'var(--tools-edit-added-bg)' }
: {}
}
>
<span className="w-10 flex-shrink-0 text-right pr-3 select-none border-r mr-3 -my-0.5 py-0.5" style={{ color: 'var(--tools-edit-line-number)', borderColor: 'var(--tools-border)' }}>
{line.lineNumber || ''}
</span>
<div className="flex-1 min-w-0">
<SyntaxHighlighter
style={syntaxTheme}
language={getLanguageFromExtension(filePath || hunk.file) || 'text'}
PreTag="div"
wrapLines
wrapLongLines
customStyle={DIFF_CUSTOM_STYLE}
codeTagProps={DIFF_CODE_TAG_PROPS}
// Flatten all rendered lines so the whole diff tokenizes in one worker call.
const flatContent = React.useMemo(
() => hunks.flatMap((hunk) => hunk.lines.map((line) => line.content)).join('\n'),
[hunks],
);
const highlighted = useWorkerHighlightedLines(flatContent, language);
let lineCursor = 0;
return (
<div className="typography-code px-1 pb-1 pt-0 space-y-0" style={syntaxVars as React.CSSProperties}>
{hunks.map((hunk, hunkIdx) => (
<div key={hunkIdx} className="-mx-1 px-1 border-b border-border/20 last:border-b-0">
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground border-b border-border/10 break-words -mx-1">
{`${hunk.file || filePath?.split('/').pop() || 'file'} (line ${hunk.oldStart})`}
</div>
<div>
{hunk.lines.map((line, lineIdx) => {
const html = highlighted?.[lineCursor];
lineCursor += 1;
return (
<div
key={lineIdx}
className={cn(
'typography-code font-mono px-2 py-0.5 flex -mx-2',
line.type === 'context' && 'bg-transparent',
line.type === 'removed' && 'bg-transparent',
line.type === 'added' && 'bg-transparent'
)}
style={
line.type === 'removed'
? { backgroundColor: 'var(--tools-edit-removed-bg)' }
: line.type === 'added'
? { backgroundColor: 'var(--tools-edit-added-bg)' }
: {}
}
>
{line.content}
</SyntaxHighlighter>
</div>
</div>
))}
<span className="w-10 flex-shrink-0 text-right pr-3 select-none border-r mr-3 -my-0.5 py-0.5" style={{ color: 'var(--tools-edit-line-number)', borderColor: 'var(--tools-border)' }}>
{line.lineNumber || ''}
</span>
<div className="flex-1 min-w-0">
<CodeLineContent content={line.content} html={html} />
</div>
</div>
);
})}
</div>
</div>
</div>
))}
</div>
);
))}
</div>
);
};
interface WritePreviewProps {
content: string;
syntaxTheme: { [key: string]: React.CSSProperties };
filePath?: string;
}
export const WritePreview: React.FC<WritePreviewProps> = ({ content, syntaxTheme, filePath }) => {
const lines = content.split('\n');
export const WritePreview: React.FC<WritePreviewProps> = ({ content, filePath }) => {
const { currentTheme } = useThemeSystem();
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
const lines = React.useMemo(() => content.split('\n'), [content]);
const language = getLanguageFromExtension(filePath ?? '') || 'text';
const displayPath = filePath?.split('/').pop() || 'New file';
const lineCount = Math.max(lines.length, 1);
const headerLineLabel = lineCount === 1 ? 'line 1' : `lines 1-${lineCount}`;
const highlighted = useWorkerHighlightedLines(content, language);
return (
<div className="w-full min-w-0">
<div className="w-full min-w-0" style={syntaxVars as React.CSSProperties}>
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground border border-border/10 rounded-lg mb-1">
{`${displayPath} (${headerLineLabel})`}
</div>
@@ -102,17 +106,7 @@ export const WritePreview: React.FC<WritePreviewProps> = ({ content, syntaxTheme
{lineIdx + 1}
</span>
<div className="flex-1 min-w-0">
<SyntaxHighlighter
style={syntaxTheme}
language={language}
PreTag="div"
wrapLines
wrapLongLines
customStyle={DIFF_CUSTOM_STYLE}
codeTagProps={DIFF_CODE_TAG_PROPS}
>
{line || ' '}
</SyntaxHighlighter>
<CodeLineContent content={line || ' '} html={highlighted?.[lineIdx]} />
</div>
</div>
))}
@@ -4,9 +4,7 @@ import type { PermissionRequest, PermissionResponse } from '@/types/permission';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions } from '@/sync/sync-context';
import * as sessionActions from '@/sync/session-actions';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Icon } from "@/components/icon/Icon";
import { DiffPreview, WritePreview } from './DiffPreview';
@@ -104,8 +102,6 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
const sourceSession = sessions.find((session) => session.id === permission.sessionID);
return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId);
}, [permission.sessionID, currentSessionId, sessions]);
const { currentTheme } = useThemeSystem();
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
const handleResponse = async (response: PermissionResponse) => {
setIsResponding(true);
@@ -168,16 +164,13 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
{}
{command && (
<div>
<SyntaxHighlighter
<WorkerHighlightedCode
language="bash"
style={syntaxTheme}
PreTag="div"
customStyle={PERMISSION_BASH_CUSTOM_STYLE}
codeTagProps={PERMISSION_BASH_CODE_TAG_PROPS}
wrapLongLines={true}
>
{command}
</SyntaxHighlighter>
code={command}
style={PERMISSION_BASH_CUSTOM_STYLE}
codeStyle={PERMISSION_BASH_CODE_TAG_PROPS.style}
wrap
/>
</div>
)}
</>
@@ -198,7 +191,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
)}
{changes && (
<ScrollableOverlay outerClassName="max-h-[60vh]" className="tool-output-surface p-1 rounded-xl border border-border/20 bg-transparent">
<DiffPreview diff={changes} syntaxTheme={syntaxTheme} filePath={filePath} />
<DiffPreview diff={changes} filePath={filePath} />
</ScrollableOverlay>
)}
</>
@@ -212,7 +205,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
if (content) {
return (
<ScrollableOverlay outerClassName="max-h-[60vh]" className="tool-output-surface p-1 rounded-xl border border-border/20 bg-transparent">
<WritePreview content={content} syntaxTheme={syntaxTheme} filePath={filePath} />
<WritePreview content={content} filePath={filePath} />
</ScrollableOverlay>
);
}
@@ -247,14 +240,12 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
<div className="mb-2">
<div className="typography-meta text-muted-foreground mb-1">{t('chat.permissionCard.headers')}</div>
<ScrollableOverlay outerClassName="max-h-24" className="p-0">
<SyntaxHighlighter
<WorkerHighlightedCode
language="json"
style={syntaxTheme}
customStyle={PERMISSION_JSON_CUSTOM_STYLE}
wrapLongLines={true}
>
{JSON.stringify(headers, null, 2)}
</SyntaxHighlighter>
code={JSON.stringify(headers, null, 2)}
style={PERMISSION_JSON_CUSTOM_STYLE}
wrap
/>
</ScrollableOverlay>
</div>
)}
@@ -262,14 +253,12 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
<div className="mb-2">
<div className="typography-meta text-muted-foreground mb-1">{t('chat.permissionCard.body')}</div>
<ScrollableOverlay outerClassName="max-h-32" className="p-0">
<SyntaxHighlighter
<WorkerHighlightedCode
language={typeof body === 'object' ? 'json' : 'text'}
style={syntaxTheme}
customStyle={PERMISSION_JSON_CUSTOM_STYLE}
wrapLongLines={true}
>
{typeof body === 'object' ? JSON.stringify(body, null, 2) : String(body)}
</SyntaxHighlighter>
code={typeof body === 'object' ? JSON.stringify(body, null, 2) : String(body)}
style={PERMISSION_JSON_CUSTOM_STYLE}
wrap
/>
</ScrollableOverlay>
</div>
)}
@@ -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;
};
@@ -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>
@@ -0,0 +1,110 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme';
import { highlightCodeInWorker } from '@/components/chat/markdown/markdown-worker';
// Shared static code highlighter backed by the markdown Shiki Web Worker.
//
// Replaces `react-syntax-highlighter` for non-streaming code surfaces (tool
// output, permission previews, diffs, sidebar file contents). The escaped code
// paints synchronously; the worker tokenizes off the main thread and swaps in
// the highlighted markup. Colors resolve through the `--md-syntax-*` CSS
// variables on the host, so theme changes never require re-highlighting.
const escapeHtml = (text: string): string =>
text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const styleString = (style?: React.CSSProperties): string => {
if (!style) return '';
return Object.entries(style)
.map(([key, value]) => {
if (value == null) return '';
const prop = key.startsWith('--') ? key : key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
return `${prop}:${typeof value === 'number' ? `${value}px` : value};`;
})
.join('');
};
// Normalize a worker/plain `<pre><code>` so it sits flush inside the host: drop
// Shiki's own background/margin and apply wrap + caller code styles.
const applyPreStyles = (host: HTMLElement, wrap: boolean, codeStyle?: React.CSSProperties): void => {
const pre = host.querySelector('pre');
if (pre) {
pre.style.margin = '0';
pre.style.background = 'transparent';
pre.style.whiteSpace = wrap ? 'pre-wrap' : 'pre';
if (wrap) {
pre.style.wordBreak = 'break-word';
pre.style.overflowWrap = 'break-word';
}
}
const code = host.querySelector('code');
if (code) {
const extra = styleString(codeStyle);
if (extra) code.setAttribute('style', `${code.getAttribute('style') ?? ''}${extra}`);
}
};
const plainHtml = (code: string): string => `<pre><code>${escapeHtml(code)}</code></pre>`;
export interface WorkerHighlightedCodeProps {
code: string;
language: string;
className?: string;
/** Inline styles for the host container (mirrors react-syntax-highlighter `customStyle`). */
style?: React.CSSProperties;
/** Inline styles applied to the `<code>` element (mirrors `codeTagProps.style`). */
codeStyle?: React.CSSProperties;
/** Wrap long lines instead of horizontal scroll. */
wrap?: boolean;
}
export const WorkerHighlightedCode: React.FC<WorkerHighlightedCodeProps> = ({
code,
language,
className,
style,
codeStyle,
wrap = false,
}) => {
const { currentTheme } = useThemeSystem();
const hostRef = React.useRef<HTMLDivElement>(null);
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
// Synchronous escaped first paint — no blank frame before highlighting lands.
React.useLayoutEffect(() => {
const host = hostRef.current;
if (!host) return;
host.innerHTML = plainHtml(code);
applyPreStyles(host, wrap, codeStyle);
}, [code, wrap, codeStyle]);
// Highlight off the main thread, then swap in. Guarded against stale results.
React.useEffect(() => {
const host = hostRef.current;
if (!host) return;
let active = true;
void highlightCodeInWorker(code, (language || 'text').toLowerCase()).then((html) => {
if (!active || !host || !html) return;
host.innerHTML = html;
applyPreStyles(host, wrap, codeStyle);
});
return () => {
active = false;
};
}, [code, language, wrap, codeStyle]);
return (
<div
ref={hostRef}
className={cn('typography-code', className)}
style={{ ...(syntaxVars as React.CSSProperties), ...style }}
/>
);
};
@@ -0,0 +1,26 @@
import React from 'react';
import { highlightLinesInWorker } from '@/components/chat/markdown/markdown-worker';
// Tokenize a whole block ONCE in the Shiki worker and expose per-line inner
// HTML. For per-line layouts (diffs, gutters, virtualization) that would
// otherwise spawn one highlighter per row. Returns `null` until the first
// result lands (or permanently on failure) — callers render plain text then.
//
// Whole-block tokenization also restores cross-line syntax context (multi-line
// strings / comments) that independent per-line highlighting loses.
export const useWorkerHighlightedLines = (code: string, language: string): string[] | null => {
const [lines, setLines] = React.useState<string[] | null>(null);
React.useEffect(() => {
let active = true;
setLines(null);
void highlightLinesInWorker(code, (language || 'text').toLowerCase()).then((result) => {
if (active) setLines(result);
});
return () => {
active = false;
};
}, [code, language]);
return lines;
};
@@ -1,11 +1,9 @@
import React from 'react';
import type { Message, Part } from '@opencode-ai/sdk/v2';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
import { deriveMessageRole } from '@/components/chat/message/messageRole';
import { Icon } from "@/components/icon/Icon";
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -272,9 +270,7 @@ const resolveProviderAndModel = (
export const ContextPanelContent: React.FC = () => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
const [expandedRawMessages, setExpandedRawMessages] = React.useState<Record<string, boolean>>({});
const [copiedRawMessageId, setCopiedRawMessageId] = React.useState<string | null>(null);
const copyResetTimeoutRef = React.useRef<number | null>(null);
@@ -649,28 +645,18 @@ export const ContextPanelContent: React.FC = () => {
{isCopied ? <Icon name="check" className="size-3.5" /> : <Icon name="file-copy" className="size-3.5" />}
</button>
</div>
<SyntaxHighlighter
<WorkerHighlightedCode
language="json"
style={syntaxTheme}
PreTag="div"
customStyle={{
code={jsonValue}
style={{
margin: 0,
padding: '0.75rem',
background: 'transparent',
fontSize: 'var(--text-micro)',
lineHeight: '1.35',
}}
codeTagProps={{
style: {
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overflowWrap: 'break-word',
},
}}
wrapLongLines
>
{jsonValue}
</SyntaxHighlighter>
wrap
/>
</div>
</div>
)}
@@ -1,14 +0,0 @@
declare module 'react-syntax-highlighter/create-element' {
import type { ReactNode } from 'react';
type CreateElementOptions = {
node: unknown;
stylesheet: unknown;
useInlineStyles: boolean;
key?: string | number;
};
const createElement: (options: CreateElementOptions) => ReactNode;
export default createElement;
}
-2
View File
@@ -70,7 +70,6 @@
"@types/node": "^24.3.1",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/supertest": "^7.2.0",
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.21",
@@ -90,7 +89,6 @@
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.6",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"strip-json-comments": "^5.0.3",