perf(ai): render streaming markdown as memoized blocks (#22489)
## Rationale `LazyMarkdownRenderer` re-parses and re-renders the **entire accumulated message** through react-markdown on every throttled stream flush (10/s). Render cost grows linearly with message length while streaming, so long answers degrade progressively — this is the dominant jank vector in the chat (verified in the perf audit: no memoization anywhere in the message-render path). ## Why this is the root cause, not a symptom patch The waste is structural: 99% of a streaming message is settled text that cannot change, yet it re-renders because the whole string is one react-markdown call. Splitting at real markdown block boundaries via `marked.lexer` (already a dependency, used in the advanced text editor) and memoizing per block means settled blocks keep their rendered subtree; only the growing tail block re-parses per flush — cost becomes O(tail) instead of O(message). Index keys are stable because streaming is append-only. This is the standard memoized-markdown pattern from the AI SDK ecosystem. Deliberately **not** included: list virtualization for very long threads. The audit's verdict was memoize first, virtualize only if profiling still shows mount cost matters — virtualization changes scroll behavior and deserves its own evaluation. One known tradeoff: markdown reference-style links whose definition lives in a *different* block won't resolve across blocks. Model output uses inline links; the tradeoff is shared by every implementation of this pattern. ## User impact Long streaming answers stop stuttering — keystroke-to-paint stays flat instead of degrading as the answer grows. Most noticeable on tool-heavy turns that produce big final summaries. ## Test plan - [ ] CI green (existing markdown rendering covered by storybook visual tests) - [ ] Manual: stream a long answer with code fences and tables — identical rendering, no per-flush jank in the profiler https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38 --- _Generated by [Claude Code](https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22489?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -11,12 +11,15 @@ import {
|
||||
StyledTableScrollContainer,
|
||||
} from '@/ai/components/LazyMarkdownRendererStyledComponents';
|
||||
import { MarkdownCodeBlock } from '@/ai/components/MarkdownCodeBlock';
|
||||
import { marked } from 'marked';
|
||||
import {
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
lazy,
|
||||
memo,
|
||||
Suspense,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
|
||||
import { getSafeUrl, isDefined } from 'twenty-shared/utils';
|
||||
@@ -203,19 +206,33 @@ const LoadingSkeleton = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const MemoizedMarkdownBlock = memo(
|
||||
({ blockText }: { blockText: string }) => (
|
||||
<MarkdownRenderer
|
||||
TableScrollContainer={StyledTableScrollContainer}
|
||||
ParagraphComponent={StyledParagraph}
|
||||
>
|
||||
{blockText}
|
||||
</MarkdownRenderer>
|
||||
),
|
||||
(previousProps, nextProps) => previousProps.blockText === nextProps.blockText,
|
||||
);
|
||||
|
||||
export const LazyMarkdownRenderer = ({ text }: { text: string }) => {
|
||||
const markdownBlocks = useMemo(
|
||||
() => marked.lexer(text).map((token) => token.raw),
|
||||
[text],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledMarkdownContainer
|
||||
className="markdown-section"
|
||||
data-replay-ignore-mutations="true"
|
||||
>
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<MarkdownRenderer
|
||||
TableScrollContainer={StyledTableScrollContainer}
|
||||
ParagraphComponent={StyledParagraph}
|
||||
>
|
||||
{text}
|
||||
</MarkdownRenderer>
|
||||
{markdownBlocks.map((blockText, blockIndex) => (
|
||||
<MemoizedMarkdownBlock key={blockIndex} blockText={blockText} />
|
||||
))}
|
||||
</Suspense>
|
||||
</StyledMarkdownContainer>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user