perf: fix streaming lag, memory leaks, stuck spinners, and proxy timeout (#483)

* perf: fix streaming lag, memory leaks, and proxy timeout

- PERF-001: Batch all streaming parts via requestAnimationFrame instead of
  per-token Zustand set() calls (~100/sec → 1 per frame)
- PERF-002: Fix direct state.sessionMemoryState mutation inside set() callback
- PERF-003: Debounce messageStore→sessionStore subscription via rAF + 500ms
  title computation delay
- PERF-004: Stabilize SSE callbacks with refs to prevent reconnection storms;
  add 5-min stuck session idle timeout
- PERF-005: Bound messageCache (500 max, LRU eviction), cap registry Maps,
  cleanup on session eviction
- PERF-006: Replace toast duration: Infinity with 30s + id-based dedup
- Fix proxy timeout: POST /session/:id/message 45s → 4min (matches CLI)
- Add vitest + jsdom test infrastructure (61 tests across 7 files)

Addresses: #476 (stuck spinner), #358 (34GB memory leak), #190 (browser lag)

* perf: virtualize tool output rendering (read, edit, write)

- PERF-007: Replace per-line <SyntaxHighlighter> with VirtualizedCodeBlock:
  - ONE Prism.highlight() call for entire file instead of N per-line calls
  - @tanstack/react-virtual renders only visible rows (~30 vs 2000+)
  - Applied to: ToolPart (read, DiffPreview, WriteInputPreview) and
    ToolOutputDialog (unified diff, read content)
- PERF-008: Memoize parseReadToolOutput/parseDiffToUnified via useMemo
  to prevent re-parsing on every re-render

A 2000-line file read now mounts ~30 DOM nodes instead of 2000
SyntaxHighlighter instances, eliminating the main-thread blocking
that caused UI freezes during file operations.

* fix: resolve lint errors (unused vars in tests and VirtualizedCodeBlock)

* chore: trim PR scope to core perf fixes

* fix: restore tool-card highlight stability

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Nguyễn Ngô Thượng
2026-02-23 12:05:53 +02:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 989593ed72
commit 3cd6d051cb
11 changed files with 756 additions and 407 deletions
@@ -23,6 +23,7 @@ import {
} from './toolRenderers';
import type { ToolPopupContent, DiffViewMode } from './types';
import { DiffViewToggle } from './DiffViewToggle';
import { VirtualizedCodeBlock, type CodeLine } from './parts/VirtualizedCodeBlock';
interface ToolOutputDialogProps {
popup: ToolPopupContent;
@@ -355,6 +356,104 @@ const ImagePreviewDialog: React.FC<{
return createPortal(content, document.body);
};
// ── PERF-007: Virtualised sub-components for dialog ──────────────────
const DialogUnifiedDiff: React.FC<{
popup: ToolPopupContent;
syntaxTheme: Record<string, React.CSSProperties>;
isMobile: boolean;
}> = React.memo(({ popup, syntaxTheme, isMobile }) => {
const hunks = React.useMemo(() => parseDiffToUnified(popup.content), [popup.content]);
return (
<div className="typography-code">
{hunks.map((hunk, hunkIdx) => {
const inputFile = (typeof popup.metadata?.input === 'object' && popup.metadata.input !== null)
? ((popup.metadata.input as Record<string, unknown>).file_path || (popup.metadata.input as Record<string, unknown>).filePath)
: null;
const fileStr = typeof inputFile === 'string' ? inputFile : '';
const hunkFileStr = typeof hunk.file === 'string' ? hunk.file : '';
const lang = getLanguageFromExtension(fileStr || hunkFileStr || '') || 'text';
const codeLines: CodeLine[] = hunk.lines.map((line) => ({
text: line.content,
lineNumber: line.lineNumber || null,
type: line.type as CodeLine['type'],
}));
return (
<div key={hunkIdx} className="border-b border-border/20 last:border-b-0">
<div className={cn('bg-muted/20 px-3 py-2 font-medium text-muted-foreground border-b border-border/10 sticky top-0 z-10 break-words -mx-3', isMobile ? 'typography-micro' : 'typography-markdown')}>
{`${hunk.file} (line ${hunk.oldStart})`}
</div>
<VirtualizedCodeBlock
lines={codeLines}
language={lang}
syntaxTheme={syntaxTheme}
maxHeight="70vh"
lineStyles={(line) =>
line.type === 'removed'
? { backgroundColor: 'var(--tools-edit-removed-bg)', color: 'var(--tools-edit-removed)' }
: line.type === 'added'
? { backgroundColor: 'var(--tools-edit-added-bg)', color: 'var(--tools-edit-added)' }
: undefined
}
/>
</div>
);
})}
</div>
);
});
DialogUnifiedDiff.displayName = 'DialogUnifiedDiff';
const DialogReadContent: React.FC<{
popup: ToolPopupContent;
syntaxTheme: Record<string, React.CSSProperties>;
}> = React.memo(({ popup, syntaxTheme }) => {
const parsedReadOutput = React.useMemo(() => parseReadToolOutput(popup.content), [popup.content]);
const codeLines: CodeLine[] = React.useMemo(() => {
const inputMeta = popup.metadata?.input;
const inputObj = typeof inputMeta === 'object' && inputMeta !== null ? (inputMeta as Record<string, unknown>) : {};
const offset = typeof inputObj.offset === 'number' ? inputObj.offset : 0;
const hasExplicitLineNumbers = parsedReadOutput.lines.some((line) => line.lineNumber !== null);
let fallbackLineCursor = offset;
return parsedReadOutput.lines.map((line) => {
if (line.lineNumber !== null) {
fallbackLineCursor = line.lineNumber;
}
const shouldAssignFallback =
parsedReadOutput.type === 'file'
&& !hasExplicitLineNumbers
&& line.lineNumber === null
&& !line.isInfo;
const effectiveLineNumber = line.lineNumber ?? (shouldAssignFallback
? (fallbackLineCursor += 1)
: null);
return {
text: line.text,
lineNumber: effectiveLineNumber,
isInfo: line.isInfo,
};
});
}, [parsedReadOutput, popup.metadata?.input]);
return (
<VirtualizedCodeBlock
lines={codeLines}
language={popup.language || 'text'}
syntaxTheme={syntaxTheme}
maxHeight="70vh"
/>
);
});
DialogReadContent.displayName = 'DialogReadContent';
const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange, syntaxTheme, isMobile }) => {
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>(isMobile ? 'unified' : 'side-by-side');
@@ -460,82 +559,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
{popup.isDiff ? (
diffViewMode === 'unified' ? (
<div className="typography-code">
{parseDiffToUnified(popup.content).map((hunk, hunkIdx) => (
<div key={hunkIdx} className="border-b border-border/20 last:border-b-0">
<div
className={cn('bg-muted/20 px-3 py-2 font-medium text-muted-foreground border-b border-border/10 sticky top-0 z-10 break-words -mx-3', isMobile ? 'typography-micro' : 'typography-markdown')}
>
{`${hunk.file} (line ${hunk.oldStart})`}
</div>
<div>
{hunk.lines.map((line, lineIdx) => (
<div
key={lineIdx}
className={cn(
'typography-code font-mono px-3 py-0.5 flex',
line.type === 'context' && 'bg-transparent',
line.type === 'removed' && 'bg-transparent',
line.type === 'added' && 'bg-transparent'
)}
style={{
lineHeight: '1.1',
...(line.type === 'removed'
? { backgroundColor: 'var(--tools-edit-removed-bg)' }
: line.type === 'added'
? { backgroundColor: 'var(--tools-edit-added-bg)' }
: {}),
}}
>
<span className="w-12 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">
{(() => {
const inputFile = (typeof popup.metadata?.input === 'object' && popup.metadata.input !== null)
? ((popup.metadata.input as Record<string, unknown>).file_path || (popup.metadata.input as Record<string, unknown>).filePath)
: null;
const fileStr = typeof inputFile === 'string' ? inputFile : '';
const hunkFile = (typeof hunk === 'object' && hunk !== null && 'file' in hunk) ? (hunk as UnifiedDiffHunk).file : null;
const hunkFileStr = typeof hunkFile === 'string' ? hunkFile : '';
const finalFile = fileStr || hunkFileStr || '';
return (
<SyntaxHighlighter
style={syntaxTheme}
language={getLanguageFromExtension(finalFile) || 'text'}
PreTag="div"
wrapLines
wrapLongLines
customStyle={{
margin: 0,
padding: 0,
fontSize: 'inherit',
background: 'transparent',
backgroundColor: 'transparent',
borderRadius: 0,
overflow: 'visible',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
overflowWrap: 'anywhere',
}}
codeTagProps={{
style: {
background: 'transparent',
backgroundColor: 'transparent',
},
}}
>
{line.content}
</SyntaxHighlighter>
);
})()}
</div>
</div>
))}
</div>
</div>
))}
</div>
<DialogUnifiedDiff popup={popup} syntaxTheme={syntaxTheme} isMobile={isMobile} />
) : popup.diffHunks ? (
<div className="typography-code">
{popup.diffHunks.map((hunk, hunkIdx) => (
@@ -741,77 +765,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
}
if (tool === 'read') {
const parsedReadOutput = parseReadToolOutput(popup.content);
const inputMeta = popup.metadata?.input;
const inputObj = typeof inputMeta === 'object' && inputMeta !== null ? (inputMeta as Record<string, unknown>) : {};
const offset = typeof inputObj.offset === 'number' ? inputObj.offset : 0;
let fallbackLineCursor = offset;
const hasExplicitLineNumbers = parsedReadOutput.lines.some((line) => line.lineNumber !== null);
return (
<div>
{parsedReadOutput.lines.map((line, idx: number) => {
if (line.lineNumber !== null) {
fallbackLineCursor = line.lineNumber;
}
const shouldAssignFallbackLineNumber =
parsedReadOutput.type === 'file'
&& !hasExplicitLineNumbers
&& line.lineNumber === null
&& !line.isInfo;
const effectiveLineNumber = line.lineNumber ?? (shouldAssignFallbackLineNumber
? (fallbackLineCursor += 1)
: null);
const shouldShowLineNumber = !line.isInfo && effectiveLineNumber !== null;
return (
<div key={idx} className={`typography-code font-mono flex ${line.isInfo ? 'text-muted-foreground/70 italic' : ''}`}>
<span className="w-12 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)' }}>
{shouldShowLineNumber ? effectiveLineNumber : ''}
</span>
<div className="flex-1 min-w-0">
{line.isInfo ? (
<div className="whitespace-pre-wrap break-words">{line.text}</div>
) : (
<SyntaxHighlighter
style={syntaxTheme}
language={popup.language || 'text'}
PreTag="div"
wrapLines
wrapLongLines
customStyle={{
margin: 0,
padding: 0,
fontSize: 'inherit',
background: 'transparent',
backgroundColor: 'transparent',
borderRadius: 0,
overflow: 'visible',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
overflowWrap: 'anywhere',
}}
codeTagProps={{
style: {
background: 'transparent',
backgroundColor: 'transparent',
fontSize: 'inherit',
},
}}
>
{line.text}
</SyntaxHighlighter>
)}
</div>
</div>
);
})}
</div>
);
return <DialogReadContent popup={popup} syntaxTheme={syntaxTheme} />;
}
return (
@@ -27,6 +27,7 @@ import {
formatInputForDisplay,
parseReadToolOutput,
} from '../toolRenderers';
import { VirtualizedCodeBlock, type CodeLine } from './VirtualizedCodeBlock';
type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record<string, unknown>; input?: Record<string, unknown>; output?: string; error?: string; time?: { start: number; end?: number } };
@@ -570,74 +571,48 @@ interface DiffPreviewProps {
input?: ToolStateWithMetadata['input'];
}
const DiffPreview: React.FC<DiffPreviewProps> = React.memo(({ diff, syntaxTheme, input }) => (
<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 last:border-b-0" style={{ borderBottomWidth: '1px', borderBottomColor: 'var(--tools-border)' }}>
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground break-words -mx-1" style={{ borderBottomWidth: '1px', borderBottomColor: 'var(--tools-border)' }}>
{`${hunk.file} (line ${hunk.oldStart})`}
</div>
const DiffPreview: React.FC<DiffPreviewProps> = React.memo(({ diff, syntaxTheme, input }) => {
const hunks = React.useMemo(() => parseDiffToUnified(diff), [diff]);
<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(typeof input?.file_path === 'string' ? input.file_path : typeof input?.filePath === 'string' ? input.filePath : hunk.file) || 'text'}
PreTag="div"
wrapLines
wrapLongLines
customStyle={{
margin: 0,
padding: 0,
fontSize: 'inherit',
background: 'transparent',
backgroundColor: 'transparent',
borderRadius: 0,
overflow: 'visible',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
overflowWrap: 'anywhere',
color: line.type === 'removed' ? 'var(--tools-edit-removed)' : line.type === 'added' ? 'var(--tools-edit-added)' : 'inherit',
}}
codeTagProps={{
style: {
background: 'transparent',
backgroundColor: 'transparent',
fontSize: 'inherit',
color: line.type === 'removed' ? 'var(--tools-edit-removed)' : line.type === 'added' ? 'var(--tools-edit-added)' : 'inherit',
},
}}
>
{line.content}
</SyntaxHighlighter>
</div>
return (
<div className="typography-code px-1 pb-1 pt-0 space-y-0">
{hunks.map((hunk, hunkIdx) => {
const lang = getLanguageFromExtension(
typeof input?.file_path === 'string' ? input.file_path
: typeof input?.filePath === 'string' ? input.filePath
: hunk.file
) || 'text';
const codeLines: CodeLine[] = hunk.lines.map((line) => ({
text: line.content,
lineNumber: line.lineNumber || null,
type: line.type as CodeLine['type'],
}));
return (
<div key={hunkIdx} className="-mx-1 px-1 last:border-b-0" style={{ borderBottomWidth: '1px', borderBottomColor: 'var(--tools-border)' }}>
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground break-words -mx-1" style={{ borderBottomWidth: '1px', borderBottomColor: 'var(--tools-border)' }}>
{`${hunk.file} (line ${hunk.oldStart})`}
</div>
))}
</div>
</div>
))}
</div>
));
<VirtualizedCodeBlock
lines={codeLines}
language={lang}
syntaxTheme={syntaxTheme}
maxHeight="50vh"
lineStyles={(line) =>
line.type === 'removed'
? { backgroundColor: 'var(--tools-edit-removed-bg)', color: 'var(--tools-edit-removed)' }
: line.type === 'added'
? { backgroundColor: 'var(--tools-edit-added-bg)', color: 'var(--tools-edit-added)' }
: undefined
}
/>
</div>
);
})}
</div>
);
});
DiffPreview.displayName = 'DiffPreview';
@@ -649,13 +624,20 @@ interface WriteInputPreviewProps {
}
const WriteInputPreview: React.FC<WriteInputPreviewProps> = React.memo(({ content, syntaxTheme, filePath, displayPath }) => {
const lines = React.useMemo(() => content.split('\n'), [content]);
const language = React.useMemo(
() => getLanguageFromExtension(filePath ?? '') || detectLanguageFromOutput(content, 'write', filePath ? { filePath } : undefined),
[content, filePath]
);
const lineCount = Math.max(lines.length, 1);
const codeLines: CodeLine[] = React.useMemo(() => {
const rawLines = content.split('\n');
return rawLines.map((text, idx) => ({
text: text || ' ',
lineNumber: idx + 1,
}));
}, [content]);
const lineCount = Math.max(codeLines.length, 1);
const headerLineLabel = lineCount === 1 ? 'line 1' : `lines 1-${lineCount}`;
return (
@@ -663,47 +645,83 @@ const WriteInputPreview: React.FC<WriteInputPreviewProps> = React.memo(({ conten
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground rounded-lg mb-1" style={{ borderWidth: '1px', borderColor: 'var(--tools-border)' }}>
{`${displayPath} (${headerLineLabel})`}
</div>
<div className="space-y-0">
{lines.map((line, lineIdx) => (
<div key={lineIdx} className="typography-code font-mono px-2 py-0.5 flex -mx-1">
<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)' }}>
{lineIdx + 1}
</span>
<div className="flex-1 min-w-0">
<SyntaxHighlighter
style={syntaxTheme}
language={language || 'text'}
PreTag="div"
wrapLines
wrapLongLines
customStyle={{
margin: 0,
padding: 0,
fontSize: 'inherit',
background: 'transparent',
backgroundColor: 'transparent',
borderRadius: 0,
overflow: 'visible',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
overflowWrap: 'anywhere',
}}
codeTagProps={{
style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' },
}}
>
{line || ' '}
</SyntaxHighlighter>
</div>
</div>
))}
</div>
<VirtualizedCodeBlock
lines={codeLines}
language={language || 'text'}
syntaxTheme={syntaxTheme}
maxHeight="50vh"
/>
</div>
);
});
WriteInputPreview.displayName = 'WriteInputPreview';
// ── PERF-007: Read tool output with virtualised highlighting ─────────
interface ReadToolVirtualizedProps {
outputString: string;
input?: Record<string, unknown>;
syntaxTheme: { [key: string]: React.CSSProperties };
toolName: string;
renderScrollableBlock: (
content: React.ReactNode,
options?: { maxHeightClass?: string; className?: string; disableHorizontal?: boolean; outerClassName?: string }
) => React.ReactNode;
}
const ReadToolVirtualized: React.FC<ReadToolVirtualizedProps> = React.memo(({
outputString,
input,
syntaxTheme,
toolName,
renderScrollableBlock,
}) => {
const parsedReadOutput = React.useMemo(() => parseReadToolOutput(outputString), [outputString]);
const offset = typeof input?.offset === 'number' ? input.offset : 0;
const codeLines: CodeLine[] = React.useMemo(() => {
const hasExplicitLineNumbers = parsedReadOutput.lines.some((line) => line.lineNumber !== null);
let fallbackLineCursor = offset;
return parsedReadOutput.lines.map((line) => {
if (line.lineNumber !== null) {
fallbackLineCursor = line.lineNumber;
}
const shouldAssignFallback =
parsedReadOutput.type === 'file'
&& !hasExplicitLineNumbers
&& line.lineNumber === null
&& !line.isInfo;
const effectiveLineNumber = line.lineNumber ?? (shouldAssignFallback
? (fallbackLineCursor += 1)
: null);
return {
text: line.text,
lineNumber: effectiveLineNumber,
isInfo: line.isInfo,
};
});
}, [parsedReadOutput, offset]);
const language = React.useMemo(() => {
const contentForLanguage = parsedReadOutput.lines.map((l) => l.text).join('\n');
return detectLanguageFromOutput(contentForLanguage, toolName, input as Record<string, unknown>);
}, [parsedReadOutput, toolName, input]);
return renderScrollableBlock(
<VirtualizedCodeBlock
lines={codeLines}
language={language}
syntaxTheme={syntaxTheme}
maxHeight="55vh"
/>,
{ className: 'p-1' }
) as React.ReactElement;
});
ReadToolVirtualized.displayName = 'ReadToolVirtualized';
interface ImagePreviewProps {
content: string;
filePath: string;
@@ -969,72 +987,13 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
if (hasStringOutput && outputString.trim()) {
if (part.tool === 'read') {
const parsedReadOutput = parseReadToolOutput(outputString);
const offset = typeof input?.offset === 'number' ? input.offset : 0;
const contentForLanguage = parsedReadOutput.lines.map((line) => line.text).join('\n');
let fallbackLineCursor = offset;
const hasExplicitLineNumbers = parsedReadOutput.lines.some((line) => line.lineNumber !== null);
return renderScrollableBlock(
<div className="typography-code w-full min-w-0 space-y-1">
{parsedReadOutput.lines.map((line, idx) => {
if (line.lineNumber !== null) {
fallbackLineCursor = line.lineNumber;
}
const shouldAssignFallbackLineNumber =
parsedReadOutput.type === 'file'
&& !hasExplicitLineNumbers
&& line.lineNumber === null
&& !line.isInfo;
const effectiveLineNumber = line.lineNumber ?? (shouldAssignFallbackLineNumber
? (fallbackLineCursor += 1)
: null);
const shouldShowLineNumber = !line.isInfo && effectiveLineNumber !== null;
return (
<div key={idx} className={cn('typography-code font-mono flex w-full min-w-0', line.isInfo && 'text-muted-foreground/70 italic')}>
<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)' }}>
{shouldShowLineNumber ? effectiveLineNumber : ''}
</span>
<div className="flex-1 min-w-0">
{line.isInfo ? (
<div className="whitespace-pre-wrap break-words">{line.text}</div>
) : (
<SyntaxHighlighter
style={syntaxTheme}
language={detectLanguageFromOutput(contentForLanguage, part.tool, input as Record<string, unknown>)}
PreTag="div"
wrapLines
wrapLongLines
customStyle={{
margin: 0,
padding: 0,
fontSize: 'inherit',
background: 'transparent',
backgroundColor: 'transparent',
borderRadius: 0,
overflow: 'visible',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
overflowWrap: 'anywhere',
}}
codeTagProps={{
style: {
background: 'transparent',
backgroundColor: 'transparent',
},
}}
>
{line.text}
</SyntaxHighlighter>
)}
</div>
</div>
);
})}
</div>,
{ className: 'p-1' }
);
return <ReadToolVirtualized
outputString={outputString}
input={input}
syntaxTheme={syntaxTheme}
toolName={part.tool}
renderScrollableBlock={renderScrollableBlock}
/>;
}
return renderScrollableBlock(
@@ -0,0 +1,340 @@
/**
* VirtualizedCodeBlock — PERF-007
*
* Replaces per-line <SyntaxHighlighter> with:
* 1. ONE Prism.highlight() call to tokenize all code at once
* 2. @tanstack/react-virtual 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.
*/
import React from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
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';
// ── Threshold: files smaller than this render without virtualization ──
const VIRTUALIZE_THRESHOLD = 80;
const ROW_HEIGHT = 20; // px — matches typography-code line-height
// ── Types ────────────────────────────────────────────────────────────
export interface CodeLine {
text: string;
lineNumber?: number | null;
isInfo?: boolean;
/** For diff lines */
type?: 'context' | 'added' | 'removed';
}
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) */
showLineNumbers?: boolean;
/** Styles per line type (for diffs) */
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 shouldVirtualize = lines.length > VIRTUALIZE_THRESHOLD;
// ── Small file: render directly (no virtualizer overhead) ──
if (!shouldVirtualize) {
return (
<div
className="typography-code font-mono w-full min-w-0 oc-virtualized-prism"
style={{ maxHeight, overflowY: 'auto' }}
>
{prismThemeCss ? <style>{prismThemeCss}</style> : null}
{lines.map((line, idx) => (
<Row
key={idx}
line={line}
language={language}
showLineNumbers={showLineNumbers}
style={lineStyles?.(line)}
/>
))}
</div>
);
}
// ── Large file: virtualise ──
return (
<VirtualizedRows
lines={lines}
language={language}
prismThemeCss={prismThemeCss}
maxHeight={maxHeight}
showLineNumbers={showLineNumbers}
lineStyles={lineStyles}
/>
);
});
VirtualizedCodeBlock.displayName = 'VirtualizedCodeBlock';
// ── Virtualised container (extracted so the hook is top-level) ────────
interface VirtualizedRowsProps {
lines: CodeLine[];
language: string;
prismThemeCss: string;
maxHeight: string;
showLineNumbers: boolean;
lineStyles?: (line: CodeLine) => React.CSSProperties | undefined;
}
const VirtualizedRows: React.FC<VirtualizedRowsProps> = React.memo(({
lines,
language,
prismThemeCss,
maxHeight,
showLineNumbers,
lineStyles,
}) => {
const parentRef = React.useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: lines.length,
getScrollElement: () => parentRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 20, // render 20 extra rows above/below viewport
});
return (
<div
ref={parentRef}
className="typography-code font-mono w-full min-w-0 oc-virtualized-prism"
style={{ maxHeight, overflowY: 'auto' }}
>
{prismThemeCss ? <style>{prismThemeCss}</style> : null}
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const line = lines[vItem.index];
return (
<div
key={vItem.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${vItem.size}px`,
transform: `translateY(${vItem.start}px)`,
}}
>
<Row
line={line}
language={language}
showLineNumbers={showLineNumbers}
style={lineStyles?.(line)}
/>
</div>
);
})}
</div>
</div>
);
});
VirtualizedRows.displayName = 'VirtualizedRows';
// ── Single row ───────────────────────────────────────────────────────
interface RowProps {
line: CodeLine;
language: string;
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]);
return (
<div
className="typography-code font-mono flex w-full min-w-0"
style={style}
>
{showLineNumbers && (
<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.isInfo && line.lineNumber != null ? line.lineNumber : ''}
</span>
)}
<div className="flex-1 min-w-0">
{line.isInfo ? (
<div className="whitespace-pre-wrap break-words text-muted-foreground/70 italic">
{line.text}
</div>
) : (
<div
className="whitespace-pre-wrap break-all"
style={{ overflowWrap: 'anywhere' }}
dangerouslySetInnerHTML={{ __html: html }}
/>
)}
</div>
</div>
);
});
Row.displayName = 'VirtualizedCodeBlock.Row';