Skip to content

Commit 295322f

Browse files
authored
feat: DH-21211: Adding window listener hook (#2620)
Adding window listener hook as mentioned in deephaven-ent/iris#3926
1 parent 9364f60 commit 295322f

3 files changed

Lines changed: 323 additions & 0 deletions

File tree

packages/react-hooks/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,4 @@ export * from './useWindowedListData';
3838
export * from './useResizeObserver';
3939
export * from './useMergeRef';
4040
export * from './useUndoRedo';
41+
export { default as useWindowListener } from './useWindowListener';
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
import { renderHook } from '@testing-library/react';
2+
import useWindowListener from './useWindowListener';
3+
4+
const mockCallback = jest.fn();
5+
6+
beforeEach(() => {
7+
jest.clearAllMocks();
8+
jest.spyOn(window, 'addEventListener');
9+
jest.spyOn(window, 'removeEventListener');
10+
});
11+
12+
afterEach(() => {
13+
jest.restoreAllMocks();
14+
});
15+
16+
describe('useWindowListener', () => {
17+
it('should add event listener for a single event', () => {
18+
renderHook(() => useWindowListener('resize', mockCallback));
19+
20+
expect(window.addEventListener).toHaveBeenCalledWith(
21+
'resize',
22+
mockCallback,
23+
undefined
24+
);
25+
});
26+
27+
it('should add event listeners for multiple events', () => {
28+
const events = ['resize', 'scroll', 'focus'] as const;
29+
renderHook(() => useWindowListener(events, mockCallback));
30+
31+
events.forEach(event => {
32+
expect(window.addEventListener).toHaveBeenCalledWith(
33+
event,
34+
mockCallback,
35+
undefined
36+
);
37+
});
38+
});
39+
40+
it('should pass options to addEventListener', () => {
41+
const options = { passive: true, capture: false };
42+
renderHook(() => useWindowListener('scroll', mockCallback, options));
43+
44+
expect(window.addEventListener).toHaveBeenCalledWith(
45+
'scroll',
46+
mockCallback,
47+
options
48+
);
49+
});
50+
51+
it('should pass boolean options to addEventListener', () => {
52+
renderHook(() => useWindowListener('click', mockCallback, true));
53+
54+
expect(window.addEventListener).toHaveBeenCalledWith(
55+
'click',
56+
mockCallback,
57+
true
58+
);
59+
});
60+
61+
it('should pass options to removeEventListener on unmount', () => {
62+
const options = { passive: true, capture: false };
63+
const { unmount } = renderHook(() =>
64+
useWindowListener('scroll', mockCallback, options)
65+
);
66+
67+
jest.clearAllMocks();
68+
unmount();
69+
70+
expect(window.removeEventListener).toHaveBeenCalledWith(
71+
'scroll',
72+
mockCallback,
73+
options
74+
);
75+
});
76+
77+
it('should pass boolean options to removeEventListener on unmount', () => {
78+
const { unmount } = renderHook(() =>
79+
useWindowListener('click', mockCallback, true)
80+
);
81+
82+
jest.clearAllMocks();
83+
unmount();
84+
85+
expect(window.removeEventListener).toHaveBeenCalledWith(
86+
'click',
87+
mockCallback,
88+
true
89+
);
90+
});
91+
92+
it('should remove event listener on unmount for single event', () => {
93+
const { unmount } = renderHook(() =>
94+
useWindowListener('resize', mockCallback)
95+
);
96+
97+
jest.clearAllMocks();
98+
unmount();
99+
100+
expect(window.removeEventListener).toHaveBeenCalledWith(
101+
'resize',
102+
mockCallback,
103+
undefined
104+
);
105+
});
106+
107+
it('should remove event listeners on unmount for multiple events', () => {
108+
const events = ['resize', 'scroll', 'focus'] as const;
109+
const { unmount } = renderHook(() =>
110+
useWindowListener(events, mockCallback)
111+
);
112+
113+
jest.clearAllMocks();
114+
unmount();
115+
116+
events.forEach(event => {
117+
expect(window.removeEventListener).toHaveBeenCalledWith(
118+
event,
119+
mockCallback,
120+
undefined
121+
);
122+
});
123+
});
124+
125+
it('should update listeners when events change', () => {
126+
const { rerender } = renderHook(
127+
({ events }) => useWindowListener(events, mockCallback),
128+
{ initialProps: { events: 'resize' as string } }
129+
);
130+
131+
jest.clearAllMocks();
132+
rerender({ events: 'scroll' as const });
133+
134+
expect(window.removeEventListener).toHaveBeenCalledWith(
135+
'resize',
136+
mockCallback,
137+
undefined
138+
);
139+
expect(window.addEventListener).toHaveBeenCalledWith(
140+
'scroll',
141+
mockCallback,
142+
undefined
143+
);
144+
});
145+
146+
it('should update listeners when events array changes', () => {
147+
const { rerender } = renderHook(
148+
({ events }) => useWindowListener(events, mockCallback),
149+
{ initialProps: { events: ['resize'] as readonly string[] } }
150+
);
151+
152+
jest.clearAllMocks();
153+
rerender({ events: ['scroll', 'focus'] as const });
154+
155+
expect(window.removeEventListener).toHaveBeenCalledWith(
156+
'resize',
157+
mockCallback,
158+
undefined
159+
);
160+
expect(window.addEventListener).toHaveBeenCalledWith(
161+
'scroll',
162+
mockCallback,
163+
undefined
164+
);
165+
expect(window.addEventListener).toHaveBeenCalledWith(
166+
'focus',
167+
mockCallback,
168+
undefined
169+
);
170+
});
171+
172+
it('should update listeners when callback changes', () => {
173+
const newCallback = jest.fn();
174+
const { rerender } = renderHook(
175+
({ callback }) => useWindowListener('resize', callback),
176+
{ initialProps: { callback: mockCallback } }
177+
);
178+
179+
jest.clearAllMocks();
180+
rerender({ callback: newCallback });
181+
182+
expect(window.removeEventListener).toHaveBeenCalledWith(
183+
'resize',
184+
mockCallback,
185+
undefined
186+
);
187+
expect(window.addEventListener).toHaveBeenCalledWith(
188+
'resize',
189+
newCallback,
190+
undefined
191+
);
192+
});
193+
194+
it('should update listeners when options change', () => {
195+
const { rerender } = renderHook(
196+
({ options }) => useWindowListener('scroll', mockCallback, options),
197+
{ initialProps: { options: { passive: true } } }
198+
);
199+
200+
jest.clearAllMocks();
201+
rerender({ options: { passive: false } });
202+
203+
expect(window.removeEventListener).toHaveBeenCalledWith(
204+
'scroll',
205+
mockCallback,
206+
{ passive: true }
207+
);
208+
expect(window.addEventListener).toHaveBeenCalledWith(
209+
'scroll',
210+
mockCallback,
211+
{ passive: false }
212+
);
213+
});
214+
215+
it('should handle empty array of events', () => {
216+
renderHook(() => useWindowListener([], mockCallback));
217+
218+
expect(window.addEventListener).not.toHaveBeenCalled();
219+
});
220+
221+
it('should not re-register listeners if events array reference changes but content is the same', () => {
222+
const events1 = ['resize', 'scroll'];
223+
const events2 = ['resize', 'scroll'];
224+
225+
const { rerender } = renderHook(
226+
({ events }) => useWindowListener(events, mockCallback),
227+
{ initialProps: { events: events1 } }
228+
);
229+
230+
jest.clearAllMocks();
231+
rerender({ events: events2 });
232+
233+
// Should still re-register because array reference changed
234+
expect(window.removeEventListener).toHaveBeenCalledTimes(2);
235+
expect(window.addEventListener).toHaveBeenCalledTimes(2);
236+
});
237+
238+
it('should handle conversion from string to array', () => {
239+
const { rerender } = renderHook(
240+
({ events }) => useWindowListener(events, mockCallback),
241+
{ initialProps: { events: 'resize' as string | readonly string[] } }
242+
);
243+
244+
jest.clearAllMocks();
245+
rerender({ events: ['resize'] });
246+
247+
// Should re-register because events array reference changed
248+
expect(window.removeEventListener).toHaveBeenCalledWith(
249+
'resize',
250+
mockCallback,
251+
undefined
252+
);
253+
expect(window.addEventListener).toHaveBeenCalledWith(
254+
'resize',
255+
mockCallback,
256+
undefined
257+
);
258+
});
259+
260+
it('should call callback when event is dispatched', () => {
261+
renderHook(() => useWindowListener('custom-event', mockCallback));
262+
263+
const event = new Event('custom-event');
264+
window.dispatchEvent(event);
265+
266+
expect(mockCallback).toHaveBeenCalledWith(event);
267+
});
268+
269+
it('should call callback for each registered event', () => {
270+
const events = ['event1', 'event2', 'event3'] as const;
271+
renderHook(() => useWindowListener(events, mockCallback));
272+
273+
events.forEach(eventName => {
274+
const event = new Event(eventName);
275+
window.dispatchEvent(event);
276+
});
277+
278+
expect(mockCallback).toHaveBeenCalledTimes(3);
279+
});
280+
281+
it('should not call callback after unmount', () => {
282+
const { unmount } = renderHook(() =>
283+
useWindowListener('test-event', mockCallback)
284+
);
285+
286+
unmount();
287+
jest.clearAllMocks();
288+
289+
const event = new Event('test-event');
290+
window.dispatchEvent(event);
291+
292+
expect(mockCallback).not.toHaveBeenCalled();
293+
});
294+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { useMemo, useEffect } from 'react';
2+
3+
/**
4+
* Hook to add event listeners to the window object.
5+
* Automatically cleans up on unmount or when dependencies change.
6+
*
7+
* @param events Event or array of events to listen for
8+
* @param callback Event handler function
9+
* @param options Options to pass to addEventListener
10+
*/
11+
export default function useWindowListener(
12+
events: string | readonly string[],
13+
callback: (e: Event) => void,
14+
options?: boolean | AddEventListenerOptions
15+
): void {
16+
const eventsArray = useMemo(
17+
() => (typeof events === 'string' ? [events] : events),
18+
[events]
19+
);
20+
21+
useEffect(() => {
22+
eventsArray.forEach(e => window.addEventListener(e, callback, options));
23+
return () =>
24+
eventsArray.forEach(e =>
25+
window.removeEventListener(e, callback, options)
26+
);
27+
}, [eventsArray, callback, options]);
28+
}

0 commit comments

Comments
 (0)