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()
}))
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(() => {
let pending: Promise<void> | undefined
let releasePending: (() => void) | undefined
@@ -2527,6 +2540,28 @@ describe('App', () => {
).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 () => {
let resolveRecording!: (value: {
audio: ArrayBuffer
@@ -3325,9 +3360,12 @@ describe('App', () => {
scrollTop: { configurable: true, writable: true, value: 225 }
})
fireEvent.scroll(firstChat)
messageRenderProbe.mockClear()
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '第一段会话草稿' }
})
expect(messageRenderProbe).not.toHaveBeenCalled()
expect(firstPane?.querySelectorAll('.message')).toHaveLength(160)
fireEvent.click(screen.getByLabelText('添加附件'))
expect(
await screen.findByText(draftAttachment.name)
@@ -3341,9 +3379,12 @@ describe('App', () => {
expect(
screen.queryByText(draftAttachment.name)
).not.toBeInTheDocument()
messageRenderProbe.mockClear()
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '第二段会话草稿' }
})
expect(messageRenderProbe).not.toHaveBeenCalled()
expect(firstPane?.querySelectorAll('.message')).toHaveLength(160)
fireEvent.click(
screen.getByText('第一段长会话').closest('button')!
)
+23 -15
View File
@@ -931,11 +931,23 @@ function ChatHistoryPane({
0,
conversation.messages.length - visibleMessageCount
)
const visibleMessages = conversation.messages.slice(
visibleMessageStartIndex
const visibleMessages = useMemo(
() => conversation.messages.slice(visibleMessageStartIndex),
[conversation.messages, 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(
(scrollContainer: HTMLElement): boolean => {
const distanceFromBottom =
@@ -1068,7 +1080,7 @@ function ChatHistoryPane({
}
}, [visibleMessageCount])
const revealEarlierMessages = (): void => {
const revealEarlierMessages = useCallback((): void => {
const scrollContainer = scrollRef.current
if (scrollContainer) {
prependScrollPositionRef.current = {
@@ -1087,7 +1099,12 @@ function ChatHistoryPane({
conversation.id,
visibleMessageCount + messageRenderBatchSize
)
}
}, [
conversation.id,
conversation.messages,
onVisibleMessageCountChange,
visibleMessageCount
])
const scrollToBottom = (): void => {
const scrollContainer = scrollRef.current
@@ -1156,13 +1173,7 @@ function ChatHistoryPane({
locale={locale}
messages={visibleMessages}
messageStartIndex={visibleMessageStartIndex}
onArticleRef={(messageId, element) => {
if (element) {
messageArticleRefs.current.set(messageId, element)
} else {
messageArticleRefs.current.delete(messageId)
}
}}
onArticleRef={handleArticleRef}
onCopyMessage={onCopyMessage}
onDownloadImage={onDownloadImage}
onOpenCitationContext={onOpenCitationContext}
@@ -3073,7 +3084,7 @@ function App(): React.JSX.Element {
assistantTasksRef.current = assistantTasks
}, [assistantTasks])
useEffect(() => {
useLayoutEffect(() => {
resizeComposerTextarea(inputRef.current)
}, [input])
@@ -9679,9 +9690,6 @@ function App(): React.JSX.Element {
title={t('composer.keyboardHint')}
value={input}
onChange={(event) => setInput(event.target.value)}
onInput={(event) =>
resizeComposerTextarea(event.currentTarget)
}
onPaste={(event) => {
const imageItem = Array.from(
event.clipboardData.items
+6
View File
@@ -182,6 +182,12 @@ describe('ChatTimeline', () => {
unchangedDetails!.open = true
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) =>
index === messages.length - 1
? { ...message, content: `${message.content} delta` }
+2 -2
View File
@@ -1163,7 +1163,7 @@ type ChatTimelineProps = {
totalMessageCount: number
}
export function ChatTimeline({
export const ChatTimeline = memo(function ChatTimeline({
artifactById,
conversationId,
hiddenMessageCount,
@@ -1230,4 +1230,4 @@ export function ChatTimeline({
})}
</div>
)
}
})