perf(desktop): memoize chat row subtree to stop window-slide re-renders

When the virtualization window slid during scroll, every visible row reconciled
from scratch because the heavy chat-row subtree had no memo barriers, and
because renderTranscriptItem rebuilt fresh branchAction / toolResult object
literals each render that broke MessageBlock's existing memo.

Wraps AssistantMessage, UserMessage, ToolCallBlock, ToolCallGroup, ToolResultBlock,
and MarkdownRenderer in React.memo, and hoists the per-message branchAction and
toolResult lookups into useMemo'd Maps keyed by message id. Window slides now
keep referentially-stable props for unchanged rows, so reconciliation skips them
and only newly entering rows pay full render cost.

Tested: 722/722 desktop vitest suites pass

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-05-23 23:41:25 +08:00
parent 147b744abd
commit 659c63e5ba
7 changed files with 47 additions and 31 deletions

View File

@ -1,3 +1,4 @@
import { memo } from 'react'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import { MessageActionBar, type MessageBranchAction } from './MessageActionBar'
import { InlineImageGallery } from './InlineImageGallery'
@ -8,7 +9,7 @@ type Props = {
branchAction?: MessageBranchAction
}
export function AssistantMessage({ content, isStreaming, branchAction }: Props) {
export const AssistantMessage = memo(function AssistantMessage({ content, isStreaming, branchAction }: Props) {
if (!content.trim()) return null
const documentLayout = shouldUseDocumentLayout(content)
@ -43,7 +44,7 @@ export function AssistantMessage({ content, isStreaming, branchAction }: Props)
</div>
</div>
)
}
})
function shouldUseDocumentLayout(content: string) {
const normalized = content.trim()

View File

@ -1638,6 +1638,33 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
}
}, [addToast, branchSession, branchingMessageId, resolvedSessionId, t])
// Pre-compute per-message branchAction + toolResult lookups so MessageBlock's
// memo barrier is not broken by inline object literals on every render.
const branchActionByMessageId = useMemo(() => {
if (branchableMessageTargets.size === 0) {
return new Map<string, { label: string; loading: boolean; onBranch: () => void }>()
}
const result = new Map<string, { label: string; loading: boolean; onBranch: () => void }>()
const label = t('chat.branchFromHere')
for (const [uiMessageId, target] of branchableMessageTargets) {
result.set(uiMessageId, {
label,
loading: branchingMessageId === target.uiMessageId,
onBranch: () => { void handleBranchMessage(target) },
})
}
return result
}, [branchableMessageTargets, branchingMessageId, handleBranchMessage, t])
const toolResultByToolUseId = useMemo(() => {
if (toolResultMap.size === 0) return new Map<string, { content: unknown; isError: boolean }>()
const result = new Map<string, { content: unknown; isError: boolean }>()
for (const [toolUseId, toolResult] of toolResultMap) {
result.set(toolUseId, { content: toolResult.content, isError: toolResult.isError })
}
return result
}, [toolResultMap])
const renderTranscriptItem = (item: RenderItem, index: number) => {
const cardsForItem = turnCardsByRenderIndex.get(index) ?? []
@ -1662,23 +1689,10 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
agentTaskNotifications={agentTaskNotifications}
toolResult={
item.message.type === 'tool_use'
? (() => {
const result = toolResultMap.get(item.message.toolUseId)
return result ? { content: result.content, isError: result.isError } : null
})()
? toolResultByToolUseId.get(item.message.toolUseId) ?? null
: null
}
branchAction={
(() => {
const branchTarget = branchableMessageTargets.get(item.message.id)
if (!branchTarget) return undefined
return {
label: t('chat.branchFromHere'),
loading: branchingMessageId === branchTarget.uiMessageId,
onBranch: () => { void handleBranchMessage(branchTarget) },
}
})()
}
branchAction={branchActionByMessageId.get(item.message.id)}
/>
)}

View File

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'
import { memo, useMemo, useState } from 'react'
import { LoaderCircle } from 'lucide-react'
import { CodeViewer } from './CodeViewer'
import { DiffViewer } from './DiffViewer'
@ -36,7 +36,7 @@ const TOOL_ICONS: Record<string, string> = {
const WRITER_PREVIEW_MAX_LINES = 120
const WRITER_PREVIEW_MAX_CHARS = 30000
export function ToolCallBlock({ toolName, input, result, compact = false, isPending = false, partialInput }: Props) {
export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, result, compact = false, isPending = false, partialInput }: Props) {
const [expanded, setExpanded] = useState(false)
const t = useTranslation()
const obj = input && typeof input === 'object' ? (input as Record<string, unknown>) : {}
@ -122,7 +122,7 @@ export function ToolCallBlock({ toolName, input, result, compact = false, isPend
)}
</div>
)
}
})
function renderPreview(
toolName: string,

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { memo, useEffect, useState } from 'react'
import { BookMarked, ChevronDown, ChevronRight, Settings } from 'lucide-react'
import { ToolCallBlock } from './ToolCallBlock'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
@ -108,7 +108,7 @@ function hasUnresolvedToolCalls(
)
}
export function ToolCallGroup({
export const ToolCallGroup = memo(function ToolCallGroup({
toolCalls,
resultMap,
childToolCallsByParent,
@ -159,7 +159,7 @@ export function ToolCallGroup({
isStreaming={isStreaming}
/>
)
}
})
function ToolCallGroupContent({
toolCalls,

View File

@ -1,5 +1,5 @@
import { CodeViewer } from './CodeViewer'
import { useState } from 'react'
import { memo, useState } from 'react'
import { useTranslation } from '../../i18n'
import { InlineImageGallery } from './InlineImageGallery'
@ -15,7 +15,7 @@ type Props = {
* inline within ToolCallBlock (i.e., when the tool_use and tool_result
* are NOT grouped together by MessageList).
*/
export function ToolResultBlock({ content, isError, toolName, standalone = true }: Props) {
export const ToolResultBlock = memo(function ToolResultBlock({ content, isError, toolName, standalone = true }: Props) {
const [expanded, setExpanded] = useState(false)
const t = useTranslation()
@ -90,7 +90,7 @@ export function ToolResultBlock({ content, isError, toolName, standalone = true
)}
</div>
)
}
})
function extractText(content: unknown): string {
if (typeof content === 'string') return content

View File

@ -1,3 +1,4 @@
import { memo } from 'react'
import type { UIAttachment } from '../../types/chat'
import { AttachmentGallery } from './AttachmentGallery'
import { MessageActionBar, type MessageBranchAction } from './MessageActionBar'
@ -8,7 +9,7 @@ type Props = {
branchAction?: MessageBranchAction
}
export function UserMessage({ content, attachments, branchAction }: Props) {
export const UserMessage = memo(function UserMessage({ content, attachments, branchAction }: Props) {
const hasText = content.trim().length > 0
return (
@ -45,4 +46,4 @@ export function UserMessage({ content, attachments, branchAction }: Props) {
</div>
</div>
)
}
})

View File

@ -1,4 +1,4 @@
import { useMemo, useCallback } from 'react'
import { memo, useMemo, useCallback } from 'react'
import type { MouseEvent as ReactMouseEvent } from 'react'
import DOMPurify from 'dompurify'
import katex from 'katex'
@ -396,7 +396,7 @@ function getProseClasses(variant: 'default' | 'document' | 'compact', className?
.join(' ')
}
export function MarkdownRenderer({ content, variant = 'default', className, cache = true, onLinkClick }: Props) {
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, variant = 'default', className, cache = true, onLinkClick }: Props) {
const { html, codeBlocks, mathBlocks } = useMemo(
() => cache ? getCachedMarkdownParse(content) : parseMarkdown(content),
[cache, content],
@ -490,4 +490,4 @@ export function MarkdownRenderer({ content, variant = 'default', className, cach
)}
</div>
)
}
})