mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
perf(desktop): load shiki and @streamdown/code on first use, not at boot
The chunk split only helps if nothing on the entry graph statically imports the heavy libs. Three seams did: - react-shiki: now behind one lazy() boundary (shiki-block.tsx is its only static importer; LazyShiki wraps it with a PlainCode fallback). - diff-lines.tsx: useShikiHighlighter hook extracted to a lazily-loaded syntax-diff.tsx (plain DiffBody fallback — same output Shiki's own pre-resolve state showed); codeToTokens becomes a dynamic import that runs only once a highlightable diff is on screen. - @streamdown/code: statically ran createJavaScriptRegexEngine() and built full language registries at module scope on every launch. It now loads via useCodePlugin() on first markdown mount and swaps into the plugin table when it lands; fenced code renders plain until then, identical to the delay fallback users already see during streaming. Interleaved A/B vs main (prod build, warm V8 cache, median of 4, two alternating rounds): FCP 1100->940ms, DOMContentLoaded 618->542ms, nav-to-interactive 1312->1123ms on the quiet round; same direction with larger gaps under load. ~160-190ms off every renderer boot metric.
This commit is contained in:
parent
6fb5d2d89c
commit
1baea21cfb
6 changed files with 120 additions and 24 deletions
|
|
@ -7,7 +7,6 @@ import type {
|
|||
ReactNode
|
||||
} from 'react'
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import ShikiHighlighter from 'react-shiki'
|
||||
import { Streamdown } from 'streamdown'
|
||||
|
||||
import { requestComposerFocus, requestComposerInsertRefs } from '@/app/chat/composer/focus'
|
||||
|
|
@ -18,6 +17,7 @@ import { RichCodeBlock } from '@/components/assistant-ui/embeds'
|
|||
import { CodeEditor } from '@/components/chat/code-editor'
|
||||
import { FileDiffPanel } from '@/components/chat/diff-lines'
|
||||
import { chunkTextLines, useFixedRowWindow } from '@/components/chat/fixed-row-window'
|
||||
import { LazyShiki as ShikiHighlighter } from '@/components/chat/shiki-highlighter'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { translateNow, useI18n } from '@/i18n'
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
type SyntaxHighlighterProps,
|
||||
tailBoundedRemend
|
||||
} from '@assistant-ui/react-streamdown'
|
||||
import { code } from '@streamdown/code'
|
||||
import type { code as streamdownCode } from '@streamdown/code'
|
||||
import { type ComponentProps, memo, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { ExpandableBlock } from '@/components/chat/expandable-block'
|
||||
|
|
@ -52,6 +52,41 @@ import { detectEmbed, extractAlert, MarkdownAlert, RichCodeBlock, UrlEmbed } fro
|
|||
// LLM convention). The default false-setting only accepts `$$...$$`.
|
||||
const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
|
||||
|
||||
// `@streamdown/code` statically imports ALL of shiki (every grammar + theme —
|
||||
// the single largest chunk in the renderer), so it must never sit on the
|
||||
// entry graph. Load it on first markdown mount and swap it into the plugin
|
||||
// table when it lands; until then fenced code renders through the
|
||||
// `SyntaxHighlighter` override's plain path (same output Shiki's own
|
||||
// `delay` fallback shows), so nothing flashes or reflows unexpectedly.
|
||||
type CodePlugin = typeof streamdownCode
|
||||
let codePluginCache: CodePlugin | null = null
|
||||
|
||||
function useCodePlugin(): CodePlugin | null {
|
||||
const [plugin, setPlugin] = useState(codePluginCache)
|
||||
|
||||
useEffect(() => {
|
||||
if (plugin) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
void import('@streamdown/code').then(({ code }) => {
|
||||
codePluginCache = code
|
||||
|
||||
if (!cancelled) {
|
||||
setPlugin(code)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [plugin])
|
||||
|
||||
return plugin
|
||||
}
|
||||
|
||||
// Replaces Streamdown's `parseIncompleteMarkdown` (full-text remend per
|
||||
// flush) with a tail-bounded repair. Must stay module-scope so the prop
|
||||
// identity is stable across renders.
|
||||
|
|
@ -419,8 +454,10 @@ function MarkdownTextSurface({
|
|||
|
||||
// Keep code parsing enabled while streaming so incomplete fenced blocks still
|
||||
// render as code cards. The expensive Shiki pass is deferred by
|
||||
// `SyntaxHighlighter` below when `isStreaming` is true.
|
||||
const plugins = useMemo(() => ({ math: mathPlugin, code }), [])
|
||||
// `SyntaxHighlighter` below when `isStreaming` is true, and the code plugin
|
||||
// itself arrives async (useCodePlugin) so shiki never blocks cold start.
|
||||
const code = useCodePlugin()
|
||||
const plugins = useMemo(() => (code ? { math: mathPlugin, code } : { math: mathPlugin }), [code])
|
||||
|
||||
const components = useMemo(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useShikiHighlighter } from 'react-shiki'
|
||||
import { type BundledLanguage, codeToTokens, type ShikiTransformer, type ThemedToken } from 'shiki'
|
||||
import type { BundledLanguage, ShikiTransformer, ThemedToken } from 'shiki'
|
||||
|
||||
import { chunkLines, type LineChunk, useFixedRowWindow } from '@/components/chat/fixed-row-window'
|
||||
import { exceedsHighlightBudget, SHIKI_THEME } from '@/components/chat/shiki-highlighter'
|
||||
|
|
@ -276,7 +274,8 @@ function parseFullFileDiff(diff: string, fullText: string): DiffLine[] {
|
|||
return out
|
||||
}
|
||||
|
||||
function DiffBody({ lines, syntax }: { lines: DiffLine[]; syntax?: boolean }) {
|
||||
/** Exported for the lazily-loaded SyntaxDiff (syntax-diff.tsx). */
|
||||
export function DiffBody({ lines, syntax }: { lines: DiffLine[]; syntax?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
{lines.map((line, index) => (
|
||||
|
|
@ -383,7 +382,10 @@ function TokenizedDiffBody({
|
|||
let cancelled = false
|
||||
|
||||
setTokens(null)
|
||||
void codeToTokens(code, { lang: language as BundledLanguage, theme })
|
||||
// Dynamic import so the multi-MB shiki chunk stays off the cold-start
|
||||
// path — this effect only runs once a highlightable diff is on screen.
|
||||
void import('shiki')
|
||||
.then(({ codeToTokens }) => codeToTokens(code, { lang: language as BundledLanguage, theme }))
|
||||
.then(result => {
|
||||
if (!cancelled) {
|
||||
setTokens(result.tokens)
|
||||
|
|
@ -446,7 +448,8 @@ function TokenizedDiffBody({
|
|||
|
||||
// Shiki transformer: tag each `.line` with the diff tint for its kind, so the
|
||||
// syntax-highlighted output keeps add/remove backgrounds + the gutter accent.
|
||||
function diffLineTransformer(kinds: DiffKind[]): ShikiTransformer {
|
||||
// Exported for the lazily-loaded SyntaxDiff (syntax-diff.tsx).
|
||||
export function diffLineTransformer(kinds: DiffKind[]): ShikiTransformer {
|
||||
return {
|
||||
line(node, line) {
|
||||
const kind = kinds[line - 1] ?? 'context'
|
||||
|
|
@ -463,18 +466,18 @@ function diffLineTransformer(kinds: DiffKind[]): ShikiTransformer {
|
|||
}
|
||||
|
||||
function SyntaxDiff({ language, lines }: { language: string; lines: DiffLine[] }) {
|
||||
const code = React.useMemo(() => lines.map(line => line.text).join('\n'), [lines])
|
||||
const transformers = React.useMemo(() => [diffLineTransformer(lines.map(line => line.kind))], [lines])
|
||||
|
||||
const highlighted = useShikiHighlighter(code, language, SHIKI_THEME, {
|
||||
defaultColor: 'light-dark()',
|
||||
transformers
|
||||
})
|
||||
|
||||
// Until Shiki resolves, show the plain colored diff so there's no flash.
|
||||
return (highlighted as ReactNode) ?? <DiffBody lines={lines} />
|
||||
// The Shiki hook lives in a lazily-loaded module (syntax-diff.tsx) so the
|
||||
// multi-MB shiki chunk stays off the cold-start path. Until it (and the
|
||||
// highlight itself) resolves, show the plain colored diff — no flash.
|
||||
return (
|
||||
<React.Suspense fallback={<DiffBody lines={lines} />}>
|
||||
<LazySyntaxDiff language={language} lines={lines} />
|
||||
</React.Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
const LazySyntaxDiff = React.lazy(() => import('./syntax-diff'))
|
||||
|
||||
interface DiffLinesProps extends Omit<React.ComponentProps<'pre'>, 'children'> {
|
||||
text: string
|
||||
}
|
||||
|
|
|
|||
15
apps/desktop/src/components/chat/shiki-block.tsx
Normal file
15
apps/desktop/src/components/chat/shiki-block.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
'use client'
|
||||
|
||||
/**
|
||||
* The ONLY static importer of `react-shiki` (and through it the multi-MB
|
||||
* shiki language/theme bundle). Every consumer reaches this module through
|
||||
* `React.lazy(() => import('./shiki-block'))` — see `LazyShiki` in
|
||||
* shiki-highlighter.tsx — so the shiki chunk stays entirely off the
|
||||
* cold-start path and loads on the first highlighted code block instead.
|
||||
*
|
||||
* Do NOT import this module statically from anything the entry graph
|
||||
* reaches, or the chunk moves back into boot.
|
||||
*/
|
||||
import ShikiHighlighter from 'react-shiki'
|
||||
|
||||
export default ShikiHighlighter
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
'use client'
|
||||
|
||||
import type { SyntaxHighlighterProps } from '@assistant-ui/react-streamdown'
|
||||
import { type FC, useMemo } from 'react'
|
||||
import ShikiHighlighter from 'react-shiki'
|
||||
import { type ComponentProps, type FC, lazy, Suspense, useMemo } from 'react'
|
||||
import type ShikiHighlighter from 'react-shiki'
|
||||
|
||||
import {
|
||||
CodeCard,
|
||||
|
|
@ -52,6 +52,20 @@ const MAX_HIGHLIGHT_LINES = 3_000
|
|||
const CHUNK_LINES = 200
|
||||
const EST_LINE_PX = 16
|
||||
|
||||
// react-shiki (and through it the multi-MB shiki grammar/theme bundle) is the
|
||||
// heaviest dependency in the renderer. `shiki-block.tsx` is its only static
|
||||
// importer, so this lazy() is the single seam that keeps shiki out of the
|
||||
// entry chunk — it loads on the first highlighted code block, not at boot.
|
||||
const ShikiBlock = lazy(() => import('./shiki-block'))
|
||||
|
||||
/** Drop-in ShikiHighlighter that suspends on first use and renders the code
|
||||
* as plain preformatted text until the shiki chunk arrives. */
|
||||
export const LazyShiki: FC<ComponentProps<typeof ShikiHighlighter>> = props => (
|
||||
<Suspense fallback={<PlainCode code={String(props.children ?? '')} />}>
|
||||
<ShikiBlock {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
export function exceedsHighlightBudget(code: string): boolean {
|
||||
if (code.length > MAX_HIGHLIGHT_CHARS) {
|
||||
return true
|
||||
|
|
@ -161,7 +175,7 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
|
|||
{plain ? (
|
||||
<PlainCode code={trimmed} />
|
||||
) : (
|
||||
<ShikiHighlighter
|
||||
<LazyShiki
|
||||
addDefaultStyles={false}
|
||||
as="div"
|
||||
colorReplacements={SHIKI_COLOR_REPLACEMENTS}
|
||||
|
|
@ -172,7 +186,7 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
|
|||
theme={SHIKI_THEME}
|
||||
>
|
||||
{trimmed}
|
||||
</ShikiHighlighter>
|
||||
</LazyShiki>
|
||||
)}
|
||||
</Pre>
|
||||
</ExpandableBlock>
|
||||
|
|
|
|||
27
apps/desktop/src/components/chat/syntax-diff.tsx
Normal file
27
apps/desktop/src/components/chat/syntax-diff.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
'use client'
|
||||
|
||||
/**
|
||||
* The Shiki-highlighted compact diff body, split out of diff-lines.tsx so the
|
||||
* `react-shiki` static import (and the multi-MB shiki chunk behind it) loads
|
||||
* lazily on first use instead of on the cold-start path. diff-lines.tsx
|
||||
* reaches this through `React.lazy` with a plain `DiffBody` fallback.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useShikiHighlighter } from 'react-shiki'
|
||||
|
||||
import { DiffBody, type DiffLine, diffLineTransformer } from '@/components/chat/diff-lines'
|
||||
import { SHIKI_THEME } from '@/components/chat/shiki-highlighter'
|
||||
|
||||
export default function SyntaxDiff({ language, lines }: { language: string; lines: DiffLine[] }) {
|
||||
const code = useMemo(() => lines.map(line => line.text).join('\n'), [lines])
|
||||
const transformers = useMemo(() => [diffLineTransformer(lines.map(line => line.kind))], [lines])
|
||||
|
||||
const highlighted = useShikiHighlighter(code, language, SHIKI_THEME, {
|
||||
defaultColor: 'light-dark()',
|
||||
transformers
|
||||
})
|
||||
|
||||
// Until Shiki resolves, show the plain colored diff so there's no flash.
|
||||
return (highlighted as ReactNode) ?? <DiffBody lines={lines} />
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue