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[\\/]/ }
+ ]
+ }
}
}
},