From 1baea21cfb839d7aebe6d04122c9866fb647d969 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 20:17:38 -0500 Subject: [PATCH] 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) ?? +}