Skip to content

Commit 48f46a1

Browse files
committed
use debounced save instead of interval
1 parent 82090a2 commit 48f46a1

File tree

2 files changed

+277
-18
lines changed

2 files changed

+277
-18
lines changed
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
// npx vitest webview-ui/src/components/chat/hooks/__tests__/useChatTextDraft.spec.ts
2+
3+
import { renderHook, act } from "@testing-library/react"
4+
import { useChatTextDraft } from "../useChatTextDraft"
5+
import { vi } from "vitest"
6+
7+
describe("useChatTextDraft", () => {
8+
const draftKey = "test-draft-key"
9+
let setInputValue: (v: string) => void
10+
let onSend: () => void
11+
12+
let getItemMock: ReturnType<typeof vi.fn>
13+
let setItemMock: ReturnType<typeof vi.fn>
14+
let removeItemMock: ReturnType<typeof vi.fn>
15+
16+
beforeEach(() => {
17+
setInputValue = vi.fn((_: string) => {})
18+
onSend = vi.fn()
19+
20+
getItemMock = vi.fn()
21+
setItemMock = vi.fn()
22+
removeItemMock = vi.fn()
23+
24+
// @ts-expect-error override readonly
25+
global.localStorage = {
26+
getItem: getItemMock,
27+
setItem: setItemMock,
28+
removeItem: removeItemMock,
29+
}
30+
vi.useFakeTimers()
31+
})
32+
33+
afterEach(() => {
34+
vi.clearAllTimers()
35+
vi.useRealTimers()
36+
vi.restoreAllMocks()
37+
})
38+
39+
describe("Draft restoration on mount", () => {
40+
it("should restore draft from localStorage on mount if inputValue is empty", () => {
41+
getItemMock.mockReturnValue("restored draft")
42+
renderHook(() => useChatTextDraft(draftKey, "", setInputValue, onSend))
43+
expect(getItemMock).toHaveBeenCalledWith(draftKey)
44+
expect(setInputValue).toHaveBeenCalledWith("restored draft")
45+
})
46+
47+
it("should not restore draft if inputValue is not empty", () => {
48+
getItemMock.mockReturnValue("restored draft")
49+
renderHook(() => useChatTextDraft(draftKey, "already typed", setInputValue, onSend))
50+
expect(getItemMock).toHaveBeenCalledWith(draftKey)
51+
expect(setInputValue).not.toHaveBeenCalled()
52+
})
53+
54+
it("should ignore errors from localStorage.getItem", () => {
55+
getItemMock.mockImplementation(() => {
56+
throw new Error("getItem error")
57+
})
58+
expect(() => renderHook(() => useChatTextDraft(draftKey, "", setInputValue, onSend))).not.toThrow()
59+
expect(setInputValue).not.toHaveBeenCalled()
60+
})
61+
})
62+
63+
describe("Auto-save functionality with debounce", () => {
64+
it("should auto-save draft to localStorage after 3 seconds of inactivity if inputValue is non-empty", () => {
65+
renderHook(({ value }) => useChatTextDraft(draftKey, value, setInputValue, onSend), {
66+
initialProps: { value: "hello world" },
67+
})
68+
expect(setItemMock).not.toHaveBeenCalled()
69+
act(() => {
70+
vi.advanceTimersByTime(2999)
71+
})
72+
expect(setItemMock).not.toHaveBeenCalled()
73+
act(() => {
74+
vi.advanceTimersByTime(1)
75+
})
76+
expect(setItemMock).toHaveBeenCalledWith(draftKey, "hello world")
77+
})
78+
79+
it("should reset debounce timer when inputValue changes before debounce delay", () => {
80+
const { rerender } = renderHook(({ value }) => useChatTextDraft(draftKey, value, setInputValue, onSend), {
81+
initialProps: { value: "foo" },
82+
})
83+
act(() => {
84+
vi.advanceTimersByTime(2000)
85+
})
86+
// Should not save before debounce delay
87+
expect(setItemMock).not.toHaveBeenCalled()
88+
rerender({ value: "bar" })
89+
act(() => {
90+
vi.advanceTimersByTime(2999)
91+
})
92+
expect(setItemMock).not.toHaveBeenCalled()
93+
act(() => {
94+
vi.advanceTimersByTime(1)
95+
})
96+
expect(setItemMock).toHaveBeenCalledWith(draftKey, "bar")
97+
})
98+
99+
it("should remove draft from localStorage if inputValue is empty", () => {
100+
renderHook(({ value }) => useChatTextDraft(draftKey, value, setInputValue, onSend), {
101+
initialProps: { value: "" },
102+
})
103+
expect(removeItemMock).toHaveBeenCalledWith(draftKey)
104+
})
105+
106+
it("should ignore errors from localStorage.setItem", () => {
107+
setItemMock.mockImplementation(() => {
108+
throw new Error("setItem error")
109+
})
110+
renderHook(({ value }) => useChatTextDraft(draftKey, value, setInputValue, onSend), {
111+
initialProps: { value: "err" },
112+
})
113+
act(() => {
114+
vi.advanceTimersByTime(5000)
115+
})
116+
expect(setItemMock).toHaveBeenCalled()
117+
})
118+
119+
it("should ignore errors from localStorage.removeItem", () => {
120+
removeItemMock.mockImplementation(() => {
121+
throw new Error("removeItem error")
122+
})
123+
renderHook(({ value }) => useChatTextDraft(draftKey, value, setInputValue, onSend), {
124+
initialProps: { value: "" },
125+
})
126+
expect(removeItemMock).toHaveBeenCalledWith(draftKey)
127+
})
128+
})
129+
130+
describe("Draft clearing on send", () => {
131+
it("should remove draft and call onSend when handleSendAndClearDraft is called", () => {
132+
const { result } = renderHook(() => useChatTextDraft(draftKey, "msg", setInputValue, onSend))
133+
act(() => {
134+
result.current.handleSendAndClearDraft()
135+
})
136+
expect(removeItemMock).toHaveBeenCalledWith(draftKey)
137+
expect(onSend).toHaveBeenCalled()
138+
})
139+
140+
it("should ignore errors from localStorage.removeItem on send", () => {
141+
removeItemMock.mockImplementation(() => {
142+
throw new Error("removeItem error")
143+
})
144+
const { result } = renderHook(() => useChatTextDraft(draftKey, "msg", setInputValue, onSend))
145+
act(() => {
146+
expect(() => result.current.handleSendAndClearDraft()).not.toThrow()
147+
})
148+
expect(onSend).toHaveBeenCalled()
149+
})
150+
})
151+
152+
/**
153+
* @description
154+
* Complex scenario: multiple inputValue changes, ensure debounce timer cleanup and localStorage operations have no side effects.
155+
*/
156+
it("should handle rapid inputValue changes and cleanup debounce timers", () => {
157+
const { rerender } = renderHook(({ value }) => useChatTextDraft(draftKey, value, setInputValue, onSend), {
158+
initialProps: { value: "first" },
159+
})
160+
act(() => {
161+
vi.advanceTimersByTime(2999)
162+
})
163+
expect(setItemMock).not.toHaveBeenCalled()
164+
act(() => {
165+
vi.advanceTimersByTime(1)
166+
})
167+
expect(setItemMock).toHaveBeenCalledWith(draftKey, "first")
168+
rerender({ value: "second" })
169+
act(() => {
170+
vi.advanceTimersByTime(2999)
171+
})
172+
expect(setItemMock).toHaveBeenCalledTimes(1)
173+
act(() => {
174+
vi.advanceTimersByTime(1)
175+
})
176+
expect(setItemMock).toHaveBeenCalledWith(draftKey, "second")
177+
rerender({ value: "" })
178+
expect(removeItemMock).toHaveBeenCalledWith(draftKey)
179+
})
180+
it("should not save and should warn if inputValue exceeds 100KB (ASCII)", () => {
181+
const draftKey = "large-draft-key"
182+
const MAX_DRAFT_BYTES = 102400
183+
// Generate a string larger than 100KB (1 byte per char, simple ASCII)
184+
const largeStr = "a".repeat(MAX_DRAFT_BYTES + 5000)
185+
const setItemMock = vi.fn()
186+
const getItemMock = vi.fn()
187+
const removeItemMock = vi.fn()
188+
// @ts-expect-error override readonly
189+
global.localStorage = {
190+
getItem: getItemMock,
191+
setItem: setItemMock,
192+
removeItem: removeItemMock,
193+
}
194+
const warnMock = vi.spyOn(console, "warn").mockImplementation(() => {})
195+
const setInputValue = vi.fn()
196+
const onSend = vi.fn()
197+
renderHook(() => useChatTextDraft(draftKey, largeStr, setInputValue, onSend))
198+
act(() => {
199+
vi.advanceTimersByTime(3000)
200+
})
201+
expect(setItemMock).not.toHaveBeenCalled()
202+
expect(warnMock).toHaveBeenCalledWith(expect.stringContaining("exceeds 100KB"))
203+
warnMock.mockRestore()
204+
})
205+
206+
it("should not save and should warn if inputValue exceeds 100KB (UTF-8 multi-byte)", () => {
207+
const draftKey = "utf8-draft-key"
208+
// Each emoji is 4 bytes in UTF-8, 汉字 is 3 bytes
209+
const emoji = "😀"
210+
const hanzi = "汉"
211+
// Compose a string: 20,000 emojis (~80KB) + 10,000 汉 (~30KB) + some ASCII
212+
const utf8Str = emoji.repeat(20000) + hanzi.repeat(10000) + "abc"
213+
const setItemMock = vi.fn()
214+
const getItemMock = vi.fn()
215+
const removeItemMock = vi.fn()
216+
// @ts-expect-error override readonly
217+
global.localStorage = {
218+
getItem: getItemMock,
219+
setItem: setItemMock,
220+
removeItem: removeItemMock,
221+
}
222+
const warnMock = vi.spyOn(console, "warn").mockImplementation(() => {})
223+
const setInputValue = vi.fn()
224+
const onSend = vi.fn()
225+
renderHook(() => useChatTextDraft(draftKey, utf8Str, setInputValue, onSend))
226+
act(() => {
227+
vi.advanceTimersByTime(3000)
228+
})
229+
expect(setItemMock).not.toHaveBeenCalled()
230+
expect(warnMock).toHaveBeenCalledWith(expect.stringContaining("exceeds 100KB"))
231+
warnMock.mockRestore()
232+
})
233+
})

webview-ui/src/components/chat/hooks/useChatTextDraft.ts

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { useCallback, useEffect, useRef } from "react"
22

3+
// Debounce delay for saving chat draft (ms)
4+
export const CHAT_DRAFT_SAVE_DEBOUNCE_MS = 3000
5+
36
/**
47
* Hook for chat textarea draft persistence (localStorage).
58
* Handles auto-save, restore on mount, and clear on send.
@@ -14,46 +17,68 @@ export function useChatTextDraft(
1417
setInputValue: (value: string) => void,
1518
onSend: () => void,
1619
) {
17-
const saveDraftTimerRef = useRef<NodeJS.Timeout | null>(null)
18-
1920
// Restore draft on mount
2021
useEffect(() => {
2122
try {
2223
const draft = localStorage.getItem(draftKey)
2324
if (draft && !inputValue) {
2425
setInputValue(draft)
2526
}
26-
} catch (_) {
27-
// ignore
27+
} catch (err) {
28+
// Log localStorage getItem failure for debugging
29+
console.warn(`[useChatTextDraft] Failed to restore draft from localStorage for key "${draftKey}":`, err)
2830
}
2931
// Only run on initial mount
3032
// eslint-disable-next-line react-hooks/exhaustive-deps
3133
}, [])
3234

33-
// Periodically save draft
35+
// Debounced save draft
36+
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null)
37+
3438
useEffect(() => {
35-
if (saveDraftTimerRef.current) {
36-
clearInterval(saveDraftTimerRef.current)
39+
if (debounceTimerRef.current) {
40+
clearTimeout(debounceTimerRef.current)
3741
}
3842
if (inputValue && inputValue.trim()) {
39-
saveDraftTimerRef.current = setInterval(() => {
43+
debounceTimerRef.current = setTimeout(() => {
4044
try {
41-
localStorage.setItem(draftKey, inputValue)
42-
} catch (_) {
43-
// ignore
45+
// Limit draft size to 100KB (102400 bytes)
46+
const MAX_DRAFT_BYTES = 102400
47+
// Fast pre-check: if character count is much greater than max bytes, skip encoding
48+
if (inputValue.length > MAX_DRAFT_BYTES * 2) {
49+
// In UTF-8, Chinese chars are usually 2-3 bytes, English 1 byte
50+
console.warn(
51+
`[useChatTextDraft] Draft for key "${draftKey}" is too long (chars=${inputValue.length}), not saving to localStorage.`,
52+
)
53+
} else {
54+
const encoder = new TextEncoder()
55+
const bytes = encoder.encode(inputValue)
56+
if (bytes.length > MAX_DRAFT_BYTES) {
57+
// Do not save if draft exceeds 100KB
58+
console.warn(
59+
`[useChatTextDraft] Draft for key "${draftKey}" exceeds 100KB, not saving to localStorage.`,
60+
)
61+
} else {
62+
localStorage.setItem(draftKey, inputValue)
63+
}
64+
}
65+
} catch (err) {
66+
// Log localStorage setItem failure for debugging
67+
console.warn(`[useChatTextDraft] Failed to save draft to localStorage for key "${draftKey}":`, err)
4468
}
45-
}, 5000)
69+
}, CHAT_DRAFT_SAVE_DEBOUNCE_MS)
4670
} else {
4771
// Remove draft if no content
4872
try {
4973
localStorage.removeItem(draftKey)
50-
} catch (_) {
51-
// ignore
74+
} catch (err) {
75+
// Log localStorage removeItem failure for debugging
76+
console.warn(`[useChatTextDraft] Failed to remove draft from localStorage for key "${draftKey}":`, err)
5277
}
5378
}
5479
return () => {
55-
if (saveDraftTimerRef.current) {
56-
clearInterval(saveDraftTimerRef.current)
80+
if (debounceTimerRef.current) {
81+
clearTimeout(debounceTimerRef.current)
5782
}
5883
}
5984
}, [inputValue, draftKey])
@@ -62,8 +87,9 @@ export function useChatTextDraft(
6287
const handleSendAndClearDraft = useCallback(() => {
6388
try {
6489
localStorage.removeItem(draftKey)
65-
} catch (_) {
66-
// ignore
90+
} catch (err) {
91+
// Log localStorage removeItem failure for debugging
92+
console.warn(`[useChatTextDraft] Failed to remove draft from localStorage for key "${draftKey}":`, err)
6793
}
6894
onSend()
6995
}, [onSend, draftKey])

0 commit comments

Comments
 (0)