Skip to content

Commit e139d7b

Browse files
committed
fix: typing in ChatInput no longer causes unnecessary rerenders
This commit refactors the Chat component by removing unnecessary state management for input handling and streamlining the message submission process. The `setInput` state has been eliminated in favor of directly appending messages. Additionally, a new test suite for the ChatInput component has been introduced, covering rendering, message submission, and input behavior. - Removed `input`, `handleInputChange`, and `handleSubmit` from Chat component. - Updated message submission to use `append` directly. - Added tests for ChatInput to ensure proper functionality and accessibility. This refactor enhances code clarity and maintainability while ensuring the ChatInput component behaves as expected.
1 parent cd5ea20 commit e139d7b

3 files changed

Lines changed: 78 additions & 32 deletions

File tree

src/components/Chat.tsx

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -824,12 +824,9 @@ export function Chat() {
824824

825825
const {
826826
messages,
827-
input,
828-
handleInputChange,
829-
handleSubmit,
830827
isLoading,
831828
setMessages,
832-
setInput,
829+
append,
833830
} = useChat({
834831
body: chatBody,
835832
onError: handleError,
@@ -883,10 +880,9 @@ export function Chat() {
883880
content: prompt,
884881
},
885882
])
886-
887-
handleSubmit(new Event('submit'))
883+
append({ role: 'user', content: prompt })
888884
},
889-
[hasStartedChat, handleSubmit],
885+
[hasStartedChat, append],
890886
)
891887

892888
const handleServerToggle = useCallback((serverId: string) => {
@@ -907,15 +903,15 @@ export function Chat() {
907903
setStreaming(false)
908904
setTimedOut(false)
909905
setMessages([]) // Clear messages completely - initialMessage will be shown via renderEvents logic
910-
setInput('')
906+
// Removed: setInput('')
911907
setFocusTimestamp(Date.now())
912908
setUseCodeInterpreter(false)
913909
setUseWebSearch(false)
914910

915911
textBufferRef.current = ''
916912
lastAssistantIdRef.current = null
917913
pendingStreamEventsRef.current = []
918-
}, [setMessages, setInput])
914+
}, [setMessages])
919915

920916
const handleScrollToBottom = useCallback(() => {
921917
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
@@ -1167,8 +1163,6 @@ export function Chat() {
11671163
<ChatInput
11681164
onSendMessage={handleSendMessage}
11691165
disabled={isLoading || streaming}
1170-
value={input}
1171-
onChange={handleInputChange}
11721166
focusTimestamp={focusTimestamp}
11731167
/>
11741168
</div>

src/components/ChatInput.test.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { describe, it, expect, vi } from 'vitest'
2+
import { render, screen, fireEvent } from '@testing-library/react'
3+
import { ChatInput } from './ChatInput'
4+
5+
const setup = (props = {}) => {
6+
const onSendMessage = vi.fn()
7+
render(<ChatInput onSendMessage={onSendMessage} {...props} />)
8+
9+
const textarea = screen.getByRole('textbox', {
10+
name: 'Ask something...',
11+
}) as HTMLTextAreaElement
12+
const { form } = textarea
13+
14+
return { onSendMessage, textarea, form }
15+
}
16+
17+
describe('ChatInput', () => {
18+
it('renders the input and button', () => {
19+
setup()
20+
expect(screen.getByPlaceholderText(/ask something/i)).toBeInTheDocument()
21+
expect(screen.getByRole('button')).toBeInTheDocument()
22+
})
23+
24+
it('submits message on Enter', () => {
25+
const { onSendMessage, textarea } = setup()
26+
fireEvent.change(textarea, { target: { value: 'Hello world' } })
27+
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter', charCode: 13 })
28+
expect(onSendMessage).toHaveBeenCalledWith('Hello world')
29+
})
30+
31+
it('does not submit on Shift+Enter', () => {
32+
const { onSendMessage, textarea } = setup()
33+
fireEvent.change(textarea, { target: { value: 'Hello\nworld' } })
34+
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter', shiftKey: true })
35+
expect(onSendMessage).not.toHaveBeenCalled()
36+
})
37+
38+
it('disables input and button when disabled', () => {
39+
setup({ disabled: true })
40+
expect(screen.getByPlaceholderText(/ask something/i)).toBeDisabled()
41+
expect(screen.getByRole('button')).toBeDisabled()
42+
})
43+
44+
it('autofocuses', () => {
45+
const { textarea } = setup({ focusTimestamp: Date.now() })
46+
47+
expect(document.activeElement).toBe(textarea)
48+
})
49+
50+
it('clears after submit', () => {
51+
const { textarea } = setup()
52+
53+
fireEvent.change(textarea, { target: { value: 'Clear me' } })
54+
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter', charCode: 13 })
55+
56+
expect(textarea).toHaveValue('')
57+
})
58+
})

src/components/ChatInput.tsx

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,17 @@
1-
import React, { useRef, useEffect } from 'react'
1+
import { useRef, useEffect, type KeyboardEvent } from 'react'
22
import { Button } from './ui/button'
33
import { Send } from 'lucide-react'
44
import { Textarea } from './ui/textarea'
55

66
type ChatInputProps = {
77
onSendMessage: (message: string) => void
88
disabled?: boolean
9-
value: string
10-
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
119
focusTimestamp?: number
1210
}
1311

1412
export function ChatInput({
1513
onSendMessage,
1614
disabled = false,
17-
value,
18-
onChange,
1915
focusTimestamp,
2016
}: ChatInputProps) {
2117
const textareaRef = useRef<HTMLTextAreaElement>(null)
@@ -28,31 +24,28 @@ export function ChatInput({
2824

2925
useEffect(() => {
3026
if (textareaRef.current) {
31-
// Reset height to calculate the right one
3227
textareaRef.current.style.height = 'auto'
33-
// Set new height based on scrollHeight (content)
3428
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 128)}px`
3529
}
36-
}, [value])
30+
}, [textareaRef.current?.value])
3731

38-
const handleSubmit = (e: React.FormEvent) => {
32+
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
3933
e.preventDefault()
34+
const form = e.currentTarget as HTMLFormElement
35+
const textarea = form.elements.namedItem('prompt') as HTMLTextAreaElement
36+
const { value } = textarea
4037

4138
if (value.trim() && !disabled) {
4239
onSendMessage(value)
43-
44-
// Reset the textarea height
45-
if (textareaRef.current) {
46-
textareaRef.current.style.height = 'auto'
47-
}
40+
form.reset()
41+
textarea.style.height = 'auto'
4842
}
4943
}
5044

51-
const handleKeyDown = (e: React.KeyboardEvent) => {
52-
// Submit on Enter without Shift
45+
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
5346
if (e.key === 'Enter' && !e.shiftKey) {
5447
e.preventDefault()
55-
handleSubmit(e)
48+
e.currentTarget.form?.requestSubmit()
5649
}
5750
}
5851

@@ -64,22 +57,23 @@ export function ChatInput({
6457
<div className="relative flex-1 flex items-center">
6558
<Textarea
6659
ref={textareaRef}
67-
value={value}
68-
onChange={onChange}
60+
name="prompt"
6961
onKeyDown={handleKeyDown}
7062
placeholder="Ask something..."
7163
required
7264
disabled={disabled}
7365
rows={1}
66+
aria-label="Ask something..."
7467
className="w-full resize-none rounded-lg border-0 pr-12 text-base placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 disabled:opacity-50 transition-all"
7568
/>
7669
<Button
7770
type="submit"
7871
variant="default"
7972
className="absolute right-2 size-8"
80-
aria-label="Send message"
73+
disabled={disabled}
8174
>
82-
<Send className="size-4" />
75+
<span className="sr-only">Send message</span>
76+
<Send className="size-4" aria-hidden="true" />
8377
</Button>
8478
</div>
8579
</form>

0 commit comments

Comments
 (0)