From ab4e979352e0f9c4ba30779ea0bded99db53126c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Thu, 2 Jul 2026 21:25:09 +0200 Subject: [PATCH] perf(ai): render streaming markdown as memoized blocks (#22489) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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)_ Review in cubic --- .../ai/components/LazyMarkdownRenderer.tsx | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx b/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx index 168b6ae77c..20c0a4373f 100644 --- a/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx +++ b/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx @@ -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 }) => ( + + {blockText} + + ), + (previousProps, nextProps) => previousProps.blockText === nextProps.blockText, +); + export const LazyMarkdownRenderer = ({ text }: { text: string }) => { + const markdownBlocks = useMemo( + () => marked.lexer(text).map((token) => token.raw), + [text], + ); + return ( }> - - {text} - + {markdownBlocks.map((blockText, blockIndex) => ( + + ))} );