From 6fb5d2d89cf85ec63afb2d712958988a02130b2f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 20:17:38 -0500 Subject: [PATCH 1/2] perf(desktop): split heavy lazy-only libs out of the renderer entry chunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codeSplitting:false inlined every lazy()/dynamic import into the entry, so the whole 28.5 MB bundle — including 19 MB of shiki grammars and 3.1 MB of mermaid that nothing rendered — was parsed and evaluated on every cold start. The original single-chunk rationale (#38888: electron-builder OOMs scanning shiki's thousands of default chunks) doesn't require ONE chunk, just few: rolldown advancedChunks groups keep the file count at ~180. Ordering matters and is the subtle part: shared foundations (react, hast/ mdast utils, lodash-es/d3 commons) must match BEFORE the heavy groups, because rolldown merges an unmatched shared module INTO the heavy chunk that uses it — and then the entry statically imports 19 MB of shiki just to reach react, putting the whole chunk back on the boot path. Statically-reachable boot graph: 26.9 MB -> 7.7 MB. --- apps/desktop/vite.config.ts | 44 +++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 7 deletions(-) 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[\\/]/ } + ] + } } } }, From 1baea21cfb839d7aebe6d04122c9866fb647d969 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 20:17:38 -0500 Subject: [PATCH 2/2] perf(desktop): load shiki and @streamdown/code on first use, not at boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/app/chat/right-rail/preview-file.tsx | 2 +- .../components/assistant-ui/markdown-text.tsx | 43 +++++++++++++++++-- .../src/components/chat/diff-lines.tsx | 35 ++++++++------- .../src/components/chat/shiki-block.tsx | 15 +++++++ .../src/components/chat/shiki-highlighter.tsx | 22 ++++++++-- .../src/components/chat/syntax-diff.tsx | 27 ++++++++++++ 6 files changed, 120 insertions(+), 24 deletions(-) create mode 100644 apps/desktop/src/components/chat/shiki-block.tsx create mode 100644 apps/desktop/src/components/chat/syntax-diff.tsx 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) ?? +}