From 3d707dbd901df4f969a773ad369e1fd1fc1e563a Mon Sep 17 00:00:00 2001 From: Nicolas Varrot Date: Fri, 13 Feb 2026 14:26:14 +0000 Subject: [PATCH] perf: lazy-load ReactMarkdown and plugins for faster initial render Move react-markdown, remark-gfm, remark-breaks, and rehype-highlight imports from eager to dynamic via a LazyMarkdown wrapper component. The ~336KB markdown bundle is now loaded on-demand after initial paint instead of blocking the critical rendering path. - Create LazyMarkdown component with Suspense fallback - Pre-load plugins in parallel on module init - Replace all ReactMarkdown usage in ChatMessage with LazyMarkdown --- src/components/ChatMessage.tsx | 18 ++++-------- src/components/LazyMarkdown.tsx | 50 +++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 13 deletions(-) create mode 100644 src/components/LazyMarkdown.tsx diff --git a/src/components/ChatMessage.tsx b/src/components/ChatMessage.tsx index aa8c066..5922efa 100644 --- a/src/components/ChatMessage.tsx +++ b/src/components/ChatMessage.tsx @@ -1,11 +1,7 @@ import { useState, useCallback, useRef, useEffect, useMemo, memo } from 'react'; import { createPortal } from 'react-dom'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import remarkBreaks from 'remark-breaks'; -import rehypeHighlight from 'rehype-highlight'; -import { rehypeHighlightOptions } from '../lib/highlight'; +import { LazyMarkdown } from './LazyMarkdown'; import type { ChatMessage as ChatMessageType, MessageBlock } from '../types'; import { useTheme } from '../hooks/useTheme'; import { ThinkingBlock } from './ThinkingBlock'; @@ -167,17 +163,13 @@ function MarkdownLink(props: React.AnchorHTMLAttributes) { } const markdownComponents = { pre: CodeBlock, img: MarkdownImage, a: MarkdownLink }; -// Hoisted to module scope to avoid creating new array references each render -import type { PluggableList } from 'unified'; -const remarkPlugins: PluggableList = [remarkGfm, remarkBreaks]; -const rehypePlugins: PluggableList = [[rehypeHighlight, rehypeHighlightOptions]]; function renderTextBlocks(blocks: MessageBlock[]) { return getTextBlocks(blocks).map((block, i) => (
- + {autoFormatText((block as Extract).text)} - +
)); } @@ -480,9 +472,9 @@ export const ChatMessageComponent = memo(function ChatMessageComponent({ message {/* User-visible text */} {message.blocks.length > 0 ? renderTextBlocks(message.blocks) : (
- + {autoFormatText(message.content)} - +
)} diff --git a/src/components/LazyMarkdown.tsx b/src/components/LazyMarkdown.tsx new file mode 100644 index 0000000..9c50a63 --- /dev/null +++ b/src/components/LazyMarkdown.tsx @@ -0,0 +1,50 @@ +import { lazy, Suspense } from 'react'; +import type { Components } from 'react-markdown'; +import type { PluggableList } from 'unified'; + +const ReactMarkdown = lazy(() => import('react-markdown')); + +// Pre-load plugins so they're ready when ReactMarkdown renders +let _remarkPlugins: PluggableList | null = null; +let _rehypePlugins: PluggableList | null = null; +let _pluginsReady = false; + +const pluginsPromise = Promise.all([ + import('remark-gfm').then(m => m.default), + import('remark-breaks').then(m => m.default), + import('rehype-highlight').then(m => m.default), + import('../lib/highlight').then(m => m.rehypeHighlightOptions), +]).then(([remarkGfm, remarkBreaks, rehypeHighlight, rehypeHighlightOptions]) => { + _remarkPlugins = [remarkGfm, remarkBreaks]; + _rehypePlugins = [[rehypeHighlight, rehypeHighlightOptions]]; + _pluginsReady = true; +}); + +// Trigger plugin loading immediately +void pluginsPromise; + +interface LazyMarkdownProps { + children: string; + remarkPlugins?: PluggableList; + rehypePlugins?: PluggableList; + components?: Components; +} + +function MarkdownInner({ children, remarkPlugins, rehypePlugins, components }: LazyMarkdownProps) { + const remark = remarkPlugins ?? (_pluginsReady ? _remarkPlugins! : []); + const rehype = rehypePlugins ?? (_pluginsReady ? _rehypePlugins! : []); + + return ( + + {children} + + ); +} + +export function LazyMarkdown(props: LazyMarkdownProps) { + return ( + {props.children.slice(0, 200)}}> + + + ); +}