Skip to content

Commit dfe0b25

Browse files
authored
🤖 perf: remove legacy syntax highlighting in favor of Shiki (#350)
## Summary Migrates from `react-syntax-highlighter` to Shiki for improved performance and bundle size. Uses DOM-based post-processing for search highlighting to maintain fast, non-blocking rendering. ## Changes **Syntax Highlighting:** - Replaced `react-syntax-highlighter` with Shiki - Theme: `min-dark` (centralized in `SHIKI_THEME` constant) - Size limit: 4KB per diff chunk (larger diffs use plain text) - Lazy language loading (on-demand, race-safe) **Search Highlighting:** - DOM-based post-processing approach (restored from main) - Two-phase rendering: highlight first (fast), then apply search (non-blocking) - Smart caching: parsed DOMs (CRC32-keyed) + regex patterns - Uses `highlightSearchMatches()` in `useMemo` for performance **Markdown Code Blocks:** - Custom async `CodeBlock` component - Progressive enhancement: shows plain code immediately, highlights asynchronously - Reuses shared Shiki highlighter instance - Auto language loading via `codeToHtml()` **CSS:** - Global code block styling in `App.tsx` - Overrides Shiki's hardcoded background with `var(--color-code-bg)` - Search highlights: `mark.search-highlight` and `span.search-highlight` ## Performance Benefits - **Bundle size**: ~100KB savings (removed react-syntax-highlighter) - **Initial render**: Syntax highlighting immediate, search non-blocking - **Cache efficiency**: DOM parsing reused across different searches - **Progressive enhancement**: Code appears immediately, highlights asynchronously ## Test Coverage ✅ All 708 tests passing ✅ Real Shiki integration tests (no mocks) ✅ Typecheck clean (main + renderer) ## Breaking Changes None - maintains full backwards compatibility with existing search functionality. ## Dependencies - Removed: `react-syntax-highlighter`, `@types/react-syntax-highlighter` - Using: `shiki` (already present), `crc-32` (already present) _Generated with `cmux`_
1 parent 725aa41 commit dfe0b25

File tree

12 files changed

+212
-595
lines changed

12 files changed

+212
-595
lines changed

bun.lock

Lines changed: 18 additions & 72 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@
3737
"@ai-sdk/openai": "^2.0.52",
3838
"@emotion/react": "^11.14.0",
3939
"@emotion/styled": "^11.14.1",
40-
"@types/react-syntax-highlighter": "^15.5.13",
4140
"ai": "^5.0.72",
4241
"ai-tokenizer": "^1.0.3",
4342
"chalk": "^5.6.2",
@@ -46,6 +45,7 @@
4645
"diff": "^8.0.2",
4746
"disposablestack": "^1.1.7",
4847
"electron-updater": "^6.6.2",
48+
"escape-html": "^1.0.3",
4949
"jsonc-parser": "^3.3.1",
5050
"lru-cache": "^11.2.2",
5151
"markdown-it": "^14.1.0",
@@ -58,7 +58,6 @@
5858
"react-dnd-html5-backend": "^16.0.1",
5959
"react-dom": "^18.2.0",
6060
"react-markdown": "^10.1.0",
61-
"react-syntax-highlighter": "^15.6.6",
6261
"rehype-katex": "^7.0.1",
6362
"rehype-raw": "^7.0.0",
6463
"rehype-sanitize": "^6.0.0",
@@ -85,6 +84,7 @@
8584
"@testing-library/react": "^16.3.0",
8685
"@types/bun": "^1.2.23",
8786
"@types/diff": "^8.0.0",
87+
"@types/escape-html": "^1.0.4",
8888
"@types/jest": "^30.0.0",
8989
"@types/katex": "^0.16.7",
9090
"@types/markdown-it": "^14.1.2",

scripts/generate_prism_css.ts

Lines changed: 0 additions & 99 deletions
This file was deleted.

src/App.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,31 @@ const globalStyles = css`
101101
}
102102
103103
/* Search term highlighting - global for consistent styling across components */
104-
mark.search-highlight {
104+
/* Applied to <mark> for plain text and <span> for Shiki-highlighted code */
105+
mark.search-highlight,
106+
span.search-highlight {
105107
background: rgba(255, 215, 0, 0.3);
106108
color: inherit;
107109
padding: 0;
108110
border-radius: 2px;
109111
}
112+
113+
/* Override Shiki theme background to use our global color */
114+
.shiki,
115+
.shiki pre {
116+
background: var(--color-code-bg) !important;
117+
}
118+
119+
/* Global styling for markdown code blocks */
120+
pre code {
121+
display: block;
122+
background: var(--color-code-bg);
123+
margin: 1em 0;
124+
border-radius: 4px;
125+
font-size: 12px;
126+
padding: 12px;
127+
overflow: auto;
128+
}
110129
`;
111130

112131
// Styled Components

src/components/Messages/MarkdownComponents.tsx

Lines changed: 73 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import type { ReactNode } from "react";
2-
import React from "react";
3-
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
4-
import { syntaxStyleNoBackgrounds } from "@/styles/syntaxHighlighting";
2+
import React, { useState, useEffect } from "react";
53
import { Mermaid } from "./Mermaid";
4+
import {
5+
getShikiHighlighter,
6+
mapToShikiLang,
7+
SHIKI_THEME,
8+
} from "@/utils/highlighting/shikiHighlighter";
69

710
interface CodeProps {
811
node?: unknown;
@@ -24,6 +27,63 @@ interface SummaryProps {
2427
children?: ReactNode;
2528
}
2629

30+
interface CodeBlockProps {
31+
code: string;
32+
language: string;
33+
}
34+
35+
/**
36+
* CodeBlock component with async Shiki highlighting
37+
* Reuses shared highlighter instance from diff rendering
38+
*/
39+
const CodeBlock: React.FC<CodeBlockProps> = ({ code, language }) => {
40+
const [html, setHtml] = useState<string | null>(null);
41+
42+
useEffect(() => {
43+
let cancelled = false;
44+
45+
async function highlight() {
46+
try {
47+
const highlighter = await getShikiHighlighter();
48+
const shikiLang = mapToShikiLang(language);
49+
50+
// codeToHtml lazy-loads languages automatically
51+
const result = highlighter.codeToHtml(code, {
52+
lang: shikiLang,
53+
theme: SHIKI_THEME,
54+
});
55+
56+
if (!cancelled) {
57+
setHtml(result);
58+
}
59+
} catch (error) {
60+
console.warn(`Failed to highlight code block (${language}):`, error);
61+
if (!cancelled) {
62+
setHtml(null);
63+
}
64+
}
65+
}
66+
67+
void highlight();
68+
69+
return () => {
70+
cancelled = true;
71+
};
72+
}, [code, language]);
73+
74+
// Show loading state or fall back to plain code
75+
if (html === null) {
76+
return (
77+
<pre>
78+
<code>{code}</code>
79+
</pre>
80+
);
81+
}
82+
83+
// Render highlighted HTML
84+
return <div dangerouslySetInnerHTML={{ __html: html }} />;
85+
};
86+
2787
// Custom components for markdown rendering
2888
export const markdownComponents = {
2989
// Pass through pre element - let code component handle the wrapping
@@ -58,48 +118,29 @@ export const markdownComponents = {
58118
</summary>
59119
),
60120

61-
// Custom code block renderer with syntax highlighting
121+
// Custom code block renderer with async Shiki highlighting
62122
code: ({ inline, className, children, node, ...props }: CodeProps) => {
63123
const match = /language-(\w+)/.exec(className ?? "");
64124
const language = match ? match[1] : "";
65125

66-
// Better inline detection: check for multiline content
126+
// Extract text content
67127
const childString =
68128
typeof children === "string" ? children : Array.isArray(children) ? children.join("") : "";
69129
const hasMultipleLines = childString.includes("\n");
70130
const isInline = inline ?? !hasMultipleLines;
71131

72-
if (!isInline && language) {
73-
// Extract text content from children (react-markdown passes string or array of strings)
74-
const code =
75-
typeof children === "string" ? children : Array.isArray(children) ? children.join("") : "";
76-
77-
// Handle mermaid diagrams
78-
if (language === "mermaid") {
79-
return <Mermaid chart={code} />;
80-
}
132+
// Handle mermaid diagrams specially
133+
if (!isInline && language === "mermaid") {
134+
return <Mermaid chart={childString} />;
135+
}
81136

82-
// Code block with language - use syntax highlighter
83-
return (
84-
<SyntaxHighlighter
85-
style={syntaxStyleNoBackgrounds}
86-
language={language}
87-
PreTag="pre"
88-
customStyle={{
89-
background: "rgba(0, 0, 0, 0.3)",
90-
margin: "1em 0",
91-
borderRadius: "4px",
92-
fontSize: "12px",
93-
padding: "12px",
94-
}}
95-
>
96-
{code.replace(/\n$/, "")}
97-
</SyntaxHighlighter>
98-
);
137+
// Code blocks with language - use async Shiki highlighting
138+
if (!isInline && language) {
139+
return <CodeBlock code={childString} language={language} />;
99140
}
100141

142+
// Code blocks without language (global CSS provides styling)
101143
if (!isInline) {
102-
// Code block without language - plain pre/code
103144
return (
104145
<pre>
105146
<code className={className} {...props}>

src/components/RightSidebar/CodeReview/HunkViewer.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,8 @@ import type { DiffHunk } from "@/types/review";
88
import { SelectableDiffRenderer } from "../../shared/DiffRenderer";
99
import {
1010
type SearchHighlightConfig,
11-
highlightSearchMatches,
11+
highlightSearchInText,
1212
} from "@/utils/highlighting/highlightSearchTerms";
13-
import { escapeHtml } from "@/utils/highlighting/highlightDiffChunk";
1413
import { Tooltip, TooltipWrapper } from "../../Tooltip";
1514
import { usePersistedState } from "@/hooks/usePersistedState";
1615
import { getReviewExpandStateKey } from "@/constants/storage";
@@ -212,7 +211,7 @@ export const HunkViewer = React.memo<HunkViewerProps>(
212211
if (!searchConfig) {
213212
return hunk.filePath;
214213
}
215-
return highlightSearchMatches(escapeHtml(hunk.filePath), searchConfig);
214+
return highlightSearchInText(hunk.filePath, searchConfig);
216215
}, [hunk.filePath, searchConfig]);
217216

218217
// Persist manual expand/collapse state across remounts per workspace

src/components/shared/DiffRenderer.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,8 @@ export const LineContent = styled.span<{ type: DiffLineType }>`
102102
}};
103103
104104
/* Ensure Shiki spans don't interfere with diff backgrounds */
105-
span {
105+
/* Exclude search-highlight to allow search marking to show */
106+
span:not(.search-highlight) {
106107
background: transparent !important;
107108
}
108109
`;
@@ -156,7 +157,7 @@ interface DiffRendererProps {
156157

157158
/**
158159
* Hook to pre-process and highlight diff content in chunks
159-
* Runs once when content/language changes
160+
* Runs once when content/language changes (NOT search - that's applied post-process)
160161
*/
161162
function useHighlightedDiff(
162163
content: string,
@@ -176,7 +177,7 @@ function useHighlightedDiff(
176177
// Group into chunks
177178
const diffChunks = groupDiffLines(lines, oldStart, newStart);
178179

179-
// Highlight each chunk
180+
// Highlight each chunk (without search decorations - those are applied later)
180181
const highlighted = await Promise.all(
181182
diffChunks.map((chunk) => highlightDiffChunk(chunk, language))
182183
);

0 commit comments

Comments
 (0)