refactor(web): replace react-syntax-highlighter with shiki (#33473)

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Matt Van Horn
2026-04-02 23:40:26 -07:00
committed by GitHub
parent 64ddec0d67
commit a9cf8f6c5d
10 changed files with 291 additions and 201 deletions

View File

@@ -5,6 +5,10 @@ import { Theme } from '@/types/app'
import CodeBlock from '../code-block'
const { mockHighlightCode } = vi.hoisted(() => ({
mockHighlightCode: vi.fn(),
}))
type UseThemeReturn = {
theme: Theme
}
@@ -70,6 +74,10 @@ vi.mock('@/hooks/use-theme', () => ({
default: () => mockUseTheme(),
}))
vi.mock('../shiki-highlight', () => ({
highlightCode: mockHighlightCode,
}))
vi.mock('echarts', () => ({
getInstanceByDom: mockEcharts.getInstanceByDom,
}))
@@ -130,6 +138,11 @@ describe('CodeBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseTheme.mockReturnValue({ theme: Theme.light })
mockHighlightCode.mockImplementation(async ({ code, language }) => (
<pre className="shiki">
<code className={`language-${language}`}>{code}</code>
</pre>
))
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
clientWidthSpy = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(900)
@@ -198,11 +211,13 @@ describe('CodeBlock', () => {
expect(container.querySelector('code')?.textContent).toBe('plain text')
})
it('should render syntax-highlighted output when language is standard', () => {
it('should render syntax-highlighted output when language is standard', async () => {
render(<CodeBlock className="language-javascript">const x = 1;</CodeBlock>)
expect(screen.getByText('JavaScript')).toBeInTheDocument()
expect(document.querySelector('code.language-javascript')?.textContent).toContain('const x = 1;')
await waitFor(() => {
expect(document.querySelector('code.language-javascript')?.textContent).toContain('const x = 1;')
})
})
it('should format unknown language labels with capitalized fallback when language is not in map', () => {
@@ -242,13 +257,26 @@ describe('CodeBlock', () => {
expect(screen.queryByText(/Error rendering SVG/i)).not.toBeInTheDocument()
})
it('should render syntax-highlighted output when language is standard and app theme is dark', () => {
it('should render syntax-highlighted output when language is standard and app theme is dark', async () => {
mockUseTheme.mockReturnValue({ theme: Theme.dark })
render(<CodeBlock className="language-javascript">const y = 2;</CodeBlock>)
expect(screen.getByText('JavaScript')).toBeInTheDocument()
expect(document.querySelector('code.language-javascript')?.textContent).toContain('const y = 2;')
await waitFor(() => {
expect(document.querySelector('code.language-javascript')?.textContent).toContain('const y = 2;')
})
})
it('should fall back to plain code block when shiki highlighting fails', async () => {
mockHighlightCode.mockRejectedValueOnce(new Error('highlight failed'))
render(<CodeBlock className="language-javascript">const z = 3;</CodeBlock>)
await waitFor(() => {
expect(screen.getByText('const z = 3;')).toBeInTheDocument()
})
expect(document.querySelector('code.language-javascript')).toBeNull()
})
})

View File

@@ -1,10 +1,7 @@
import type { JSX } from 'react'
import type { BundledLanguage, BundledTheme } from 'shiki/bundle/web'
import ReactEcharts from 'echarts-for-react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import SyntaxHighlighter from 'react-syntax-highlighter'
import {
atelierHeathDark,
atelierHeathLight,
} from 'react-syntax-highlighter/dist/esm/styles/hljs'
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import ActionButton from '@/app/components/base/action-button'
import CopyIcon from '@/app/components/base/copy-icon'
import MarkdownMusic from '@/app/components/base/markdown-blocks/music'
@@ -14,6 +11,7 @@ import useTheme from '@/hooks/use-theme'
import dynamic from '@/next/dynamic'
import { Theme } from '@/types/app'
import SVGRenderer from '../svg-gallery' // Assumes svg-gallery.tsx is in /base directory
import { highlightCode } from './shiki-highlight'
const Flowchart = dynamic(() => import('@/app/components/base/mermaid'), { ssr: false })
@@ -64,6 +62,61 @@ const getCorrectCapitalizationLanguageName = (language: string) => {
// visit https://reactjs.org/docs/error-decoder.html?invariant=185 for the full message
// or use the non-minified dev environment for full errors and additional helpful warnings.
const ShikiCodeBlock = memo(({ code, language, theme, initial }: { code: string, language: string, theme: BundledTheme, initial?: JSX.Element }) => {
const [nodes, setNodes] = useState(initial)
useLayoutEffect(() => {
let cancelled = false
void highlightCode({
code,
language: language as BundledLanguage,
theme,
}).then((result) => {
if (!cancelled)
setNodes(result)
}).catch((error) => {
console.error('Shiki highlighting failed:', error)
if (!cancelled)
setNodes(undefined)
})
return () => {
cancelled = true
}
}, [code, language, theme])
if (!nodes) {
return (
<pre style={{
paddingLeft: 12,
borderBottomLeftRadius: '10px',
borderBottomRightRadius: '10px',
backgroundColor: 'var(--color-components-input-bg-normal)',
margin: 0,
overflow: 'auto',
}}
>
<code>{code}</code>
</pre>
)
}
return (
<div
style={{
borderBottomLeftRadius: '10px',
borderBottomRightRadius: '10px',
overflow: 'auto',
}}
className="shiki-line-numbers [&_pre]:m-0! [&_pre]:rounded-t-none! [&_pre]:rounded-b-[10px]! [&_pre]:bg-components-input-bg-normal! [&_pre]:py-2!"
>
{nodes}
</div>
)
})
ShikiCodeBlock.displayName = 'ShikiCodeBlock'
// Define ECharts event parameter types
type EChartsEventParams = {
type: string
@@ -416,20 +469,11 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
)
default:
return (
<SyntaxHighlighter
{...props}
style={theme === Theme.light ? atelierHeathLight : atelierHeathDark}
customStyle={{
paddingLeft: 12,
borderBottomLeftRadius: '10px',
borderBottomRightRadius: '10px',
backgroundColor: 'var(--color-components-input-bg-normal)',
}}
language={match?.[1]}
showLineNumbers
>
{content}
</SyntaxHighlighter>
<ShikiCodeBlock
code={content}
language={match?.[1] || 'text'}
theme={isDarkMode ? 'github-dark' : 'github-light'}
/>
)
}
}, [children, language, isSVG, finalChartOption, props, theme, match, chartState, isDarkMode, echartsStyle, echartsOpts, handleChartReady, echartsEvents])
@@ -440,7 +484,7 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
return (
<div className="relative">
<div className="flex h-8 items-center justify-between rounded-t-[10px] border-b border-divider-subtle bg-components-input-bg-normal p-1 pl-3">
<div className="text-text-secondary system-xs-semibold-uppercase">{languageShowName}</div>
<div className="system-xs-semibold-uppercase text-text-secondary">{languageShowName}</div>
<div className="flex items-center gap-1">
{language === 'svg' && <SVGBtn isSVG={isSVG} setIsSVG={setIsSVG} />}
<ActionButton>

View File

@@ -0,0 +1,29 @@
import type { JSX } from 'react'
import type { BundledLanguage, BundledTheme } from 'shiki/bundle/web'
import { toJsxRuntime } from 'hast-util-to-jsx-runtime'
import { Fragment } from 'react'
import { jsx, jsxs } from 'react/jsx-runtime'
import { codeToHast } from 'shiki/bundle/web'
type HighlightCodeOptions = {
code: string
language: BundledLanguage
theme: BundledTheme
}
export const highlightCode = async ({
code,
language,
theme,
}: HighlightCodeOptions): Promise<JSX.Element> => {
const hast = await codeToHast(code, {
lang: language,
theme,
})
return toJsxRuntime(hast, {
Fragment,
jsx,
jsxs,
}) as JSX.Element
}

View File

@@ -61,7 +61,7 @@ vi.mock('@/app/components/datasets/common/image-list', () => ({
),
}))
// Markdown uses next/dynamic and react-syntax-highlighter (ESM)
// Markdown uses next/dynamic and shiki (ESM)
vi.mock('@/app/components/base/markdown', () => ({
Markdown: ({ content, className }: { content: string, className?: string }) => (
<div data-testid="markdown" className={`markdown-body ${className || ''}`}>{content}</div>

View File

@@ -909,4 +909,19 @@
[data-theme='light'] [data-hide-on-theme='light'] {
display: none;
}
/* Shiki code block line numbers */
.shiki-line-numbers code {
counter-reset: line;
}
.shiki-line-numbers .line::before {
counter-increment: line;
content: counter(line);
display: inline-block;
width: 1rem;
margin-right: 0.75rem;
text-align: right;
color: var(--color-text-quaternary);
user-select: none;
}
}

View File

@@ -3142,9 +3142,6 @@
"react/set-state-in-effect": {
"count": 7
},
"tailwindcss/enforce-consistent-class-order": {
"count": 1
},
"ts/no-explicit-any": {
"count": 9
}

View File

@@ -100,6 +100,7 @@
"es-toolkit": "catalog:",
"fast-deep-equal": "catalog:",
"foxact": "catalog:",
"hast-util-to-jsx-runtime": "catalog:",
"html-entities": "catalog:",
"html-to-image": "catalog:",
"i18next": "catalog:",
@@ -134,7 +135,6 @@
"react-papaparse": "catalog:",
"react-pdf-highlighter": "catalog:",
"react-sortablejs": "catalog:",
"react-syntax-highlighter": "catalog:",
"react-textarea-autosize": "catalog:",
"react-window": "catalog:",
"reactflow": "catalog:",
@@ -142,6 +142,7 @@
"remark-directive": "catalog:",
"scheduler": "catalog:",
"sharp": "catalog:",
"shiki": "catalog:",
"sortablejs": "catalog:",
"std-semver": "catalog:",
"streamdown": "catalog:",
@@ -196,7 +197,6 @@
"@types/qs": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/react-syntax-highlighter": "catalog:",
"@types/react-window": "catalog:",
"@types/sortablejs": "catalog:",
"@typescript-eslint/parser": "catalog:",