Merge pull request #73024 from NousResearch/bb/desktop-cold-start

perf(desktop): cut renderer cold start by keeping shiki/mermaid off the boot path
This commit is contained in:
brooklyn! 2026-07-27 21:03:08 -05:00 committed by GitHub
commit 3d649f3376
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 157 additions and 31 deletions

View file

@ -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'

View file

@ -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(
() =>

View file

@ -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
}

View 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

View file

@ -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>

View 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} />
}

View file

@ -53,16 +53,46 @@ export default defineConfig(({ command }) => ({
postcss: { plugins: [] }
},
build: {
// Keep desktop packaging stable: Shiki ships many dynamic chunks by
// default, and electron-builder can OOM scanning thousands of files.
// Collapsing to a single chunk is intentional, so the renderer bundle is
// large by design (~22 MB). Raise the warning ceiling above that so the
// cosmetic "chunk larger than 500 kB" nag stays quiet, while still acting
// as a regression alarm if the bundle balloons well past today's size.
// The renderer intentionally ships FEW chunks (not one, not thousands):
// · `codeSplitting: false` (the old setup) inlines every `lazy()` /
// dynamic import into the entry, so heavyweight lazy-only deps
// (mermaid, shiki grammars, katex) are parsed + evaluated on every
// cold start even though nothing rendered them. By the time the
// bundle hit ~28 MB that eval was ~1s of launch on an M-series.
// · Default splitting emits a chunk per shiki grammar/theme — thousands
// of files, which electron-builder OOMs scanning (#38888).
// `advancedChunks` is the middle ground: heavyweight libraries merge into
// a handful of named vendor chunks loaded on first use, app-level dynamic
// imports stay lazy, and the file count stays in the tens.
chunkSizeWarningLimit: 25000,
rolldownOptions: {
output: {
codeSplitting: false
advancedChunks: {
groups: [
// Shared foundations FIRST (first match wins): an unmatched
// module shared by the entry and a heavy chunk gets merged INTO
// the heavy chunk, and the entry then statically imports 19 MB of
// shiki just to reach react/hast utils — putting the heavy chunk
// right back on the boot path.
{ name: 'vendor-react', test: /node_modules[\\/](react|react-dom|scheduler)[\\/]/ },
{
name: 'vendor-md',
test: /node_modules[\\/](property-information|hast-util-[^\\/]+|mdast-util-[^\\/]+|micromark[^\\/]*|unist-util-[^\\/]+|vfile[^\\/]*|unified|stringify-entities|space-separated-tokens|comma-separated-tokens|zwitch|html-void-elements|devlop|style-to-js|style-to-object|clsx)[\\/]/
},
// Shared utility packages the entry ALSO uses — kept out of the
// heavy groups for the same boot-path reason.
{
name: 'vendor-util',
test: /node_modules[\\/](lodash-es|es-toolkit|uuid|dayjs|d3-array|d3-color|d3-force|d3-interpolate|d3-time[^\\/]*|dompurify|stylis)[\\/]/
},
// One chunk per heavyweight, lazy-only library family.
// @streamdown/code lives WITH shiki because it statically imports
// the full shiki bundle.
{ name: 'mermaid', test: /node_modules[\\/](mermaid|cytoscape|dagre|khroma|elkjs|d3|d3-[^\\/]+|@mermaid-js)[\\/]/ },
{ name: 'shiki', test: /node_modules[\\/](shiki|@shikijs|react-shiki|@streamdown[\\/]code|oniguruma-to-es|oniguruma-parser|regex(-[^\\/]+)?)[\\/]/ },
{ name: 'katex', test: /node_modules[\\/]katex[\\/]/ }
]
}
}
}
},