diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.tsx index 14fea929387..88f8dc2f5c4 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-file.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx @@ -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' diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index f11247fbfa3..3d9b44bf9ee 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -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( () => diff --git a/apps/desktop/src/components/chat/diff-lines.tsx b/apps/desktop/src/components/chat/diff-lines.tsx index d7155bef288..f934f9f5e2c 100644 --- a/apps/desktop/src/components/chat/diff-lines.tsx +++ b/apps/desktop/src/components/chat/diff-lines.tsx @@ -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) ?? + // 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 ( + }> + + + ) } +const LazySyntaxDiff = React.lazy(() => import('./syntax-diff')) + interface DiffLinesProps extends Omit, 'children'> { text: string } diff --git a/apps/desktop/src/components/chat/shiki-block.tsx b/apps/desktop/src/components/chat/shiki-block.tsx new file mode 100644 index 00000000000..4279d218356 --- /dev/null +++ b/apps/desktop/src/components/chat/shiki-block.tsx @@ -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 diff --git a/apps/desktop/src/components/chat/shiki-highlighter.tsx b/apps/desktop/src/components/chat/shiki-highlighter.tsx index b984e60f3c8..ba59a31da89 100644 --- a/apps/desktop/src/components/chat/shiki-highlighter.tsx +++ b/apps/desktop/src/components/chat/shiki-highlighter.tsx @@ -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> = props => ( + }> + + +) + export function exceedsHighlightBudget(code: string): boolean { if (code.length > MAX_HIGHLIGHT_CHARS) { return true @@ -161,7 +175,7 @@ export const SyntaxHighlighter: FC = ({ {plain ? ( ) : ( - = ({ theme={SHIKI_THEME} > {trimmed} - + )} diff --git a/apps/desktop/src/components/chat/syntax-diff.tsx b/apps/desktop/src/components/chat/syntax-diff.tsx new file mode 100644 index 00000000000..1aa457c44f8 --- /dev/null +++ b/apps/desktop/src/components/chat/syntax-diff.tsx @@ -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) ?? +} diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 4a58133b8bf..c291d6d9718 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -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[\\/]/ } + ] + } } } },