mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
147b744abd
commit
659c63e5ba
@ -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()
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user