fix: avoid rerendering chat history while typing

This commit is contained in:
mesalogo
2026-09-07 16:46:18 +08:00
parent 6a1a671d3b
commit d3947c79dd
4 changed files with 72 additions and 17 deletions
+41
View File
@@ -44,6 +44,19 @@ const speechRecognitionMocks = vi.hoisted(() => ({
startPcmRecording: vi.fn() startPcmRecording: vi.fn()
})) }))
const messageRenderProbe = vi.hoisted(() => vi.fn())
vi.mock('./MarkdownRenderer', async (importOriginal) => {
const original = await importOriginal<typeof import('./MarkdownRenderer')>()
return {
...original,
MarkdownRenderer: (props: { children: string; renderHtml?: boolean }) => {
messageRenderProbe(props.children)
return <original.MarkdownRenderer {...props} />
}
}
})
const lazyRouteMocks = vi.hoisted(() => { const lazyRouteMocks = vi.hoisted(() => {
let pending: Promise<void> | undefined let pending: Promise<void> | undefined
let releasePending: (() => void) | undefined let releasePending: (() => void) | undefined
@@ -2527,6 +2540,28 @@ describe('App', () => {
).not.toBeInTheDocument() ).not.toBeInTheDocument()
}) })
it('measures composer height once per input and resizes after sending', async () => {
render(<App />)
const input = await screen.findByLabelText('向 GoodBuddy 提问')
const measureHeight = vi.fn(() =>
(input as HTMLTextAreaElement).value ? 300 : 0
)
Object.defineProperty(input, 'scrollHeight', {
configurable: true,
get: measureHeight
})
fireEvent.input(input, { target: { value: 'First line\nSecond line' } })
expect(measureHeight).toHaveBeenCalledTimes(1)
expect(input).toHaveStyle({ height: '220px' })
measureHeight.mockClear()
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(input).toHaveValue(''))
expect(measureHeight).toHaveBeenCalledTimes(1)
expect(input).toHaveStyle({ height: '72px' })
})
it('shows a red recording state until microphone capture stops', async () => { it('shows a red recording state until microphone capture stops', async () => {
let resolveRecording!: (value: { let resolveRecording!: (value: {
audio: ArrayBuffer audio: ArrayBuffer
@@ -3325,9 +3360,12 @@ describe('App', () => {
scrollTop: { configurable: true, writable: true, value: 225 } scrollTop: { configurable: true, writable: true, value: 225 }
}) })
fireEvent.scroll(firstChat) fireEvent.scroll(firstChat)
messageRenderProbe.mockClear()
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '第一段会话草稿' } target: { value: '第一段会话草稿' }
}) })
expect(messageRenderProbe).not.toHaveBeenCalled()
expect(firstPane?.querySelectorAll('.message')).toHaveLength(160)
fireEvent.click(screen.getByLabelText('添加附件')) fireEvent.click(screen.getByLabelText('添加附件'))
expect( expect(
await screen.findByText(draftAttachment.name) await screen.findByText(draftAttachment.name)
@@ -3341,9 +3379,12 @@ describe('App', () => {
expect( expect(
screen.queryByText(draftAttachment.name) screen.queryByText(draftAttachment.name)
).not.toBeInTheDocument() ).not.toBeInTheDocument()
messageRenderProbe.mockClear()
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '第二段会话草稿' } target: { value: '第二段会话草稿' }
}) })
expect(messageRenderProbe).not.toHaveBeenCalled()
expect(firstPane?.querySelectorAll('.message')).toHaveLength(160)
fireEvent.click( fireEvent.click(
screen.getByText('第一段长会话').closest('button')! screen.getByText('第一段长会话').closest('button')!
) )
+23 -15
View File
@@ -931,11 +931,23 @@ function ChatHistoryPane({
0, 0,
conversation.messages.length - visibleMessageCount conversation.messages.length - visibleMessageCount
) )
const visibleMessages = conversation.messages.slice( const visibleMessages = useMemo(
visibleMessageStartIndex () => conversation.messages.slice(visibleMessageStartIndex),
[conversation.messages, visibleMessageStartIndex]
) )
const hiddenMessageCount = visibleMessageStartIndex const hiddenMessageCount = visibleMessageStartIndex
const handleArticleRef = useCallback(
(messageId: string, element: HTMLElement | null): void => {
if (element) {
messageArticleRefs.current.set(messageId, element)
} else {
messageArticleRefs.current.delete(messageId)
}
},
[]
)
const saveScrollPosition = useCallback( const saveScrollPosition = useCallback(
(scrollContainer: HTMLElement): boolean => { (scrollContainer: HTMLElement): boolean => {
const distanceFromBottom = const distanceFromBottom =
@@ -1068,7 +1080,7 @@ function ChatHistoryPane({
} }
}, [visibleMessageCount]) }, [visibleMessageCount])
const revealEarlierMessages = (): void => { const revealEarlierMessages = useCallback((): void => {
const scrollContainer = scrollRef.current const scrollContainer = scrollRef.current
if (scrollContainer) { if (scrollContainer) {
prependScrollPositionRef.current = { prependScrollPositionRef.current = {
@@ -1087,7 +1099,12 @@ function ChatHistoryPane({
conversation.id, conversation.id,
visibleMessageCount + messageRenderBatchSize visibleMessageCount + messageRenderBatchSize
) )
} }, [
conversation.id,
conversation.messages,
onVisibleMessageCountChange,
visibleMessageCount
])
const scrollToBottom = (): void => { const scrollToBottom = (): void => {
const scrollContainer = scrollRef.current const scrollContainer = scrollRef.current
@@ -1156,13 +1173,7 @@ function ChatHistoryPane({
locale={locale} locale={locale}
messages={visibleMessages} messages={visibleMessages}
messageStartIndex={visibleMessageStartIndex} messageStartIndex={visibleMessageStartIndex}
onArticleRef={(messageId, element) => { onArticleRef={handleArticleRef}
if (element) {
messageArticleRefs.current.set(messageId, element)
} else {
messageArticleRefs.current.delete(messageId)
}
}}
onCopyMessage={onCopyMessage} onCopyMessage={onCopyMessage}
onDownloadImage={onDownloadImage} onDownloadImage={onDownloadImage}
onOpenCitationContext={onOpenCitationContext} onOpenCitationContext={onOpenCitationContext}
@@ -3073,7 +3084,7 @@ function App(): React.JSX.Element {
assistantTasksRef.current = assistantTasks assistantTasksRef.current = assistantTasks
}, [assistantTasks]) }, [assistantTasks])
useEffect(() => { useLayoutEffect(() => {
resizeComposerTextarea(inputRef.current) resizeComposerTextarea(inputRef.current)
}, [input]) }, [input])
@@ -9679,9 +9690,6 @@ function App(): React.JSX.Element {
title={t('composer.keyboardHint')} title={t('composer.keyboardHint')}
value={input} value={input}
onChange={(event) => setInput(event.target.value)} onChange={(event) => setInput(event.target.value)}
onInput={(event) =>
resizeComposerTextarea(event.currentTarget)
}
onPaste={(event) => { onPaste={(event) => {
const imageItem = Array.from( const imageItem = Array.from(
event.clipboardData.items event.clipboardData.items
+6
View File
@@ -182,6 +182,12 @@ describe('ChatTimeline', () => {
unchangedDetails!.open = true unchangedDetails!.open = true
markdownRenderProbe.mockClear() markdownRenderProbe.mockClear()
const mapMessages = vi.spyOn(messages, 'map')
rerender(<ChatTimeline {...props} messages={messages} />)
expect(mapMessages).not.toHaveBeenCalled()
expect(markdownRenderProbe).not.toHaveBeenCalled()
mapMessages.mockRestore()
const streamedMessages = messages.map((message, index) => const streamedMessages = messages.map((message, index) =>
index === messages.length - 1 index === messages.length - 1
? { ...message, content: `${message.content} delta` } ? { ...message, content: `${message.content} delta` }
+2 -2
View File
@@ -1163,7 +1163,7 @@ type ChatTimelineProps = {
totalMessageCount: number totalMessageCount: number
} }
export function ChatTimeline({ export const ChatTimeline = memo(function ChatTimeline({
artifactById, artifactById,
conversationId, conversationId,
hiddenMessageCount, hiddenMessageCount,
@@ -1230,4 +1230,4 @@ export function ChatTimeline({
})} })}
</div> </div>
) )
} })