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:
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
50
src/components/LazyMarkdown.tsx
Normal file
50
src/components/LazyMarkdown.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user