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
This commit is contained in:
Nicolas Varrot
2026-02-13 14:26:14 +00:00
parent 388879e14e
commit 3d707dbd90
2 changed files with 55 additions and 13 deletions

View File

@@ -1,11 +1,7 @@
import { useState, useCallback, useRef, useEffect, useMemo, memo } from 'react'; import { useState, useCallback, useRef, useEffect, useMemo, memo } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import ReactMarkdown from 'react-markdown'; import { LazyMarkdown } from './LazyMarkdown';
import remarkGfm from 'remark-gfm';
import remarkBreaks from 'remark-breaks';
import rehypeHighlight from 'rehype-highlight';
import { rehypeHighlightOptions } from '../lib/highlight';
import type { ChatMessage as ChatMessageType, MessageBlock } from '../types'; import type { ChatMessage as ChatMessageType, MessageBlock } from '../types';
import { useTheme } from '../hooks/useTheme'; import { useTheme } from '../hooks/useTheme';
import { ThinkingBlock } from './ThinkingBlock'; import { ThinkingBlock } from './ThinkingBlock';
@@ -167,17 +163,13 @@ function MarkdownLink(props: React.AnchorHTMLAttributes<HTMLAnchorElement>) {
} }
const markdownComponents = { pre: CodeBlock, img: MarkdownImage, a: MarkdownLink }; 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[]) { function renderTextBlocks(blocks: MessageBlock[]) {
return getTextBlocks(blocks).map((block, i) => ( return getTextBlocks(blocks).map((block, i) => (
<div key={`text-${i}`} className="markdown-body"> <div key={`text-${i}`} className="markdown-body">
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={markdownComponents}> <LazyMarkdown components={markdownComponents}>
{autoFormatText((block as Extract<MessageBlock, { type: 'text' }>).text)} {autoFormatText((block as Extract<MessageBlock, { type: 'text' }>).text)}
</ReactMarkdown> </LazyMarkdown>
</div> </div>
)); ));
} }
@@ -480,9 +472,9 @@ export const ChatMessageComponent = memo(function ChatMessageComponent({ message
{/* User-visible text */} {/* User-visible text */}
{message.blocks.length > 0 ? renderTextBlocks(message.blocks) : ( {message.blocks.length > 0 ? renderTextBlocks(message.blocks) : (
<div className="markdown-body"> <div className="markdown-body">
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={markdownComponents}> <LazyMarkdown components={markdownComponents}>
{autoFormatText(message.content)} {autoFormatText(message.content)}
</ReactMarkdown> </LazyMarkdown>
</div> </div>
)} )}

View File

@@ -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 (
<ReactMarkdown remarkPlugins={remark} rehypePlugins={rehype} components={components}>
{children}
</ReactMarkdown>
);
}
export function LazyMarkdown(props: LazyMarkdownProps) {
return (
<Suspense fallback={<span className="opacity-50">{props.children.slice(0, 200)}</span>}>
<MarkdownInner {...props} />
</Suspense>
);
}