Skip to content

Commit c2cb50b

Browse files
committed
fix: prevent memory leak in useEscapeKey hook by ensuring proper cleanup
1 parent a0dfa2e commit c2cb50b

File tree

2 files changed

+176
-7
lines changed

2 files changed

+176
-7
lines changed
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { renderHook } from "@testing-library/react"
2+
import { vi } from "vitest"
3+
import { useEscapeKey } from "./useEscapeKey"
4+
5+
describe("useEscapeKey", () => {
6+
let mockOnEscape: ReturnType<typeof vi.fn>
7+
8+
beforeEach(() => {
9+
mockOnEscape = vi.fn()
10+
})
11+
12+
afterEach(() => {
13+
vi.clearAllMocks()
14+
})
15+
16+
it("should call onEscape when Escape key is pressed and isOpen is true", () => {
17+
renderHook(() => useEscapeKey(true, mockOnEscape))
18+
19+
const event = new KeyboardEvent("keydown", { key: "Escape" })
20+
window.dispatchEvent(event)
21+
22+
expect(mockOnEscape).toHaveBeenCalledTimes(1)
23+
})
24+
25+
it("should not call onEscape when Escape key is pressed and isOpen is false", () => {
26+
renderHook(() => useEscapeKey(false, mockOnEscape))
27+
28+
const event = new KeyboardEvent("keydown", { key: "Escape" })
29+
window.dispatchEvent(event)
30+
31+
expect(mockOnEscape).not.toHaveBeenCalled()
32+
})
33+
34+
it("should not call onEscape when a different key is pressed", () => {
35+
renderHook(() => useEscapeKey(true, mockOnEscape))
36+
37+
const event = new KeyboardEvent("keydown", { key: "Enter" })
38+
window.dispatchEvent(event)
39+
40+
expect(mockOnEscape).not.toHaveBeenCalled()
41+
})
42+
43+
it("should prevent default when preventDefault option is true", () => {
44+
renderHook(() => useEscapeKey(true, mockOnEscape, { preventDefault: true }))
45+
46+
const event = new KeyboardEvent("keydown", { key: "Escape" })
47+
const preventDefaultSpy = vi.spyOn(event, "preventDefault")
48+
window.dispatchEvent(event)
49+
50+
expect(preventDefaultSpy).toHaveBeenCalled()
51+
})
52+
53+
it("should not prevent default when preventDefault option is false", () => {
54+
renderHook(() => useEscapeKey(true, mockOnEscape, { preventDefault: false }))
55+
56+
const event = new KeyboardEvent("keydown", { key: "Escape" })
57+
const preventDefaultSpy = vi.spyOn(event, "preventDefault")
58+
window.dispatchEvent(event)
59+
60+
expect(preventDefaultSpy).not.toHaveBeenCalled()
61+
})
62+
63+
it("should stop propagation when stopPropagation option is true", () => {
64+
renderHook(() => useEscapeKey(true, mockOnEscape, { stopPropagation: true }))
65+
66+
const event = new KeyboardEvent("keydown", { key: "Escape" })
67+
const stopPropagationSpy = vi.spyOn(event, "stopPropagation")
68+
window.dispatchEvent(event)
69+
70+
expect(stopPropagationSpy).toHaveBeenCalled()
71+
})
72+
73+
it("should not stop propagation when stopPropagation option is false", () => {
74+
renderHook(() => useEscapeKey(true, mockOnEscape, { stopPropagation: false }))
75+
76+
const event = new KeyboardEvent("keydown", { key: "Escape" })
77+
const stopPropagationSpy = vi.spyOn(event, "stopPropagation")
78+
window.dispatchEvent(event)
79+
80+
expect(stopPropagationSpy).not.toHaveBeenCalled()
81+
})
82+
83+
it("should remove event listener on unmount", () => {
84+
const addEventListenerSpy = vi.spyOn(window, "addEventListener")
85+
const removeEventListenerSpy = vi.spyOn(window, "removeEventListener")
86+
87+
const { unmount } = renderHook(() => useEscapeKey(true, mockOnEscape))
88+
89+
expect(addEventListenerSpy).toHaveBeenCalledWith("keydown", expect.any(Function))
90+
91+
unmount()
92+
93+
expect(removeEventListenerSpy).toHaveBeenCalledWith("keydown", expect.any(Function))
94+
})
95+
96+
it("should always add event listener regardless of isOpen state", () => {
97+
const addEventListenerSpy = vi.spyOn(window, "addEventListener")
98+
const removeEventListenerSpy = vi.spyOn(window, "removeEventListener")
99+
100+
// Test with isOpen = false
101+
const { rerender } = renderHook(({ isOpen }) => useEscapeKey(isOpen, mockOnEscape), {
102+
initialProps: { isOpen: false },
103+
})
104+
105+
expect(addEventListenerSpy).toHaveBeenCalledTimes(1)
106+
107+
// Change to isOpen = true
108+
rerender({ isOpen: true })
109+
110+
// The listener is re-added because handleKeyDown changes when isOpen changes
111+
// This is expected behavior - the old listener is removed and a new one is added
112+
expect(addEventListenerSpy).toHaveBeenCalledTimes(2)
113+
expect(removeEventListenerSpy).toHaveBeenCalledTimes(1)
114+
})
115+
116+
it("should handle rapid isOpen state changes without memory leaks", () => {
117+
const addEventListenerSpy = vi.spyOn(window, "addEventListener")
118+
const removeEventListenerSpy = vi.spyOn(window, "removeEventListener")
119+
120+
const { rerender, unmount } = renderHook(({ isOpen }) => useEscapeKey(isOpen, mockOnEscape), {
121+
initialProps: { isOpen: false },
122+
})
123+
124+
// Initial render
125+
expect(addEventListenerSpy).toHaveBeenCalledTimes(1)
126+
127+
// Rapid state changes
128+
rerender({ isOpen: true })
129+
rerender({ isOpen: false })
130+
rerender({ isOpen: true })
131+
132+
// Each rerender causes the effect to re-run because handleKeyDown changes
133+
expect(addEventListenerSpy).toHaveBeenCalledTimes(4)
134+
// Each re-run also removes the previous listener
135+
expect(removeEventListenerSpy).toHaveBeenCalledTimes(3)
136+
137+
// Unmount while isOpen is true
138+
unmount()
139+
140+
// Should properly clean up the final listener
141+
expect(removeEventListenerSpy).toHaveBeenCalledTimes(4)
142+
143+
// Verify that all listeners were properly cleaned up
144+
expect(addEventListenerSpy).toHaveBeenCalledTimes(removeEventListenerSpy.mock.calls.length)
145+
})
146+
147+
it("should update callback when dependencies change", () => {
148+
const { rerender } = renderHook(({ isOpen, onEscape }) => useEscapeKey(isOpen, onEscape), {
149+
initialProps: { isOpen: true, onEscape: mockOnEscape },
150+
})
151+
152+
const event = new KeyboardEvent("keydown", { key: "Escape" })
153+
window.dispatchEvent(event)
154+
155+
expect(mockOnEscape).toHaveBeenCalledTimes(1)
156+
157+
// Change the callback
158+
const newMockOnEscape = vi.fn()
159+
rerender({ isOpen: true, onEscape: newMockOnEscape })
160+
161+
window.dispatchEvent(event)
162+
163+
// Old callback should not be called again
164+
expect(mockOnEscape).toHaveBeenCalledTimes(1)
165+
// New callback should be called
166+
expect(newMockOnEscape).toHaveBeenCalledTimes(1)
167+
})
168+
})

webview-ui/src/hooks/useEscapeKey.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export function useEscapeKey(
1818

1919
const handleKeyDown = useCallback(
2020
(event: KeyboardEvent) => {
21+
// Check isOpen inside the handler to ensure proper cleanup
2122
if (event.key === "Escape" && isOpen) {
2223
if (preventDefault) {
2324
event.preventDefault()
@@ -32,12 +33,12 @@ export function useEscapeKey(
3233
)
3334

3435
useEffect(() => {
35-
if (isOpen) {
36-
// Use window instead of document for consistency with existing patterns
37-
window.addEventListener("keydown", handleKeyDown)
38-
return () => {
39-
window.removeEventListener("keydown", handleKeyDown)
40-
}
36+
// Always add the event listener to ensure proper cleanup on unmount
37+
// The isOpen check is now inside the handler
38+
window.addEventListener("keydown", handleKeyDown)
39+
40+
return () => {
41+
window.removeEventListener("keydown", handleKeyDown)
4142
}
42-
}, [isOpen, handleKeyDown])
43+
}, [handleKeyDown])
4344
}

0 commit comments

Comments
 (0)