airyhooks 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -8
- package/dist/commands/add.js +46 -9
- package/dist/commands/add.js.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/utils/config.js +2 -0
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/get-file-extension.js +3 -0
- package/dist/utils/get-file-extension.js.map +1 -1
- package/dist/utils/get-hook-template.js +9 -1426
- package/dist/utils/get-hook-template.js.map +1 -1
- package/dist/utils/hook-templates.js +4521 -0
- package/dist/utils/hook-templates.js.map +1 -0
- package/dist/utils/registry.js +8 -0
- package/dist/utils/registry.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,4521 @@
|
|
|
1
|
+
// AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY
|
|
2
|
+
// Generated by: pnpm --filter @airyhooks/hooks build:templates
|
|
3
|
+
// Source: packages/hooks/src/*/use*.ts
|
|
4
|
+
export const templates = {
|
|
5
|
+
useBoolean: `import { useCallback, useState } from "react";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Boolean state with setTrue, setFalse, and toggle handlers.
|
|
9
|
+
*
|
|
10
|
+
* @param initialValue - Initial boolean value (default: false)
|
|
11
|
+
* @returns Tuple of [value, { setTrue, setFalse, toggle }]
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* const [isEnabled, handlers] = useBoolean(false);
|
|
15
|
+
*
|
|
16
|
+
* return (
|
|
17
|
+
* <>
|
|
18
|
+
* <button onClick={handlers.toggle}>Toggle</button>
|
|
19
|
+
* <button onClick={handlers.setTrue}>Enable</button>
|
|
20
|
+
* <button onClick={handlers.setFalse}>Disable</button>
|
|
21
|
+
* </>
|
|
22
|
+
* );
|
|
23
|
+
*/
|
|
24
|
+
export function useBoolean(initialValue = false): [
|
|
25
|
+
boolean,
|
|
26
|
+
{
|
|
27
|
+
setFalse: () => void;
|
|
28
|
+
setTrue: () => void;
|
|
29
|
+
toggle: () => void;
|
|
30
|
+
},
|
|
31
|
+
] {
|
|
32
|
+
const [value, setValue] = useState(initialValue);
|
|
33
|
+
|
|
34
|
+
const toggle = useCallback(() => {
|
|
35
|
+
setValue((prev) => !prev);
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
const setTrue = useCallback(() => {
|
|
39
|
+
setValue(true);
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
const setFalse = useCallback(() => {
|
|
43
|
+
setValue(false);
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
return [
|
|
47
|
+
value,
|
|
48
|
+
{
|
|
49
|
+
setFalse,
|
|
50
|
+
setTrue,
|
|
51
|
+
toggle,
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
`, useBoolean_test: `import { act, renderHook } from "@testing-library/react";
|
|
56
|
+
import { describe, expect, it } from "vitest";
|
|
57
|
+
|
|
58
|
+
import { useBoolean } from "./useBoolean.js";
|
|
59
|
+
|
|
60
|
+
describe("useBoolean", () => {
|
|
61
|
+
it("should initialize with false by default", () => {
|
|
62
|
+
const { result } = renderHook(() => useBoolean());
|
|
63
|
+
expect(result.current[0]).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("should initialize with provided value", () => {
|
|
67
|
+
const { result } = renderHook(() => useBoolean(true));
|
|
68
|
+
expect(result.current[0]).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("should have setTrue handler", () => {
|
|
72
|
+
const { result } = renderHook(() => useBoolean());
|
|
73
|
+
|
|
74
|
+
act(() => {
|
|
75
|
+
result.current[1].setTrue();
|
|
76
|
+
});
|
|
77
|
+
expect(result.current[0]).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("should have setFalse handler", () => {
|
|
81
|
+
const { result } = renderHook(() => useBoolean(true));
|
|
82
|
+
|
|
83
|
+
act(() => {
|
|
84
|
+
result.current[1].setFalse();
|
|
85
|
+
});
|
|
86
|
+
expect(result.current[0]).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("should have toggle handler", () => {
|
|
90
|
+
const { result } = renderHook(() => useBoolean());
|
|
91
|
+
|
|
92
|
+
act(() => {
|
|
93
|
+
result.current[1].toggle();
|
|
94
|
+
});
|
|
95
|
+
expect(result.current[0]).toBe(true);
|
|
96
|
+
|
|
97
|
+
act(() => {
|
|
98
|
+
result.current[1].toggle();
|
|
99
|
+
});
|
|
100
|
+
expect(result.current[0]).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("should maintain stable handler references", () => {
|
|
104
|
+
const { rerender, result } = renderHook(() => useBoolean());
|
|
105
|
+
|
|
106
|
+
const handlers1 = result.current[1];
|
|
107
|
+
rerender();
|
|
108
|
+
const handlers2 = result.current[1];
|
|
109
|
+
|
|
110
|
+
expect(handlers1.toggle).toBe(handlers2.toggle);
|
|
111
|
+
expect(handlers1.setTrue).toBe(handlers2.setTrue);
|
|
112
|
+
expect(handlers1.setFalse).toBe(handlers2.setFalse);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
`,
|
|
116
|
+
useClickAway: `import { useEffect } from "react";
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Detects clicks outside of a target element.
|
|
120
|
+
*
|
|
121
|
+
* @param ref - React ref to the target element
|
|
122
|
+
* @param callback - Function to call when click outside is detected
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* const ref = useRef<HTMLDivElement>(null);
|
|
126
|
+
*
|
|
127
|
+
* useClickAway(ref, () => {
|
|
128
|
+
* setIsOpen(false);
|
|
129
|
+
* });
|
|
130
|
+
*
|
|
131
|
+
* return <div ref={ref}>Content</div>;
|
|
132
|
+
*/
|
|
133
|
+
export function useClickAway<T extends HTMLElement>(
|
|
134
|
+
ref: React.RefObject<null | T>,
|
|
135
|
+
callback: () => void,
|
|
136
|
+
): void {
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
139
|
+
const element = ref.current;
|
|
140
|
+
if (element && !element.contains(event.target as Node)) {
|
|
141
|
+
callback();
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
146
|
+
return () => {
|
|
147
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
148
|
+
};
|
|
149
|
+
}, [ref, callback]);
|
|
150
|
+
}
|
|
151
|
+
`, useClickAway_test: `import { cleanup, fireEvent, render } from "@testing-library/react";
|
|
152
|
+
import React from "react";
|
|
153
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
154
|
+
|
|
155
|
+
import { useClickAway } from "./useClickAway.js";
|
|
156
|
+
|
|
157
|
+
describe("useClickAway", () => {
|
|
158
|
+
afterEach(() => {
|
|
159
|
+
cleanup();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("should call callback when clicking outside element", () => {
|
|
163
|
+
const callback = vi.fn();
|
|
164
|
+
const Component = () => {
|
|
165
|
+
const ref = React.useRef<HTMLDivElement>(null);
|
|
166
|
+
useClickAway(ref, callback);
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<div>
|
|
170
|
+
<div data-testid="target" ref={ref}>
|
|
171
|
+
Target
|
|
172
|
+
</div>
|
|
173
|
+
<div data-testid="outside">Outside</div>
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const { getByTestId } = render(<Component />);
|
|
179
|
+
|
|
180
|
+
fireEvent.mouseDown(getByTestId("outside"));
|
|
181
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("should not call callback when clicking inside element", () => {
|
|
185
|
+
const callback = vi.fn();
|
|
186
|
+
const Component = () => {
|
|
187
|
+
const ref = React.useRef<HTMLDivElement>(null);
|
|
188
|
+
useClickAway(ref, callback);
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div data-testid="target" ref={ref}>
|
|
192
|
+
Target
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const { getByTestId } = render(<Component />);
|
|
198
|
+
|
|
199
|
+
fireEvent.mouseDown(getByTestId("target"));
|
|
200
|
+
expect(callback).not.toHaveBeenCalled();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("should cleanup listener on unmount", () => {
|
|
204
|
+
const callback = vi.fn();
|
|
205
|
+
const removeSpy = vi.spyOn(document, "removeEventListener");
|
|
206
|
+
|
|
207
|
+
const Component = () => {
|
|
208
|
+
const ref = React.useRef<HTMLDivElement>(null);
|
|
209
|
+
useClickAway(ref, callback);
|
|
210
|
+
return <div ref={ref} />;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const { unmount } = render(<Component />);
|
|
214
|
+
unmount();
|
|
215
|
+
|
|
216
|
+
expect(removeSpy).toHaveBeenCalledWith("mousedown", expect.any(Function));
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
`,
|
|
220
|
+
useCopyToClipboard: `import { useCallback, useState } from "react";
|
|
221
|
+
|
|
222
|
+
export interface UseCopyToClipboardResult {
|
|
223
|
+
/** The currently copied text, or null if nothing has been copied */
|
|
224
|
+
copiedText: null | string;
|
|
225
|
+
/** Function to copy text to clipboard. Returns true if successful. */
|
|
226
|
+
copy: (text: string) => Promise<boolean>;
|
|
227
|
+
/** Function to reset the copied state */
|
|
228
|
+
reset: () => void;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Copy text to the clipboard using the modern Clipboard API.
|
|
233
|
+
*
|
|
234
|
+
* @returns Object containing copiedText state, copy function, and reset function
|
|
235
|
+
*
|
|
236
|
+
* @example
|
|
237
|
+
* const { copiedText, copy, reset } = useCopyToClipboard();
|
|
238
|
+
*
|
|
239
|
+
* return (
|
|
240
|
+
* <button onClick={() => copy("Hello, World!")}>
|
|
241
|
+
* {copiedText ? "Copied!" : "Copy"}
|
|
242
|
+
* </button>
|
|
243
|
+
* );
|
|
244
|
+
*/
|
|
245
|
+
export function useCopyToClipboard(): UseCopyToClipboardResult {
|
|
246
|
+
const [copiedText, setCopiedText] = useState<null | string>(null);
|
|
247
|
+
|
|
248
|
+
const copy = useCallback(async (text: string): Promise<boolean> => {
|
|
249
|
+
// Check if we're in a browser environment with clipboard support
|
|
250
|
+
const clipboard =
|
|
251
|
+
typeof window !== "undefined" ? navigator.clipboard : undefined;
|
|
252
|
+
|
|
253
|
+
if (!clipboard) {
|
|
254
|
+
console.warn("Clipboard API not available");
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
await clipboard.writeText(text);
|
|
260
|
+
setCopiedText(text);
|
|
261
|
+
return true;
|
|
262
|
+
} catch (error) {
|
|
263
|
+
console.warn("Failed to copy to clipboard:", error);
|
|
264
|
+
setCopiedText(null);
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
}, []);
|
|
268
|
+
|
|
269
|
+
const reset = useCallback(() => {
|
|
270
|
+
setCopiedText(null);
|
|
271
|
+
}, []);
|
|
272
|
+
|
|
273
|
+
return { copiedText, copy, reset };
|
|
274
|
+
}
|
|
275
|
+
`, useCopyToClipboard_test: `import { act, renderHook } from "@testing-library/react";
|
|
276
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
277
|
+
|
|
278
|
+
import { useCopyToClipboard } from "./useCopyToClipboard.js";
|
|
279
|
+
|
|
280
|
+
describe("useCopyToClipboard", () => {
|
|
281
|
+
const mockClipboard = {
|
|
282
|
+
writeText: vi.fn(),
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
beforeEach(() => {
|
|
286
|
+
Object.assign(navigator, {
|
|
287
|
+
clipboard: mockClipboard,
|
|
288
|
+
});
|
|
289
|
+
mockClipboard.writeText.mockResolvedValue(undefined);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
afterEach(() => {
|
|
293
|
+
vi.clearAllMocks();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("should return initial state with null copiedText", () => {
|
|
297
|
+
const { result } = renderHook(() => useCopyToClipboard());
|
|
298
|
+
|
|
299
|
+
expect(result.current.copiedText).toBeNull();
|
|
300
|
+
expect(typeof result.current.copy).toBe("function");
|
|
301
|
+
expect(typeof result.current.reset).toBe("function");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("should copy text to clipboard and update state", async () => {
|
|
305
|
+
const { result } = renderHook(() => useCopyToClipboard());
|
|
306
|
+
|
|
307
|
+
await act(async () => {
|
|
308
|
+
const success = await result.current.copy("Hello, World!");
|
|
309
|
+
expect(success).toBe(true);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
expect(mockClipboard.writeText).toHaveBeenCalledWith("Hello, World!");
|
|
313
|
+
expect(result.current.copiedText).toBe("Hello, World!");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("should reset copiedText state", async () => {
|
|
317
|
+
const { result } = renderHook(() => useCopyToClipboard());
|
|
318
|
+
|
|
319
|
+
await act(async () => {
|
|
320
|
+
await result.current.copy("Test");
|
|
321
|
+
});
|
|
322
|
+
expect(result.current.copiedText).toBe("Test");
|
|
323
|
+
|
|
324
|
+
act(() => {
|
|
325
|
+
result.current.reset();
|
|
326
|
+
});
|
|
327
|
+
expect(result.current.copiedText).toBeNull();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("should return false when clipboard API fails", async () => {
|
|
331
|
+
mockClipboard.writeText.mockRejectedValueOnce(new Error("Failed"));
|
|
332
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(vi.fn());
|
|
333
|
+
|
|
334
|
+
const { result } = renderHook(() => useCopyToClipboard());
|
|
335
|
+
|
|
336
|
+
await act(async () => {
|
|
337
|
+
const success = await result.current.copy("Test");
|
|
338
|
+
expect(success).toBe(false);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
expect(result.current.copiedText).toBeNull();
|
|
342
|
+
consoleSpy.mockRestore();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("should return false when clipboard API is not available", async () => {
|
|
346
|
+
Object.assign(navigator, { clipboard: undefined });
|
|
347
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(vi.fn());
|
|
348
|
+
|
|
349
|
+
const { result } = renderHook(() => useCopyToClipboard());
|
|
350
|
+
|
|
351
|
+
await act(async () => {
|
|
352
|
+
const success = await result.current.copy("Test");
|
|
353
|
+
expect(success).toBe(false);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
consoleSpy.mockRestore();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("should update copiedText on subsequent copies", async () => {
|
|
360
|
+
const { result } = renderHook(() => useCopyToClipboard());
|
|
361
|
+
|
|
362
|
+
await act(async () => {
|
|
363
|
+
await result.current.copy("First");
|
|
364
|
+
});
|
|
365
|
+
expect(result.current.copiedText).toBe("First");
|
|
366
|
+
|
|
367
|
+
await act(async () => {
|
|
368
|
+
await result.current.copy("Second");
|
|
369
|
+
});
|
|
370
|
+
expect(result.current.copiedText).toBe("Second");
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
`,
|
|
374
|
+
useCounter: `import { useCallback, useState } from "react";
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Manages numeric state with increment, decrement, reset, and set methods.
|
|
378
|
+
*
|
|
379
|
+
* @param initialValue - Initial numeric value (default: 0)
|
|
380
|
+
* @returns Tuple of [value, { increment, decrement, reset, set }]
|
|
381
|
+
*
|
|
382
|
+
* @example
|
|
383
|
+
* const [count, { increment, decrement, reset }] = useCounter(0);
|
|
384
|
+
*
|
|
385
|
+
* return (
|
|
386
|
+
* <>
|
|
387
|
+
* <p>Count: {count}</p>
|
|
388
|
+
* <button onClick={() => increment()}>+1</button>
|
|
389
|
+
* <button onClick={() => decrement()}>-1</button>
|
|
390
|
+
* <button onClick={() => increment(5)}>+5</button>
|
|
391
|
+
* <button onClick={() => reset()}>Reset</button>
|
|
392
|
+
* </>
|
|
393
|
+
* );
|
|
394
|
+
*/
|
|
395
|
+
export function useCounter(initialValue = 0): [
|
|
396
|
+
number,
|
|
397
|
+
{
|
|
398
|
+
decrement: (amount?: number) => void;
|
|
399
|
+
increment: (amount?: number) => void;
|
|
400
|
+
reset: () => void;
|
|
401
|
+
set: (value: ((prev: number) => number) | number) => void;
|
|
402
|
+
},
|
|
403
|
+
] {
|
|
404
|
+
const [count, setCount] = useState<number>(initialValue);
|
|
405
|
+
|
|
406
|
+
const increment = useCallback((amount = 1) => {
|
|
407
|
+
setCount((prev) => prev + amount);
|
|
408
|
+
}, []);
|
|
409
|
+
|
|
410
|
+
const decrement = useCallback((amount = 1) => {
|
|
411
|
+
setCount((prev) => prev - amount);
|
|
412
|
+
}, []);
|
|
413
|
+
|
|
414
|
+
const reset = useCallback(() => {
|
|
415
|
+
setCount(initialValue);
|
|
416
|
+
}, [initialValue]);
|
|
417
|
+
|
|
418
|
+
const set = useCallback((value: ((prev: number) => number) | number) => {
|
|
419
|
+
setCount(value);
|
|
420
|
+
}, []);
|
|
421
|
+
|
|
422
|
+
return [
|
|
423
|
+
count,
|
|
424
|
+
{
|
|
425
|
+
decrement,
|
|
426
|
+
increment,
|
|
427
|
+
reset,
|
|
428
|
+
set,
|
|
429
|
+
},
|
|
430
|
+
];
|
|
431
|
+
}
|
|
432
|
+
`, useCounter_test: `import { act, renderHook } from "@testing-library/react";
|
|
433
|
+
import { describe, expect, it } from "vitest";
|
|
434
|
+
|
|
435
|
+
import { useCounter } from "./useCounter.js";
|
|
436
|
+
|
|
437
|
+
describe("useCounter", () => {
|
|
438
|
+
it("should initialize with 0 by default", () => {
|
|
439
|
+
const { result } = renderHook(() => useCounter());
|
|
440
|
+
expect(result.current[0]).toBe(0);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("should initialize with provided value", () => {
|
|
444
|
+
const { result } = renderHook(() => useCounter(10));
|
|
445
|
+
expect(result.current[0]).toBe(10);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("should increment by 1 by default", () => {
|
|
449
|
+
const { result } = renderHook(() => useCounter(0));
|
|
450
|
+
|
|
451
|
+
act(() => {
|
|
452
|
+
result.current[1].increment();
|
|
453
|
+
});
|
|
454
|
+
expect(result.current[0]).toBe(1);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("should increment by custom amount", () => {
|
|
458
|
+
const { result } = renderHook(() => useCounter(0));
|
|
459
|
+
|
|
460
|
+
act(() => {
|
|
461
|
+
result.current[1].increment(5);
|
|
462
|
+
});
|
|
463
|
+
expect(result.current[0]).toBe(5);
|
|
464
|
+
|
|
465
|
+
act(() => {
|
|
466
|
+
result.current[1].increment(3);
|
|
467
|
+
});
|
|
468
|
+
expect(result.current[0]).toBe(8);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("should decrement by 1 by default", () => {
|
|
472
|
+
const { result } = renderHook(() => useCounter(5));
|
|
473
|
+
|
|
474
|
+
act(() => {
|
|
475
|
+
result.current[1].decrement();
|
|
476
|
+
});
|
|
477
|
+
expect(result.current[0]).toBe(4);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("should decrement by custom amount", () => {
|
|
481
|
+
const { result } = renderHook(() => useCounter(10));
|
|
482
|
+
|
|
483
|
+
act(() => {
|
|
484
|
+
result.current[1].decrement(3);
|
|
485
|
+
});
|
|
486
|
+
expect(result.current[0]).toBe(7);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("should reset to initial value", () => {
|
|
490
|
+
const { result } = renderHook(() => useCounter(5));
|
|
491
|
+
|
|
492
|
+
act(() => {
|
|
493
|
+
result.current[1].increment(10);
|
|
494
|
+
});
|
|
495
|
+
expect(result.current[0]).toBe(15);
|
|
496
|
+
|
|
497
|
+
act(() => {
|
|
498
|
+
result.current[1].reset();
|
|
499
|
+
});
|
|
500
|
+
expect(result.current[0]).toBe(5);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("should set to specific value", () => {
|
|
504
|
+
const { result } = renderHook(() => useCounter(0));
|
|
505
|
+
|
|
506
|
+
act(() => {
|
|
507
|
+
result.current[1].set(42);
|
|
508
|
+
});
|
|
509
|
+
expect(result.current[0]).toBe(42);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("should set with updater function", () => {
|
|
513
|
+
const { result } = renderHook(() => useCounter(10));
|
|
514
|
+
|
|
515
|
+
act(() => {
|
|
516
|
+
result.current[1].set((prev) => prev * 2);
|
|
517
|
+
});
|
|
518
|
+
expect(result.current[0]).toBe(20);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it("should handle negative numbers", () => {
|
|
522
|
+
const { result } = renderHook(() => useCounter(0));
|
|
523
|
+
|
|
524
|
+
act(() => {
|
|
525
|
+
result.current[1].decrement(5);
|
|
526
|
+
});
|
|
527
|
+
expect(result.current[0]).toBe(-5);
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
`,
|
|
531
|
+
useDebounce: `import { useEffect, useState } from "react";
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Debounces a value by delaying updates until after the specified delay.
|
|
535
|
+
*
|
|
536
|
+
* @param value - The value to debounce
|
|
537
|
+
* @param delay - The delay in milliseconds (default: 500ms)
|
|
538
|
+
* @returns The debounced value
|
|
539
|
+
*
|
|
540
|
+
* @example
|
|
541
|
+
* const [search, setSearch] = useState("");
|
|
542
|
+
* const debouncedSearch = useDebounce(search, 300);
|
|
543
|
+
*
|
|
544
|
+
* useEffect(() => {
|
|
545
|
+
* // This effect runs 300ms after the user stops typing
|
|
546
|
+
* fetchResults(debouncedSearch);
|
|
547
|
+
* }, [debouncedSearch]);
|
|
548
|
+
*/
|
|
549
|
+
export function useDebounce<T>(value: T, delay = 500): T {
|
|
550
|
+
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
|
551
|
+
|
|
552
|
+
useEffect(() => {
|
|
553
|
+
const timer = setTimeout(() => {
|
|
554
|
+
setDebouncedValue(value);
|
|
555
|
+
}, delay);
|
|
556
|
+
|
|
557
|
+
return () => {
|
|
558
|
+
clearTimeout(timer);
|
|
559
|
+
};
|
|
560
|
+
}, [value, delay]);
|
|
561
|
+
|
|
562
|
+
return debouncedValue;
|
|
563
|
+
}
|
|
564
|
+
`, useDebounce_test: `import { act, renderHook } from "@testing-library/react";
|
|
565
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
566
|
+
|
|
567
|
+
import { useDebounce } from "./useDebounce.js";
|
|
568
|
+
|
|
569
|
+
describe("useDebounce", () => {
|
|
570
|
+
beforeEach(() => {
|
|
571
|
+
vi.useFakeTimers();
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("should return initial value immediately", () => {
|
|
575
|
+
const { result } = renderHook(() => useDebounce("initial", 500));
|
|
576
|
+
expect(result.current).toBe("initial");
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it("should debounce value updates", () => {
|
|
580
|
+
const { rerender, result } = renderHook(
|
|
581
|
+
({ delay, value }) => useDebounce(value, delay),
|
|
582
|
+
{ initialProps: { delay: 500, value: "initial" } },
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
expect(result.current).toBe("initial");
|
|
586
|
+
|
|
587
|
+
rerender({ delay: 500, value: "updated" });
|
|
588
|
+
expect(result.current).toBe("initial");
|
|
589
|
+
|
|
590
|
+
act(() => {
|
|
591
|
+
vi.advanceTimersByTime(499);
|
|
592
|
+
});
|
|
593
|
+
expect(result.current).toBe("initial");
|
|
594
|
+
|
|
595
|
+
act(() => {
|
|
596
|
+
vi.advanceTimersByTime(1);
|
|
597
|
+
});
|
|
598
|
+
expect(result.current).toBe("updated");
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it("should reset timer on rapid value changes", () => {
|
|
602
|
+
const { rerender, result } = renderHook(
|
|
603
|
+
({ value }) => useDebounce(value, 500),
|
|
604
|
+
{ initialProps: { value: "a" } },
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
rerender({ value: "b" });
|
|
608
|
+
act(() => {
|
|
609
|
+
vi.advanceTimersByTime(300);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
rerender({ value: "c" });
|
|
613
|
+
act(() => {
|
|
614
|
+
vi.advanceTimersByTime(300);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
expect(result.current).toBe("a");
|
|
618
|
+
|
|
619
|
+
act(() => {
|
|
620
|
+
vi.advanceTimersByTime(200);
|
|
621
|
+
});
|
|
622
|
+
expect(result.current).toBe("c");
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it("should use default delay of 500ms", () => {
|
|
626
|
+
const { rerender, result } = renderHook(({ value }) => useDebounce(value), {
|
|
627
|
+
initialProps: { value: "initial" },
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
rerender({ value: "updated" });
|
|
631
|
+
|
|
632
|
+
act(() => {
|
|
633
|
+
vi.advanceTimersByTime(499);
|
|
634
|
+
});
|
|
635
|
+
expect(result.current).toBe("initial");
|
|
636
|
+
|
|
637
|
+
act(() => {
|
|
638
|
+
vi.advanceTimersByTime(1);
|
|
639
|
+
});
|
|
640
|
+
expect(result.current).toBe("updated");
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
`,
|
|
644
|
+
useDebouncedCallback: `import { useCallback, useEffect, useRef } from "react";
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Creates a debounced version of a callback function.
|
|
648
|
+
* The callback will only execute after the specified delay has passed
|
|
649
|
+
* since the last invocation.
|
|
650
|
+
*
|
|
651
|
+
* @param callback - The function to debounce
|
|
652
|
+
* @param delay - The delay in milliseconds (default: 500ms)
|
|
653
|
+
* @returns A tuple containing [debouncedCallback, cancel]
|
|
654
|
+
*
|
|
655
|
+
* @example
|
|
656
|
+
* const [debouncedSearch, cancel] = useDebouncedCallback((query: string) => {
|
|
657
|
+
* fetch(\`/api/search?q=\${query}\`);
|
|
658
|
+
* }, 300);
|
|
659
|
+
*
|
|
660
|
+
* // Call the debounced function
|
|
661
|
+
* <input onChange={(e) => debouncedSearch(e.target.value)} />
|
|
662
|
+
*
|
|
663
|
+
* // Cancel pending calls if needed
|
|
664
|
+
* <button onClick={cancel}>Cancel</button>
|
|
665
|
+
*/
|
|
666
|
+
export function useDebouncedCallback<T extends unknown[]>(
|
|
667
|
+
callback: (...args: T) => void,
|
|
668
|
+
delay = 500,
|
|
669
|
+
): [(...args: T) => void, () => void] {
|
|
670
|
+
const callbackRef = useRef(callback);
|
|
671
|
+
const timeoutRef = useRef<null | ReturnType<typeof setTimeout>>(null);
|
|
672
|
+
|
|
673
|
+
// Keep callback ref up to date
|
|
674
|
+
useEffect(() => {
|
|
675
|
+
callbackRef.current = callback;
|
|
676
|
+
}, [callback]);
|
|
677
|
+
|
|
678
|
+
// Cleanup on unmount
|
|
679
|
+
useEffect(() => {
|
|
680
|
+
return () => {
|
|
681
|
+
if (timeoutRef.current) {
|
|
682
|
+
clearTimeout(timeoutRef.current);
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
}, []);
|
|
686
|
+
|
|
687
|
+
const cancel = useCallback(() => {
|
|
688
|
+
if (timeoutRef.current) {
|
|
689
|
+
clearTimeout(timeoutRef.current);
|
|
690
|
+
timeoutRef.current = null;
|
|
691
|
+
}
|
|
692
|
+
}, []);
|
|
693
|
+
|
|
694
|
+
const debouncedCallback = useCallback(
|
|
695
|
+
(...args: T) => {
|
|
696
|
+
cancel();
|
|
697
|
+
timeoutRef.current = setTimeout(() => {
|
|
698
|
+
callbackRef.current(...args);
|
|
699
|
+
}, delay);
|
|
700
|
+
},
|
|
701
|
+
[delay, cancel],
|
|
702
|
+
);
|
|
703
|
+
|
|
704
|
+
return [debouncedCallback, cancel];
|
|
705
|
+
}
|
|
706
|
+
`, useDebouncedCallback_test: `import { act, renderHook } from "@testing-library/react";
|
|
707
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
708
|
+
|
|
709
|
+
import { useDebouncedCallback } from "./useDebouncedCallback.js";
|
|
710
|
+
|
|
711
|
+
describe("useDebouncedCallback", () => {
|
|
712
|
+
beforeEach(() => {
|
|
713
|
+
vi.useFakeTimers();
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
it("should debounce callback execution", () => {
|
|
717
|
+
const callback = vi.fn();
|
|
718
|
+
const { result } = renderHook(() => useDebouncedCallback(callback, 500));
|
|
719
|
+
|
|
720
|
+
const [debouncedCallback] = result.current;
|
|
721
|
+
|
|
722
|
+
act(() => {
|
|
723
|
+
debouncedCallback("a");
|
|
724
|
+
debouncedCallback("b");
|
|
725
|
+
debouncedCallback("c");
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
expect(callback).not.toHaveBeenCalled();
|
|
729
|
+
|
|
730
|
+
act(() => {
|
|
731
|
+
vi.advanceTimersByTime(500);
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
735
|
+
expect(callback).toHaveBeenCalledWith("c");
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it("should use default delay of 500ms", () => {
|
|
739
|
+
const callback = vi.fn();
|
|
740
|
+
const { result } = renderHook(() => useDebouncedCallback(callback));
|
|
741
|
+
|
|
742
|
+
const [debouncedCallback] = result.current;
|
|
743
|
+
|
|
744
|
+
act(() => {
|
|
745
|
+
debouncedCallback();
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
act(() => {
|
|
749
|
+
vi.advanceTimersByTime(499);
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
expect(callback).not.toHaveBeenCalled();
|
|
753
|
+
|
|
754
|
+
act(() => {
|
|
755
|
+
vi.advanceTimersByTime(1);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it("should reset timer on each call", () => {
|
|
762
|
+
const callback = vi.fn();
|
|
763
|
+
const { result } = renderHook(() => useDebouncedCallback(callback, 500));
|
|
764
|
+
|
|
765
|
+
const [debouncedCallback] = result.current;
|
|
766
|
+
|
|
767
|
+
act(() => {
|
|
768
|
+
debouncedCallback("first");
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
act(() => {
|
|
772
|
+
vi.advanceTimersByTime(300);
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
act(() => {
|
|
776
|
+
debouncedCallback("second");
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
act(() => {
|
|
780
|
+
vi.advanceTimersByTime(300);
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
expect(callback).not.toHaveBeenCalled();
|
|
784
|
+
|
|
785
|
+
act(() => {
|
|
786
|
+
vi.advanceTimersByTime(200);
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
790
|
+
expect(callback).toHaveBeenCalledWith("second");
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it("should cancel pending callback", () => {
|
|
794
|
+
const callback = vi.fn();
|
|
795
|
+
const { result } = renderHook(() => useDebouncedCallback(callback, 500));
|
|
796
|
+
|
|
797
|
+
const [debouncedCallback, cancel] = result.current;
|
|
798
|
+
|
|
799
|
+
act(() => {
|
|
800
|
+
debouncedCallback("test");
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
act(() => {
|
|
804
|
+
vi.advanceTimersByTime(250);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
act(() => {
|
|
808
|
+
cancel();
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
act(() => {
|
|
812
|
+
vi.advanceTimersByTime(500);
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
expect(callback).not.toHaveBeenCalled();
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
it("should pass all arguments to callback", () => {
|
|
819
|
+
const callback = vi.fn();
|
|
820
|
+
const { result } = renderHook(() => useDebouncedCallback(callback, 100));
|
|
821
|
+
|
|
822
|
+
const [debouncedCallback] = result.current;
|
|
823
|
+
|
|
824
|
+
act(() => {
|
|
825
|
+
debouncedCallback("arg1", "arg2", 123);
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
act(() => {
|
|
829
|
+
vi.advanceTimersByTime(100);
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
expect(callback).toHaveBeenCalledWith("arg1", "arg2", 123);
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
it("should use latest callback reference", () => {
|
|
836
|
+
const callback1 = vi.fn();
|
|
837
|
+
const callback2 = vi.fn();
|
|
838
|
+
|
|
839
|
+
const { rerender, result } = renderHook(
|
|
840
|
+
({ cb }) => useDebouncedCallback(cb, 500),
|
|
841
|
+
{ initialProps: { cb: callback1 } },
|
|
842
|
+
);
|
|
843
|
+
|
|
844
|
+
const [debouncedCallback] = result.current;
|
|
845
|
+
|
|
846
|
+
act(() => {
|
|
847
|
+
debouncedCallback();
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
rerender({ cb: callback2 });
|
|
851
|
+
|
|
852
|
+
act(() => {
|
|
853
|
+
vi.advanceTimersByTime(500);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
expect(callback1).not.toHaveBeenCalled();
|
|
857
|
+
expect(callback2).toHaveBeenCalledTimes(1);
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
it("should cleanup on unmount", () => {
|
|
861
|
+
const callback = vi.fn();
|
|
862
|
+
const { result, unmount } = renderHook(() =>
|
|
863
|
+
useDebouncedCallback(callback, 500),
|
|
864
|
+
);
|
|
865
|
+
|
|
866
|
+
const [debouncedCallback] = result.current;
|
|
867
|
+
|
|
868
|
+
act(() => {
|
|
869
|
+
debouncedCallback();
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
unmount();
|
|
873
|
+
|
|
874
|
+
act(() => {
|
|
875
|
+
vi.advanceTimersByTime(500);
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
expect(callback).not.toHaveBeenCalled();
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
it("should handle delay changes", () => {
|
|
882
|
+
const callback = vi.fn();
|
|
883
|
+
|
|
884
|
+
const { rerender, result } = renderHook(
|
|
885
|
+
({ delay }) => useDebouncedCallback(callback, delay),
|
|
886
|
+
{ initialProps: { delay: 500 } },
|
|
887
|
+
);
|
|
888
|
+
|
|
889
|
+
act(() => {
|
|
890
|
+
result.current[0]("test");
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
rerender({ delay: 100 });
|
|
894
|
+
|
|
895
|
+
// Get new debounced callback with updated delay
|
|
896
|
+
act(() => {
|
|
897
|
+
result.current[0]("updated");
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
act(() => {
|
|
901
|
+
vi.advanceTimersByTime(100);
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
905
|
+
expect(callback).toHaveBeenCalledWith("updated");
|
|
906
|
+
});
|
|
907
|
+
});
|
|
908
|
+
`,
|
|
909
|
+
useDocumentTitle: `import { useEffect, useRef } from "react";
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Dynamically update the document title.
|
|
913
|
+
*
|
|
914
|
+
* @param title - The title to set for the document
|
|
915
|
+
* @param restoreOnUnmount - Whether to restore the previous title on unmount (default: true)
|
|
916
|
+
*
|
|
917
|
+
* @example
|
|
918
|
+
* // Basic usage
|
|
919
|
+
* useDocumentTitle('Home | My App');
|
|
920
|
+
*
|
|
921
|
+
* @example
|
|
922
|
+
* // Dynamic title based on state
|
|
923
|
+
* useDocumentTitle(\`\${unreadCount} new messages\`);
|
|
924
|
+
*
|
|
925
|
+
* @example
|
|
926
|
+
* // Don't restore title on unmount
|
|
927
|
+
* useDocumentTitle('Dashboard', false);
|
|
928
|
+
*/
|
|
929
|
+
export function useDocumentTitle(title: string, restoreOnUnmount = true): void {
|
|
930
|
+
const previousTitle = useRef<string | undefined>(undefined);
|
|
931
|
+
|
|
932
|
+
useEffect(() => {
|
|
933
|
+
if (typeof document === "undefined") {
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Store the previous title only on first mount
|
|
938
|
+
previousTitle.current ??= document.title;
|
|
939
|
+
|
|
940
|
+
document.title = title;
|
|
941
|
+
}, [title]);
|
|
942
|
+
|
|
943
|
+
useEffect(() => {
|
|
944
|
+
return () => {
|
|
945
|
+
if (restoreOnUnmount && previousTitle.current !== undefined) {
|
|
946
|
+
document.title = previousTitle.current;
|
|
947
|
+
}
|
|
948
|
+
};
|
|
949
|
+
}, [restoreOnUnmount]);
|
|
950
|
+
}
|
|
951
|
+
`, useDocumentTitle_test: `import { cleanup, renderHook } from "@testing-library/react";
|
|
952
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
953
|
+
|
|
954
|
+
import { useDocumentTitle } from "./useDocumentTitle.js";
|
|
955
|
+
|
|
956
|
+
describe("useDocumentTitle", () => {
|
|
957
|
+
const originalTitle = "Original Title";
|
|
958
|
+
|
|
959
|
+
beforeEach(() => {
|
|
960
|
+
document.title = originalTitle;
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
afterEach(() => {
|
|
964
|
+
cleanup();
|
|
965
|
+
document.title = originalTitle;
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
it("should set the document title", () => {
|
|
969
|
+
renderHook(() => {
|
|
970
|
+
useDocumentTitle("New Title");
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
expect(document.title).toBe("New Title");
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
it("should update the document title when title changes", () => {
|
|
977
|
+
const { rerender } = renderHook(
|
|
978
|
+
({ title }) => {
|
|
979
|
+
useDocumentTitle(title);
|
|
980
|
+
},
|
|
981
|
+
{
|
|
982
|
+
initialProps: { title: "First Title" },
|
|
983
|
+
},
|
|
984
|
+
);
|
|
985
|
+
|
|
986
|
+
expect(document.title).toBe("First Title");
|
|
987
|
+
|
|
988
|
+
rerender({ title: "Second Title" });
|
|
989
|
+
expect(document.title).toBe("Second Title");
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
it("should restore original title on unmount by default", () => {
|
|
993
|
+
const { unmount } = renderHook(() => {
|
|
994
|
+
useDocumentTitle("Temporary Title");
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
expect(document.title).toBe("Temporary Title");
|
|
998
|
+
|
|
999
|
+
unmount();
|
|
1000
|
+
expect(document.title).toBe(originalTitle);
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
it("should not restore title on unmount when restoreOnUnmount is false", () => {
|
|
1004
|
+
const { unmount } = renderHook(() => {
|
|
1005
|
+
useDocumentTitle("Permanent Title", false);
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
expect(document.title).toBe("Permanent Title");
|
|
1009
|
+
|
|
1010
|
+
unmount();
|
|
1011
|
+
expect(document.title).toBe("Permanent Title");
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
it("should restore the initial title, not intermediate titles", () => {
|
|
1015
|
+
const { rerender, unmount } = renderHook(
|
|
1016
|
+
({ title }) => {
|
|
1017
|
+
useDocumentTitle(title);
|
|
1018
|
+
},
|
|
1019
|
+
{ initialProps: { title: "First" } },
|
|
1020
|
+
);
|
|
1021
|
+
|
|
1022
|
+
expect(document.title).toBe("First");
|
|
1023
|
+
|
|
1024
|
+
rerender({ title: "Second" });
|
|
1025
|
+
expect(document.title).toBe("Second");
|
|
1026
|
+
|
|
1027
|
+
rerender({ title: "Third" });
|
|
1028
|
+
expect(document.title).toBe("Third");
|
|
1029
|
+
|
|
1030
|
+
unmount();
|
|
1031
|
+
expect(document.title).toBe(originalTitle);
|
|
1032
|
+
});
|
|
1033
|
+
});
|
|
1034
|
+
`,
|
|
1035
|
+
useEventListener: `import { useEffect, useRef } from "react";
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Attaches an event listener to a target element or window with automatic cleanup.
|
|
1039
|
+
*
|
|
1040
|
+
* @param eventName - The event type to listen for (e.g., 'click', 'scroll', 'keydown')
|
|
1041
|
+
* @param handler - The event handler function
|
|
1042
|
+
* @param element - The target element or window (default: window)
|
|
1043
|
+
* @param options - Event listener options (capture, passive, once)
|
|
1044
|
+
*
|
|
1045
|
+
* @example
|
|
1046
|
+
* // Listen for clicks on window
|
|
1047
|
+
* useEventListener('click', (e) => console.log('Clicked!'));
|
|
1048
|
+
*
|
|
1049
|
+
* @example
|
|
1050
|
+
* // Listen for clicks on a specific element
|
|
1051
|
+
* const buttonRef = useRef<HTMLButtonElement>(null);
|
|
1052
|
+
* useEventListener('click', handleClick, buttonRef);
|
|
1053
|
+
*
|
|
1054
|
+
* @example
|
|
1055
|
+
* // With options
|
|
1056
|
+
* useEventListener('scroll', handleScroll, window, { passive: true });
|
|
1057
|
+
*/
|
|
1058
|
+
export function useEventListener<K extends keyof WindowEventMap>(
|
|
1059
|
+
eventName: K,
|
|
1060
|
+
handler: (event: WindowEventMap[K]) => void,
|
|
1061
|
+
element?: Window,
|
|
1062
|
+
options?: AddEventListenerOptions | boolean,
|
|
1063
|
+
): void;
|
|
1064
|
+
export function useEventListener<
|
|
1065
|
+
K extends keyof HTMLElementEventMap,
|
|
1066
|
+
T extends HTMLElement = HTMLDivElement,
|
|
1067
|
+
>(
|
|
1068
|
+
eventName: K,
|
|
1069
|
+
handler: (event: HTMLElementEventMap[K]) => void,
|
|
1070
|
+
element: React.RefObject<null | T>,
|
|
1071
|
+
options?: AddEventListenerOptions | boolean,
|
|
1072
|
+
): void;
|
|
1073
|
+
export function useEventListener<K extends keyof DocumentEventMap>(
|
|
1074
|
+
eventName: K,
|
|
1075
|
+
handler: (event: DocumentEventMap[K]) => void,
|
|
1076
|
+
element: Document,
|
|
1077
|
+
options?: AddEventListenerOptions | boolean,
|
|
1078
|
+
): void;
|
|
1079
|
+
export function useEventListener<
|
|
1080
|
+
KW extends keyof WindowEventMap,
|
|
1081
|
+
KH extends keyof HTMLElementEventMap,
|
|
1082
|
+
KD extends keyof DocumentEventMap,
|
|
1083
|
+
T extends HTMLElement = HTMLElement,
|
|
1084
|
+
>(
|
|
1085
|
+
eventName: KD | KH | KW,
|
|
1086
|
+
handler: (
|
|
1087
|
+
event:
|
|
1088
|
+
| DocumentEventMap[KD]
|
|
1089
|
+
| Event
|
|
1090
|
+
| HTMLElementEventMap[KH]
|
|
1091
|
+
| WindowEventMap[KW],
|
|
1092
|
+
) => void,
|
|
1093
|
+
element?: Document | React.RefObject<null | T> | Window,
|
|
1094
|
+
options?: AddEventListenerOptions | boolean,
|
|
1095
|
+
): void {
|
|
1096
|
+
const savedHandler = useRef(handler);
|
|
1097
|
+
|
|
1098
|
+
useEffect(() => {
|
|
1099
|
+
savedHandler.current = handler;
|
|
1100
|
+
}, [handler]);
|
|
1101
|
+
|
|
1102
|
+
useEffect(() => {
|
|
1103
|
+
let targetElement: Document | Element | null | Window;
|
|
1104
|
+
|
|
1105
|
+
if (element === undefined) {
|
|
1106
|
+
targetElement = window;
|
|
1107
|
+
} else if (element instanceof Document || element instanceof Window) {
|
|
1108
|
+
targetElement = element;
|
|
1109
|
+
} else {
|
|
1110
|
+
targetElement = element.current;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
if (!targetElement?.addEventListener) {
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
const eventListener: typeof handler = (event) => {
|
|
1118
|
+
savedHandler.current(event);
|
|
1119
|
+
};
|
|
1120
|
+
|
|
1121
|
+
targetElement.addEventListener(eventName, eventListener, options);
|
|
1122
|
+
|
|
1123
|
+
return () => {
|
|
1124
|
+
targetElement.removeEventListener(eventName, eventListener, options);
|
|
1125
|
+
};
|
|
1126
|
+
}, [eventName, element, options]);
|
|
1127
|
+
}
|
|
1128
|
+
`, useEventListener_test: `import { cleanup, fireEvent, render } from "@testing-library/react";
|
|
1129
|
+
import React from "react";
|
|
1130
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
1131
|
+
|
|
1132
|
+
import { useEventListener } from "./useEventListener.js";
|
|
1133
|
+
|
|
1134
|
+
describe("useEventListener", () => {
|
|
1135
|
+
afterEach(() => {
|
|
1136
|
+
cleanup();
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
it("should add event listener to window by default", () => {
|
|
1140
|
+
const handler = vi.fn();
|
|
1141
|
+
const addSpy = vi.spyOn(window, "addEventListener");
|
|
1142
|
+
|
|
1143
|
+
const Component = () => {
|
|
1144
|
+
useEventListener("click", handler);
|
|
1145
|
+
return null;
|
|
1146
|
+
};
|
|
1147
|
+
|
|
1148
|
+
render(<Component />);
|
|
1149
|
+
|
|
1150
|
+
expect(addSpy).toHaveBeenCalledWith(
|
|
1151
|
+
"click",
|
|
1152
|
+
expect.any(Function),
|
|
1153
|
+
undefined,
|
|
1154
|
+
);
|
|
1155
|
+
addSpy.mockRestore();
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
it("should call handler when event is fired on window", () => {
|
|
1159
|
+
const handler = vi.fn();
|
|
1160
|
+
|
|
1161
|
+
const Component = () => {
|
|
1162
|
+
useEventListener("click", handler);
|
|
1163
|
+
return null;
|
|
1164
|
+
};
|
|
1165
|
+
|
|
1166
|
+
render(<Component />);
|
|
1167
|
+
|
|
1168
|
+
fireEvent.click(window);
|
|
1169
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
it("should add event listener to ref element", () => {
|
|
1173
|
+
const handler = vi.fn();
|
|
1174
|
+
|
|
1175
|
+
const Component = () => {
|
|
1176
|
+
const ref = React.useRef<HTMLButtonElement>(null);
|
|
1177
|
+
useEventListener("click", handler, ref);
|
|
1178
|
+
return <button ref={ref}>Click me</button>;
|
|
1179
|
+
};
|
|
1180
|
+
|
|
1181
|
+
const { getByText } = render(<Component />);
|
|
1182
|
+
|
|
1183
|
+
fireEvent.click(getByText("Click me"));
|
|
1184
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
it("should not fire handler when clicking outside ref element", () => {
|
|
1188
|
+
const handler = vi.fn();
|
|
1189
|
+
|
|
1190
|
+
const Component = () => {
|
|
1191
|
+
const ref = React.useRef<HTMLButtonElement>(null);
|
|
1192
|
+
useEventListener("click", handler, ref);
|
|
1193
|
+
return (
|
|
1194
|
+
<div>
|
|
1195
|
+
<button ref={ref}>Target</button>
|
|
1196
|
+
<button>Other</button>
|
|
1197
|
+
</div>
|
|
1198
|
+
);
|
|
1199
|
+
};
|
|
1200
|
+
|
|
1201
|
+
const { getByText } = render(<Component />);
|
|
1202
|
+
|
|
1203
|
+
fireEvent.click(getByText("Other"));
|
|
1204
|
+
expect(handler).not.toHaveBeenCalled();
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
it("should add event listener to document", () => {
|
|
1208
|
+
const handler = vi.fn();
|
|
1209
|
+
const addSpy = vi.spyOn(document, "addEventListener");
|
|
1210
|
+
|
|
1211
|
+
const Component = () => {
|
|
1212
|
+
useEventListener("click", handler, document);
|
|
1213
|
+
return null;
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
render(<Component />);
|
|
1217
|
+
|
|
1218
|
+
expect(addSpy).toHaveBeenCalledWith(
|
|
1219
|
+
"click",
|
|
1220
|
+
expect.any(Function),
|
|
1221
|
+
undefined,
|
|
1222
|
+
);
|
|
1223
|
+
addSpy.mockRestore();
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
it("should remove event listener on unmount", () => {
|
|
1227
|
+
const handler = vi.fn();
|
|
1228
|
+
const removeSpy = vi.spyOn(window, "removeEventListener");
|
|
1229
|
+
|
|
1230
|
+
const Component = () => {
|
|
1231
|
+
useEventListener("click", handler);
|
|
1232
|
+
return null;
|
|
1233
|
+
};
|
|
1234
|
+
|
|
1235
|
+
const { unmount } = render(<Component />);
|
|
1236
|
+
unmount();
|
|
1237
|
+
|
|
1238
|
+
expect(removeSpy).toHaveBeenCalledWith(
|
|
1239
|
+
"click",
|
|
1240
|
+
expect.any(Function),
|
|
1241
|
+
undefined,
|
|
1242
|
+
);
|
|
1243
|
+
removeSpy.mockRestore();
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
it("should pass options to event listener", () => {
|
|
1247
|
+
const handler = vi.fn();
|
|
1248
|
+
const addSpy = vi.spyOn(window, "addEventListener");
|
|
1249
|
+
const options = { capture: true, passive: true };
|
|
1250
|
+
|
|
1251
|
+
const Component = () => {
|
|
1252
|
+
useEventListener("scroll", handler, undefined, options);
|
|
1253
|
+
return null;
|
|
1254
|
+
};
|
|
1255
|
+
|
|
1256
|
+
render(<Component />);
|
|
1257
|
+
|
|
1258
|
+
expect(addSpy).toHaveBeenCalledWith(
|
|
1259
|
+
"scroll",
|
|
1260
|
+
expect.any(Function),
|
|
1261
|
+
options,
|
|
1262
|
+
);
|
|
1263
|
+
addSpy.mockRestore();
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
it("should update handler without re-adding listener", () => {
|
|
1267
|
+
const handler1 = vi.fn();
|
|
1268
|
+
const handler2 = vi.fn();
|
|
1269
|
+
const addSpy = vi.spyOn(window, "addEventListener");
|
|
1270
|
+
|
|
1271
|
+
const Component = ({ handler }: { handler: () => void }) => {
|
|
1272
|
+
useEventListener("click", handler);
|
|
1273
|
+
return null;
|
|
1274
|
+
};
|
|
1275
|
+
|
|
1276
|
+
const { rerender } = render(<Component handler={handler1} />);
|
|
1277
|
+
expect(addSpy).toHaveBeenCalledTimes(1);
|
|
1278
|
+
|
|
1279
|
+
rerender(<Component handler={handler2} />);
|
|
1280
|
+
|
|
1281
|
+
fireEvent.click(window);
|
|
1282
|
+
expect(handler1).not.toHaveBeenCalled();
|
|
1283
|
+
expect(handler2).toHaveBeenCalledTimes(1);
|
|
1284
|
+
|
|
1285
|
+
addSpy.mockRestore();
|
|
1286
|
+
});
|
|
1287
|
+
|
|
1288
|
+
it("should handle keydown events", () => {
|
|
1289
|
+
const handler = vi.fn();
|
|
1290
|
+
|
|
1291
|
+
const Component = () => {
|
|
1292
|
+
useEventListener("keydown", handler);
|
|
1293
|
+
return null;
|
|
1294
|
+
};
|
|
1295
|
+
|
|
1296
|
+
render(<Component />);
|
|
1297
|
+
|
|
1298
|
+
fireEvent.keyDown(window, { key: "Enter" });
|
|
1299
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
1300
|
+
});
|
|
1301
|
+
});
|
|
1302
|
+
`,
|
|
1303
|
+
useFetch: `import { useCallback, useEffect, useRef, useState } from "react";
|
|
1304
|
+
|
|
1305
|
+
export interface UseFetchOptions<T> {
|
|
1306
|
+
/** Whether to fetch immediately on mount (default: true) */
|
|
1307
|
+
immediate?: boolean;
|
|
1308
|
+
/** Initial data before fetch completes */
|
|
1309
|
+
initialData?: T;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
export interface UseFetchResult<T> {
|
|
1313
|
+
/** The fetched data, or undefined if not yet loaded */
|
|
1314
|
+
data: T | undefined;
|
|
1315
|
+
/** Error object if the fetch failed */
|
|
1316
|
+
error: Error | null;
|
|
1317
|
+
/** Whether a fetch is currently in progress */
|
|
1318
|
+
isLoading: boolean;
|
|
1319
|
+
/** Function to manually trigger a refetch */
|
|
1320
|
+
refetch: () => Promise<void>;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/**
|
|
1324
|
+
* Fetch data from a URL with loading and error states.
|
|
1325
|
+
*
|
|
1326
|
+
* @param url - The URL to fetch data from
|
|
1327
|
+
* @param options - Configuration options
|
|
1328
|
+
* @returns Object containing data, loading state, error, and refetch function
|
|
1329
|
+
*
|
|
1330
|
+
* @example
|
|
1331
|
+
* const { data, isLoading, error, refetch } = useFetch<User[]>('/api/users');
|
|
1332
|
+
*
|
|
1333
|
+
* if (isLoading) return <Spinner />;
|
|
1334
|
+
* if (error) return <Error message={error.message} />;
|
|
1335
|
+
* return <UserList users={data} />;
|
|
1336
|
+
*
|
|
1337
|
+
* @example
|
|
1338
|
+
* // With initial data and manual fetch
|
|
1339
|
+
* const { data, refetch } = useFetch<User>('/api/user', {
|
|
1340
|
+
* initialData: { name: 'Loading...' },
|
|
1341
|
+
* immediate: false,
|
|
1342
|
+
* });
|
|
1343
|
+
*/
|
|
1344
|
+
export function useFetch<T>(
|
|
1345
|
+
url: string,
|
|
1346
|
+
options: UseFetchOptions<T> = {},
|
|
1347
|
+
): UseFetchResult<T> {
|
|
1348
|
+
const { immediate = true, initialData } = options;
|
|
1349
|
+
|
|
1350
|
+
const [data, setData] = useState<T | undefined>(initialData);
|
|
1351
|
+
const [error, setError] = useState<Error | null>(null);
|
|
1352
|
+
const [isLoading, setIsLoading] = useState(immediate);
|
|
1353
|
+
|
|
1354
|
+
const abortControllerRef = useRef<AbortController | null>(null);
|
|
1355
|
+
|
|
1356
|
+
const fetchData = useCallback(async () => {
|
|
1357
|
+
// Cancel any in-flight request
|
|
1358
|
+
abortControllerRef.current?.abort();
|
|
1359
|
+
abortControllerRef.current = new AbortController();
|
|
1360
|
+
|
|
1361
|
+
setIsLoading(true);
|
|
1362
|
+
setError(null);
|
|
1363
|
+
|
|
1364
|
+
try {
|
|
1365
|
+
const response = await fetch(url, {
|
|
1366
|
+
signal: abortControllerRef.current.signal,
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
if (!response.ok) {
|
|
1370
|
+
throw new Error(\`HTTP error! status: \${String(response.status)}\`);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
const result = (await response.json()) as T;
|
|
1374
|
+
setData(result);
|
|
1375
|
+
} catch (err) {
|
|
1376
|
+
if (err instanceof Error && err.name !== "AbortError") {
|
|
1377
|
+
setError(err);
|
|
1378
|
+
}
|
|
1379
|
+
} finally {
|
|
1380
|
+
setIsLoading(false);
|
|
1381
|
+
}
|
|
1382
|
+
}, [url]);
|
|
1383
|
+
|
|
1384
|
+
useEffect(() => {
|
|
1385
|
+
if (immediate) {
|
|
1386
|
+
void fetchData();
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
return () => {
|
|
1390
|
+
abortControllerRef.current?.abort();
|
|
1391
|
+
};
|
|
1392
|
+
}, [fetchData, immediate]);
|
|
1393
|
+
|
|
1394
|
+
return { data, error, isLoading, refetch: fetchData };
|
|
1395
|
+
}
|
|
1396
|
+
`, useFetch_test: `import { act, renderHook, waitFor } from "@testing-library/react";
|
|
1397
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
1398
|
+
|
|
1399
|
+
import { useFetch } from "./useFetch.js";
|
|
1400
|
+
|
|
1401
|
+
describe("useFetch", () => {
|
|
1402
|
+
const mockData = { id: 1, name: "Test" };
|
|
1403
|
+
|
|
1404
|
+
beforeEach(() => {
|
|
1405
|
+
vi.spyOn(global, "fetch").mockImplementation(() =>
|
|
1406
|
+
Promise.resolve({
|
|
1407
|
+
json: () => Promise.resolve(mockData),
|
|
1408
|
+
ok: true,
|
|
1409
|
+
} as Response),
|
|
1410
|
+
);
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
afterEach(() => {
|
|
1414
|
+
vi.restoreAllMocks();
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
it("should fetch data immediately by default", async () => {
|
|
1418
|
+
const { result } = renderHook(() => useFetch<typeof mockData>("/api/test"));
|
|
1419
|
+
|
|
1420
|
+
expect(result.current.isLoading).toBe(true);
|
|
1421
|
+
|
|
1422
|
+
await waitFor(() => {
|
|
1423
|
+
expect(result.current.isLoading).toBe(false);
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
expect(result.current.data).toEqual(mockData);
|
|
1427
|
+
expect(result.current.error).toBeNull();
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
it("should not fetch immediately when immediate is false", () => {
|
|
1431
|
+
const { result } = renderHook(() =>
|
|
1432
|
+
useFetch<typeof mockData>("/api/test", { immediate: false }),
|
|
1433
|
+
);
|
|
1434
|
+
|
|
1435
|
+
expect(result.current.isLoading).toBe(false);
|
|
1436
|
+
expect(result.current.data).toBeUndefined();
|
|
1437
|
+
expect(global.fetch).not.toHaveBeenCalled();
|
|
1438
|
+
});
|
|
1439
|
+
|
|
1440
|
+
it("should use initial data", () => {
|
|
1441
|
+
const initialData = { id: 0, name: "Initial" };
|
|
1442
|
+
const { result } = renderHook(() =>
|
|
1443
|
+
useFetch<typeof mockData>("/api/test", {
|
|
1444
|
+
immediate: false,
|
|
1445
|
+
initialData,
|
|
1446
|
+
}),
|
|
1447
|
+
);
|
|
1448
|
+
|
|
1449
|
+
expect(result.current.data).toEqual(initialData);
|
|
1450
|
+
});
|
|
1451
|
+
|
|
1452
|
+
it("should handle fetch errors", async () => {
|
|
1453
|
+
vi.spyOn(global, "fetch").mockImplementation(() =>
|
|
1454
|
+
Promise.resolve({
|
|
1455
|
+
ok: false,
|
|
1456
|
+
status: 404,
|
|
1457
|
+
} as Response),
|
|
1458
|
+
);
|
|
1459
|
+
|
|
1460
|
+
const { result } = renderHook(() => useFetch<typeof mockData>("/api/test"));
|
|
1461
|
+
|
|
1462
|
+
await waitFor(() => {
|
|
1463
|
+
expect(result.current.isLoading).toBe(false);
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
expect(result.current.error).toBeInstanceOf(Error);
|
|
1467
|
+
expect(result.current.error?.message).toBe("HTTP error! status: 404");
|
|
1468
|
+
});
|
|
1469
|
+
|
|
1470
|
+
it("should handle network errors", async () => {
|
|
1471
|
+
vi.spyOn(global, "fetch").mockImplementation(() =>
|
|
1472
|
+
Promise.reject(new Error("Network error")),
|
|
1473
|
+
);
|
|
1474
|
+
|
|
1475
|
+
const { result } = renderHook(() => useFetch<typeof mockData>("/api/test"));
|
|
1476
|
+
|
|
1477
|
+
await waitFor(() => {
|
|
1478
|
+
expect(result.current.isLoading).toBe(false);
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
expect(result.current.error?.message).toBe("Network error");
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
it("should refetch data when refetch is called", async () => {
|
|
1485
|
+
const { result } = renderHook(() =>
|
|
1486
|
+
useFetch<typeof mockData>("/api/test", { immediate: false }),
|
|
1487
|
+
);
|
|
1488
|
+
|
|
1489
|
+
expect(global.fetch).not.toHaveBeenCalled();
|
|
1490
|
+
|
|
1491
|
+
await act(async () => {
|
|
1492
|
+
await result.current.refetch();
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
1496
|
+
expect(result.current.data).toEqual(mockData);
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
it("should refetch when URL changes", async () => {
|
|
1500
|
+
const { rerender, result } = renderHook(
|
|
1501
|
+
({ url }) => useFetch<typeof mockData>(url),
|
|
1502
|
+
{ initialProps: { url: "/api/test1" } },
|
|
1503
|
+
);
|
|
1504
|
+
|
|
1505
|
+
await waitFor(() => {
|
|
1506
|
+
expect(result.current.isLoading).toBe(false);
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
1510
|
+
|
|
1511
|
+
rerender({ url: "/api/test2" });
|
|
1512
|
+
|
|
1513
|
+
await waitFor(() => {
|
|
1514
|
+
expect(global.fetch).toHaveBeenCalledTimes(2);
|
|
1515
|
+
});
|
|
1516
|
+
});
|
|
1517
|
+
|
|
1518
|
+
it("should clear error on successful refetch", async () => {
|
|
1519
|
+
vi.spyOn(global, "fetch").mockImplementationOnce(() =>
|
|
1520
|
+
Promise.resolve({
|
|
1521
|
+
ok: false,
|
|
1522
|
+
status: 500,
|
|
1523
|
+
} as Response),
|
|
1524
|
+
);
|
|
1525
|
+
|
|
1526
|
+
const { result } = renderHook(() => useFetch<typeof mockData>("/api/test"));
|
|
1527
|
+
|
|
1528
|
+
await waitFor(() => {
|
|
1529
|
+
expect(result.current.error).not.toBeNull();
|
|
1530
|
+
});
|
|
1531
|
+
|
|
1532
|
+
vi.spyOn(global, "fetch").mockImplementation(() =>
|
|
1533
|
+
Promise.resolve({
|
|
1534
|
+
json: () => Promise.resolve(mockData),
|
|
1535
|
+
ok: true,
|
|
1536
|
+
} as Response),
|
|
1537
|
+
);
|
|
1538
|
+
|
|
1539
|
+
await act(async () => {
|
|
1540
|
+
await result.current.refetch();
|
|
1541
|
+
});
|
|
1542
|
+
|
|
1543
|
+
expect(result.current.error).toBeNull();
|
|
1544
|
+
expect(result.current.data).toEqual(mockData);
|
|
1545
|
+
});
|
|
1546
|
+
});
|
|
1547
|
+
`,
|
|
1548
|
+
useHover: `import { useCallback, useRef, useState } from "react";
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* Tracks mouse hover state on a DOM element via ref.
|
|
1552
|
+
*
|
|
1553
|
+
* @returns Tuple of [isHovered, ref]
|
|
1554
|
+
*
|
|
1555
|
+
* @example
|
|
1556
|
+
* const [isHovered, ref] = useHover();
|
|
1557
|
+
*
|
|
1558
|
+
* return (
|
|
1559
|
+
* <div
|
|
1560
|
+
* ref={ref}
|
|
1561
|
+
* style={{
|
|
1562
|
+
* backgroundColor: isHovered ? "blue" : "gray",
|
|
1563
|
+
* }}
|
|
1564
|
+
* >
|
|
1565
|
+
* Hover me!
|
|
1566
|
+
* </div>
|
|
1567
|
+
* );
|
|
1568
|
+
*/
|
|
1569
|
+
export function useHover<T extends HTMLElement = HTMLElement>(): [
|
|
1570
|
+
boolean,
|
|
1571
|
+
React.RefObject<T>,
|
|
1572
|
+
] {
|
|
1573
|
+
const ref = useRef<T>(null);
|
|
1574
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
1575
|
+
|
|
1576
|
+
const handleMouseEnter = useCallback(() => {
|
|
1577
|
+
setIsHovered(true);
|
|
1578
|
+
}, []);
|
|
1579
|
+
|
|
1580
|
+
const handleMouseLeave = useCallback(() => {
|
|
1581
|
+
setIsHovered(false);
|
|
1582
|
+
}, []);
|
|
1583
|
+
|
|
1584
|
+
// Attach event listeners to the ref
|
|
1585
|
+
const setRef = useCallback(
|
|
1586
|
+
(element: null | T) => {
|
|
1587
|
+
if (ref.current) {
|
|
1588
|
+
ref.current.removeEventListener("mouseenter", handleMouseEnter);
|
|
1589
|
+
ref.current.removeEventListener("mouseleave", handleMouseLeave);
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
if (element) {
|
|
1593
|
+
element.addEventListener("mouseenter", handleMouseEnter);
|
|
1594
|
+
element.addEventListener("mouseleave", handleMouseLeave);
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
ref.current = element;
|
|
1598
|
+
},
|
|
1599
|
+
[handleMouseEnter, handleMouseLeave],
|
|
1600
|
+
);
|
|
1601
|
+
|
|
1602
|
+
// Return a proxy ref that updates the internal ref
|
|
1603
|
+
return [
|
|
1604
|
+
isHovered,
|
|
1605
|
+
{
|
|
1606
|
+
get current() {
|
|
1607
|
+
return ref.current;
|
|
1608
|
+
},
|
|
1609
|
+
set current(element: null | T) {
|
|
1610
|
+
setRef(element);
|
|
1611
|
+
},
|
|
1612
|
+
} as React.RefObject<T>,
|
|
1613
|
+
];
|
|
1614
|
+
}
|
|
1615
|
+
`, useHover_test: `import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
1616
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
1617
|
+
|
|
1618
|
+
import { useHover } from "./useHover.js";
|
|
1619
|
+
|
|
1620
|
+
function TestComponent() {
|
|
1621
|
+
const [isHovered, ref] = useHover<HTMLDivElement>();
|
|
1622
|
+
|
|
1623
|
+
return (
|
|
1624
|
+
<div data-testid="hover-element" ref={ref}>
|
|
1625
|
+
{isHovered ? "Hovering" : "Not hovering"}
|
|
1626
|
+
</div>
|
|
1627
|
+
);
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
describe("useHover", () => {
|
|
1631
|
+
afterEach(() => {
|
|
1632
|
+
cleanup();
|
|
1633
|
+
});
|
|
1634
|
+
|
|
1635
|
+
it("should initialize with false", () => {
|
|
1636
|
+
render(<TestComponent />);
|
|
1637
|
+
const element = screen.getByTestId("hover-element");
|
|
1638
|
+
expect(element.textContent).toBe("Not hovering");
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
it("should set to true on mouseenter", () => {
|
|
1642
|
+
render(<TestComponent />);
|
|
1643
|
+
const element = screen.getByTestId("hover-element");
|
|
1644
|
+
|
|
1645
|
+
fireEvent.mouseEnter(element);
|
|
1646
|
+
expect(element.textContent).toBe("Hovering");
|
|
1647
|
+
});
|
|
1648
|
+
|
|
1649
|
+
it("should set to false on mouseleave", () => {
|
|
1650
|
+
render(<TestComponent />);
|
|
1651
|
+
const element = screen.getByTestId("hover-element");
|
|
1652
|
+
|
|
1653
|
+
fireEvent.mouseEnter(element);
|
|
1654
|
+
expect(element.textContent).toBe("Hovering");
|
|
1655
|
+
|
|
1656
|
+
fireEvent.mouseLeave(element);
|
|
1657
|
+
expect(element.textContent).toBe("Not hovering");
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
it("should handle multiple enter/leave cycles", () => {
|
|
1661
|
+
render(<TestComponent />);
|
|
1662
|
+
const element = screen.getByTestId("hover-element");
|
|
1663
|
+
|
|
1664
|
+
fireEvent.mouseEnter(element);
|
|
1665
|
+
expect(element.textContent).toBe("Hovering");
|
|
1666
|
+
|
|
1667
|
+
fireEvent.mouseLeave(element);
|
|
1668
|
+
expect(element.textContent).toBe("Not hovering");
|
|
1669
|
+
|
|
1670
|
+
fireEvent.mouseEnter(element);
|
|
1671
|
+
expect(element.textContent).toBe("Hovering");
|
|
1672
|
+
});
|
|
1673
|
+
});
|
|
1674
|
+
`,
|
|
1675
|
+
useIntersectionObserver: `import { useEffect, useRef, useState } from "react";
|
|
1676
|
+
|
|
1677
|
+
export interface UseIntersectionObserverOptions {
|
|
1678
|
+
/** Whether to stop observing after the first intersection (default: false) */
|
|
1679
|
+
once?: boolean;
|
|
1680
|
+
/** The element used as the viewport for checking visibility (default: browser viewport) */
|
|
1681
|
+
root?: Element | null;
|
|
1682
|
+
/** Margin around the root element (e.g., "10px 20px 30px 40px") */
|
|
1683
|
+
rootMargin?: string;
|
|
1684
|
+
/** A number or array of numbers indicating at what percentage of visibility the callback should trigger */
|
|
1685
|
+
threshold?: number | number[];
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
export interface UseIntersectionObserverResult {
|
|
1689
|
+
/** The current intersection observer entry */
|
|
1690
|
+
entry: IntersectionObserverEntry | null;
|
|
1691
|
+
/** Whether the element is currently intersecting */
|
|
1692
|
+
isIntersecting: boolean;
|
|
1693
|
+
/** Ref to attach to the element to observe */
|
|
1694
|
+
ref: React.RefObject<HTMLElement | null>;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
/**
|
|
1698
|
+
* Track the visibility of a DOM element within the viewport using IntersectionObserver.
|
|
1699
|
+
*
|
|
1700
|
+
* @param options - IntersectionObserver configuration options
|
|
1701
|
+
* @returns Object containing ref, entry, and isIntersecting state
|
|
1702
|
+
*
|
|
1703
|
+
* @example
|
|
1704
|
+
* // Basic usage - lazy load an image
|
|
1705
|
+
* const { ref, isIntersecting } = useIntersectionObserver();
|
|
1706
|
+
*
|
|
1707
|
+
* return (
|
|
1708
|
+
* <div ref={ref}>
|
|
1709
|
+
* {isIntersecting && <img src="large-image.jpg" />}
|
|
1710
|
+
* </div>
|
|
1711
|
+
* );
|
|
1712
|
+
*
|
|
1713
|
+
* @example
|
|
1714
|
+
* // Infinite scroll with threshold
|
|
1715
|
+
* const { ref, isIntersecting } = useIntersectionObserver({
|
|
1716
|
+
* threshold: 0.5,
|
|
1717
|
+
* rootMargin: '100px',
|
|
1718
|
+
* });
|
|
1719
|
+
*
|
|
1720
|
+
* useEffect(() => {
|
|
1721
|
+
* if (isIntersecting) loadMoreItems();
|
|
1722
|
+
* }, [isIntersecting]);
|
|
1723
|
+
*/
|
|
1724
|
+
export function useIntersectionObserver(
|
|
1725
|
+
options: UseIntersectionObserverOptions = {},
|
|
1726
|
+
): UseIntersectionObserverResult {
|
|
1727
|
+
const {
|
|
1728
|
+
once = false,
|
|
1729
|
+
root = null,
|
|
1730
|
+
rootMargin = "0px",
|
|
1731
|
+
threshold = 0,
|
|
1732
|
+
} = options;
|
|
1733
|
+
|
|
1734
|
+
const ref = useRef<HTMLElement | null>(null);
|
|
1735
|
+
const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null);
|
|
1736
|
+
const hasTriggered = useRef(false);
|
|
1737
|
+
|
|
1738
|
+
useEffect(() => {
|
|
1739
|
+
const element = ref.current;
|
|
1740
|
+
|
|
1741
|
+
if (!element || (once && hasTriggered.current)) {
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
const observer = new IntersectionObserver(
|
|
1750
|
+
([observerEntry]) => {
|
|
1751
|
+
if (!observerEntry) {
|
|
1752
|
+
return;
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
setEntry(observerEntry);
|
|
1756
|
+
|
|
1757
|
+
if (once && observerEntry.isIntersecting) {
|
|
1758
|
+
hasTriggered.current = true;
|
|
1759
|
+
observer.disconnect();
|
|
1760
|
+
}
|
|
1761
|
+
},
|
|
1762
|
+
{ root, rootMargin, threshold },
|
|
1763
|
+
);
|
|
1764
|
+
|
|
1765
|
+
observer.observe(element);
|
|
1766
|
+
|
|
1767
|
+
return () => {
|
|
1768
|
+
observer.disconnect();
|
|
1769
|
+
};
|
|
1770
|
+
}, [root, rootMargin, threshold, once]);
|
|
1771
|
+
|
|
1772
|
+
return {
|
|
1773
|
+
entry,
|
|
1774
|
+
isIntersecting: entry?.isIntersecting ?? false,
|
|
1775
|
+
ref,
|
|
1776
|
+
};
|
|
1777
|
+
}
|
|
1778
|
+
`, useIntersectionObserver_test: `import { cleanup, render, waitFor } from "@testing-library/react";
|
|
1779
|
+
import React from "react";
|
|
1780
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
1781
|
+
|
|
1782
|
+
import { useIntersectionObserver } from "./useIntersectionObserver.js";
|
|
1783
|
+
|
|
1784
|
+
describe("useIntersectionObserver", () => {
|
|
1785
|
+
let mockObserve: ReturnType<typeof vi.fn>;
|
|
1786
|
+
let mockDisconnect: ReturnType<typeof vi.fn>;
|
|
1787
|
+
let mockCallback: (entries: IntersectionObserverEntry[]) => void;
|
|
1788
|
+
let mockConstructorOptions: IntersectionObserverInit | undefined;
|
|
1789
|
+
|
|
1790
|
+
beforeEach(() => {
|
|
1791
|
+
mockObserve = vi.fn();
|
|
1792
|
+
mockDisconnect = vi.fn();
|
|
1793
|
+
mockConstructorOptions = undefined;
|
|
1794
|
+
|
|
1795
|
+
vi.stubGlobal(
|
|
1796
|
+
"IntersectionObserver",
|
|
1797
|
+
class MockIntersectionObserver {
|
|
1798
|
+
disconnect = mockDisconnect;
|
|
1799
|
+
observe = mockObserve;
|
|
1800
|
+
unobserve = vi.fn();
|
|
1801
|
+
constructor(
|
|
1802
|
+
callback: (entries: IntersectionObserverEntry[]) => void,
|
|
1803
|
+
options?: IntersectionObserverInit,
|
|
1804
|
+
) {
|
|
1805
|
+
mockCallback = callback;
|
|
1806
|
+
mockConstructorOptions = options;
|
|
1807
|
+
}
|
|
1808
|
+
},
|
|
1809
|
+
);
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
afterEach(() => {
|
|
1813
|
+
cleanup();
|
|
1814
|
+
vi.unstubAllGlobals();
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
it("should return ref, entry, and isIntersecting", () => {
|
|
1818
|
+
let hookResult: ReturnType<typeof useIntersectionObserver> | undefined;
|
|
1819
|
+
|
|
1820
|
+
const Component = () => {
|
|
1821
|
+
hookResult = useIntersectionObserver();
|
|
1822
|
+
return (
|
|
1823
|
+
<div ref={hookResult.ref as React.RefObject<HTMLDivElement>}>Test</div>
|
|
1824
|
+
);
|
|
1825
|
+
};
|
|
1826
|
+
|
|
1827
|
+
render(<Component />);
|
|
1828
|
+
|
|
1829
|
+
expect(hookResult?.ref).toBeDefined();
|
|
1830
|
+
expect(hookResult?.entry).toBeNull();
|
|
1831
|
+
expect(hookResult?.isIntersecting).toBe(false);
|
|
1832
|
+
});
|
|
1833
|
+
|
|
1834
|
+
it("should observe the element", () => {
|
|
1835
|
+
const Component = () => {
|
|
1836
|
+
const { ref } = useIntersectionObserver();
|
|
1837
|
+
return <div ref={ref as React.RefObject<HTMLDivElement>}>Test</div>;
|
|
1838
|
+
};
|
|
1839
|
+
|
|
1840
|
+
render(<Component />);
|
|
1841
|
+
|
|
1842
|
+
expect(mockObserve).toHaveBeenCalled();
|
|
1843
|
+
});
|
|
1844
|
+
|
|
1845
|
+
it("should update isIntersecting when element becomes visible", async () => {
|
|
1846
|
+
let hookResult: ReturnType<typeof useIntersectionObserver>;
|
|
1847
|
+
|
|
1848
|
+
const Component = () => {
|
|
1849
|
+
hookResult = useIntersectionObserver();
|
|
1850
|
+
return (
|
|
1851
|
+
<div ref={hookResult.ref as React.RefObject<HTMLDivElement>}>Test</div>
|
|
1852
|
+
);
|
|
1853
|
+
};
|
|
1854
|
+
|
|
1855
|
+
render(<Component />);
|
|
1856
|
+
|
|
1857
|
+
const mockEntry = {
|
|
1858
|
+
boundingClientRect: {} as DOMRectReadOnly,
|
|
1859
|
+
intersectionRatio: 1,
|
|
1860
|
+
intersectionRect: {} as DOMRectReadOnly,
|
|
1861
|
+
isIntersecting: true,
|
|
1862
|
+
rootBounds: null,
|
|
1863
|
+
target: document.createElement("div"),
|
|
1864
|
+
time: Date.now(),
|
|
1865
|
+
} as IntersectionObserverEntry;
|
|
1866
|
+
|
|
1867
|
+
mockCallback([mockEntry]);
|
|
1868
|
+
|
|
1869
|
+
await waitFor(() => {
|
|
1870
|
+
expect(hookResult.isIntersecting).toBe(true);
|
|
1871
|
+
expect(hookResult.entry).toBe(mockEntry);
|
|
1872
|
+
});
|
|
1873
|
+
});
|
|
1874
|
+
|
|
1875
|
+
it("should disconnect on unmount", () => {
|
|
1876
|
+
const Component = () => {
|
|
1877
|
+
const { ref } = useIntersectionObserver();
|
|
1878
|
+
return <div ref={ref as React.RefObject<HTMLDivElement>}>Test</div>;
|
|
1879
|
+
};
|
|
1880
|
+
|
|
1881
|
+
const { unmount } = render(<Component />);
|
|
1882
|
+
unmount();
|
|
1883
|
+
|
|
1884
|
+
expect(mockDisconnect).toHaveBeenCalled();
|
|
1885
|
+
});
|
|
1886
|
+
|
|
1887
|
+
it("should pass options to IntersectionObserver", () => {
|
|
1888
|
+
const options = {
|
|
1889
|
+
root: null,
|
|
1890
|
+
rootMargin: "10px",
|
|
1891
|
+
threshold: 0.5,
|
|
1892
|
+
};
|
|
1893
|
+
|
|
1894
|
+
const Component = () => {
|
|
1895
|
+
const { ref } = useIntersectionObserver(options);
|
|
1896
|
+
return <div ref={ref as React.RefObject<HTMLDivElement>}>Test</div>;
|
|
1897
|
+
};
|
|
1898
|
+
|
|
1899
|
+
render(<Component />);
|
|
1900
|
+
|
|
1901
|
+
expect(mockConstructorOptions).toEqual({
|
|
1902
|
+
root: null,
|
|
1903
|
+
rootMargin: "10px",
|
|
1904
|
+
threshold: 0.5,
|
|
1905
|
+
});
|
|
1906
|
+
});
|
|
1907
|
+
|
|
1908
|
+
it("should disconnect after first intersection when once is true", async () => {
|
|
1909
|
+
let hookResult: ReturnType<typeof useIntersectionObserver>;
|
|
1910
|
+
|
|
1911
|
+
const Component = () => {
|
|
1912
|
+
hookResult = useIntersectionObserver({ once: true });
|
|
1913
|
+
return (
|
|
1914
|
+
<div ref={hookResult.ref as React.RefObject<HTMLDivElement>}>Test</div>
|
|
1915
|
+
);
|
|
1916
|
+
};
|
|
1917
|
+
|
|
1918
|
+
render(<Component />);
|
|
1919
|
+
|
|
1920
|
+
const mockEntry = {
|
|
1921
|
+
isIntersecting: true,
|
|
1922
|
+
} as IntersectionObserverEntry;
|
|
1923
|
+
|
|
1924
|
+
mockCallback([mockEntry]);
|
|
1925
|
+
|
|
1926
|
+
await waitFor(() => {
|
|
1927
|
+
expect(hookResult.isIntersecting).toBe(true);
|
|
1928
|
+
});
|
|
1929
|
+
|
|
1930
|
+
expect(mockDisconnect).toHaveBeenCalled();
|
|
1931
|
+
});
|
|
1932
|
+
|
|
1933
|
+
it("should not disconnect when once is true but not intersecting", async () => {
|
|
1934
|
+
let hookResult: ReturnType<typeof useIntersectionObserver>;
|
|
1935
|
+
|
|
1936
|
+
const Component = () => {
|
|
1937
|
+
hookResult = useIntersectionObserver({ once: true });
|
|
1938
|
+
return (
|
|
1939
|
+
<div ref={hookResult.ref as React.RefObject<HTMLDivElement>}>Test</div>
|
|
1940
|
+
);
|
|
1941
|
+
};
|
|
1942
|
+
|
|
1943
|
+
render(<Component />);
|
|
1944
|
+
|
|
1945
|
+
const mockEntry = {
|
|
1946
|
+
isIntersecting: false,
|
|
1947
|
+
} as IntersectionObserverEntry;
|
|
1948
|
+
|
|
1949
|
+
mockCallback([mockEntry]);
|
|
1950
|
+
|
|
1951
|
+
await waitFor(() => {
|
|
1952
|
+
expect(hookResult.isIntersecting).toBe(false);
|
|
1953
|
+
});
|
|
1954
|
+
|
|
1955
|
+
// Should not disconnect because it hasn't intersected yet
|
|
1956
|
+
expect(mockDisconnect).not.toHaveBeenCalled();
|
|
1957
|
+
});
|
|
1958
|
+
|
|
1959
|
+
it("should handle no element ref gracefully", () => {
|
|
1960
|
+
const Component = () => {
|
|
1961
|
+
const { isIntersecting } = useIntersectionObserver();
|
|
1962
|
+
// Don't attach the ref to any element
|
|
1963
|
+
return (
|
|
1964
|
+
<div>No ref attached, isIntersecting: {String(isIntersecting)}</div>
|
|
1965
|
+
);
|
|
1966
|
+
};
|
|
1967
|
+
|
|
1968
|
+
render(<Component />);
|
|
1969
|
+
|
|
1970
|
+
// Should not observe anything since ref is not attached
|
|
1971
|
+
expect(mockObserve).not.toHaveBeenCalled();
|
|
1972
|
+
});
|
|
1973
|
+
|
|
1974
|
+
it("should handle IntersectionObserver being undefined (SSR)", () => {
|
|
1975
|
+
vi.stubGlobal("IntersectionObserver", undefined);
|
|
1976
|
+
|
|
1977
|
+
const Component = () => {
|
|
1978
|
+
const { isIntersecting, ref } = useIntersectionObserver();
|
|
1979
|
+
return (
|
|
1980
|
+
<div ref={ref as React.RefObject<HTMLDivElement>}>
|
|
1981
|
+
SSR: {String(isIntersecting)}
|
|
1982
|
+
</div>
|
|
1983
|
+
);
|
|
1984
|
+
};
|
|
1985
|
+
|
|
1986
|
+
// Should not throw
|
|
1987
|
+
render(<Component />);
|
|
1988
|
+
expect(mockObserve).not.toHaveBeenCalled();
|
|
1989
|
+
});
|
|
1990
|
+
});
|
|
1991
|
+
`,
|
|
1992
|
+
useInterval: `import { useEffect } from "react";
|
|
1993
|
+
|
|
1994
|
+
/**
|
|
1995
|
+
* Calls a callback at specified intervals.
|
|
1996
|
+
*
|
|
1997
|
+
* @param callback - Function to call on each interval
|
|
1998
|
+
* @param delay - Interval delay in milliseconds (null to pause)
|
|
1999
|
+
*
|
|
2000
|
+
* @example
|
|
2001
|
+
* useInterval(() => {
|
|
2002
|
+
* setTime(new Date());
|
|
2003
|
+
* }, 1000);
|
|
2004
|
+
*
|
|
2005
|
+
* @example
|
|
2006
|
+
* // Pause interval by passing null
|
|
2007
|
+
* useInterval(callback, isPaused ? null : 1000);
|
|
2008
|
+
*/
|
|
2009
|
+
export function useInterval(callback: () => void, delay: null | number): void {
|
|
2010
|
+
useEffect(() => {
|
|
2011
|
+
if (delay === null) return;
|
|
2012
|
+
|
|
2013
|
+
const interval = setInterval(callback, delay);
|
|
2014
|
+
return () => {
|
|
2015
|
+
clearInterval(interval);
|
|
2016
|
+
};
|
|
2017
|
+
}, [callback, delay]);
|
|
2018
|
+
}
|
|
2019
|
+
`, useInterval_test: `import { renderHook } from "@testing-library/react";
|
|
2020
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2021
|
+
|
|
2022
|
+
import { useInterval } from "./useInterval.js";
|
|
2023
|
+
|
|
2024
|
+
describe("useInterval", () => {
|
|
2025
|
+
beforeEach(() => {
|
|
2026
|
+
vi.useFakeTimers();
|
|
2027
|
+
});
|
|
2028
|
+
|
|
2029
|
+
afterEach(() => {
|
|
2030
|
+
vi.useRealTimers();
|
|
2031
|
+
});
|
|
2032
|
+
|
|
2033
|
+
it("should call callback at interval", () => {
|
|
2034
|
+
const callback = vi.fn();
|
|
2035
|
+
renderHook(() => {
|
|
2036
|
+
useInterval(callback, 1000);
|
|
2037
|
+
});
|
|
2038
|
+
|
|
2039
|
+
vi.advanceTimersByTime(1000);
|
|
2040
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
2041
|
+
|
|
2042
|
+
vi.advanceTimersByTime(1000);
|
|
2043
|
+
expect(callback).toHaveBeenCalledTimes(2);
|
|
2044
|
+
});
|
|
2045
|
+
|
|
2046
|
+
it("should pause when delay is null", () => {
|
|
2047
|
+
const callback = vi.fn();
|
|
2048
|
+
const { rerender } = renderHook(
|
|
2049
|
+
({ delay }: { delay: null | number }) => {
|
|
2050
|
+
useInterval(callback, delay);
|
|
2051
|
+
},
|
|
2052
|
+
{ initialProps: { delay: 1000 as null | number } },
|
|
2053
|
+
);
|
|
2054
|
+
|
|
2055
|
+
vi.advanceTimersByTime(1000);
|
|
2056
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
2057
|
+
|
|
2058
|
+
rerender({ delay: null });
|
|
2059
|
+
|
|
2060
|
+
vi.advanceTimersByTime(1000);
|
|
2061
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
2062
|
+
});
|
|
2063
|
+
|
|
2064
|
+
it("should cleanup interval on unmount", () => {
|
|
2065
|
+
const callback = vi.fn();
|
|
2066
|
+
const { unmount } = renderHook(() => {
|
|
2067
|
+
useInterval(callback, 1000);
|
|
2068
|
+
});
|
|
2069
|
+
|
|
2070
|
+
vi.advanceTimersByTime(1000);
|
|
2071
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
2072
|
+
|
|
2073
|
+
unmount();
|
|
2074
|
+
|
|
2075
|
+
vi.advanceTimersByTime(1000);
|
|
2076
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
2077
|
+
});
|
|
2078
|
+
});
|
|
2079
|
+
`,
|
|
2080
|
+
useIsClient: `import { useSyncExternalStore } from "react";
|
|
2081
|
+
|
|
2082
|
+
/**
|
|
2083
|
+
* Determine if the code is running on the client-side.
|
|
2084
|
+
* Useful for SSR-safe code that needs to access browser APIs.
|
|
2085
|
+
*
|
|
2086
|
+
* @returns true if running on client, false during SSR
|
|
2087
|
+
*
|
|
2088
|
+
* @example
|
|
2089
|
+
* const isClient = useIsClient();
|
|
2090
|
+
*
|
|
2091
|
+
* if (!isClient) {
|
|
2092
|
+
* return <div>Loading...</div>;
|
|
2093
|
+
* }
|
|
2094
|
+
*
|
|
2095
|
+
* // Safe to use browser APIs
|
|
2096
|
+
* return <div>Window width: {window.innerWidth}</div>;
|
|
2097
|
+
*
|
|
2098
|
+
* @example
|
|
2099
|
+
* // Conditionally render client-only components
|
|
2100
|
+
* const isClient = useIsClient();
|
|
2101
|
+
*
|
|
2102
|
+
* return (
|
|
2103
|
+
* <div>
|
|
2104
|
+
* <Header />
|
|
2105
|
+
* {isClient && <ClientOnlyMap />}
|
|
2106
|
+
* <Footer />
|
|
2107
|
+
* </div>
|
|
2108
|
+
* );
|
|
2109
|
+
*/
|
|
2110
|
+
export function useIsClient(): boolean {
|
|
2111
|
+
return useSyncExternalStore(
|
|
2112
|
+
() => {
|
|
2113
|
+
// Subscribe function that returns cleanup noop
|
|
2114
|
+
return function noopCleanup() {
|
|
2115
|
+
// Intentionally empty - no subscriptions needed
|
|
2116
|
+
};
|
|
2117
|
+
},
|
|
2118
|
+
() => true, // Client snapshot - always true on client
|
|
2119
|
+
() => false, // Server snapshot - always false during SSR
|
|
2120
|
+
);
|
|
2121
|
+
}
|
|
2122
|
+
`, useIsClient_test: `import { renderHook } from "@testing-library/react";
|
|
2123
|
+
import { describe, expect, it } from "vitest";
|
|
2124
|
+
|
|
2125
|
+
import { useIsClient } from "./useIsClient.js";
|
|
2126
|
+
|
|
2127
|
+
describe("useIsClient", () => {
|
|
2128
|
+
it("should return true after mount", () => {
|
|
2129
|
+
const { result } = renderHook(() => useIsClient());
|
|
2130
|
+
|
|
2131
|
+
// After the initial render and useEffect, isClient should be true
|
|
2132
|
+
expect(result.current).toBe(true);
|
|
2133
|
+
});
|
|
2134
|
+
|
|
2135
|
+
it("should return consistent value across re-renders", () => {
|
|
2136
|
+
const { rerender, result } = renderHook(() => useIsClient());
|
|
2137
|
+
|
|
2138
|
+
expect(result.current).toBe(true);
|
|
2139
|
+
|
|
2140
|
+
rerender();
|
|
2141
|
+
expect(result.current).toBe(true);
|
|
2142
|
+
|
|
2143
|
+
rerender();
|
|
2144
|
+
expect(result.current).toBe(true);
|
|
2145
|
+
});
|
|
2146
|
+
});
|
|
2147
|
+
`,
|
|
2148
|
+
useKeyPress: `import { useEffect, useState } from "react";
|
|
2149
|
+
|
|
2150
|
+
/**
|
|
2151
|
+
* Detects if a specific keyboard key is currently pressed.
|
|
2152
|
+
*
|
|
2153
|
+
* @param targetKey - The key to detect (e.g., "Enter", "ArrowUp", " " for space)
|
|
2154
|
+
* @returns Whether the key is currently pressed
|
|
2155
|
+
*
|
|
2156
|
+
* @example
|
|
2157
|
+
* const isEnterPressed = useKeyPress("Enter");
|
|
2158
|
+
* const isArrowUpPressed = useKeyPress("ArrowUp");
|
|
2159
|
+
*
|
|
2160
|
+
* return (
|
|
2161
|
+
* <div>
|
|
2162
|
+
* <p>Enter pressed: {isEnterPressed ? "Yes" : "No"}</p>
|
|
2163
|
+
* <p>Arrow Up pressed: {isArrowUpPressed ? "Yes" : "No"}</p>
|
|
2164
|
+
* </div>
|
|
2165
|
+
* );
|
|
2166
|
+
*/
|
|
2167
|
+
export function useKeyPress(targetKey: string): boolean {
|
|
2168
|
+
const [isKeyPressed, setIsKeyPressed] = useState(false);
|
|
2169
|
+
|
|
2170
|
+
useEffect(() => {
|
|
2171
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
2172
|
+
if (event.key === targetKey) {
|
|
2173
|
+
setIsKeyPressed(true);
|
|
2174
|
+
}
|
|
2175
|
+
};
|
|
2176
|
+
|
|
2177
|
+
const handleKeyUp = (event: KeyboardEvent) => {
|
|
2178
|
+
if (event.key === targetKey) {
|
|
2179
|
+
setIsKeyPressed(false);
|
|
2180
|
+
}
|
|
2181
|
+
};
|
|
2182
|
+
|
|
2183
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
2184
|
+
window.addEventListener("keyup", handleKeyUp);
|
|
2185
|
+
|
|
2186
|
+
return () => {
|
|
2187
|
+
window.removeEventListener("keydown", handleKeyDown);
|
|
2188
|
+
window.removeEventListener("keyup", handleKeyUp);
|
|
2189
|
+
};
|
|
2190
|
+
}, [targetKey]);
|
|
2191
|
+
|
|
2192
|
+
return isKeyPressed;
|
|
2193
|
+
}
|
|
2194
|
+
`, useKeyPress_test: `import { act, renderHook, waitFor } from "@testing-library/react";
|
|
2195
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2196
|
+
|
|
2197
|
+
import { useKeyPress } from "./useKeyPress.js";
|
|
2198
|
+
|
|
2199
|
+
describe("useKeyPress", () => {
|
|
2200
|
+
it("should initialize with false", () => {
|
|
2201
|
+
const { result } = renderHook(() => useKeyPress("Enter"));
|
|
2202
|
+
expect(result.current).toBe(false);
|
|
2203
|
+
});
|
|
2204
|
+
|
|
2205
|
+
it("should set to true on keydown", async () => {
|
|
2206
|
+
const { result } = renderHook(() => useKeyPress("Enter"));
|
|
2207
|
+
|
|
2208
|
+
act(() => {
|
|
2209
|
+
const event = new KeyboardEvent("keydown", { key: "Enter" });
|
|
2210
|
+
window.dispatchEvent(event);
|
|
2211
|
+
});
|
|
2212
|
+
|
|
2213
|
+
await waitFor(() => {
|
|
2214
|
+
expect(result.current).toBe(true);
|
|
2215
|
+
});
|
|
2216
|
+
});
|
|
2217
|
+
|
|
2218
|
+
it("should set to false on keyup", async () => {
|
|
2219
|
+
const { result } = renderHook(() => useKeyPress("Enter"));
|
|
2220
|
+
|
|
2221
|
+
act(() => {
|
|
2222
|
+
const downEvent = new KeyboardEvent("keydown", { key: "Enter" });
|
|
2223
|
+
window.dispatchEvent(downEvent);
|
|
2224
|
+
});
|
|
2225
|
+
|
|
2226
|
+
await waitFor(() => {
|
|
2227
|
+
expect(result.current).toBe(true);
|
|
2228
|
+
});
|
|
2229
|
+
|
|
2230
|
+
act(() => {
|
|
2231
|
+
const upEvent = new KeyboardEvent("keyup", { key: "Enter" });
|
|
2232
|
+
window.dispatchEvent(upEvent);
|
|
2233
|
+
});
|
|
2234
|
+
|
|
2235
|
+
await waitFor(() => {
|
|
2236
|
+
expect(result.current).toBe(false);
|
|
2237
|
+
});
|
|
2238
|
+
});
|
|
2239
|
+
|
|
2240
|
+
it("should ignore other keys", () => {
|
|
2241
|
+
const { result } = renderHook(() => useKeyPress("Enter"));
|
|
2242
|
+
|
|
2243
|
+
act(() => {
|
|
2244
|
+
const event = new KeyboardEvent("keydown", { key: "a" });
|
|
2245
|
+
window.dispatchEvent(event);
|
|
2246
|
+
});
|
|
2247
|
+
|
|
2248
|
+
// Should still be false
|
|
2249
|
+
expect(result.current).toBe(false);
|
|
2250
|
+
});
|
|
2251
|
+
|
|
2252
|
+
it("should work with different keys", async () => {
|
|
2253
|
+
const { result: resultA } = renderHook(() => useKeyPress("ArrowUp"));
|
|
2254
|
+
const { result: resultEnter } = renderHook(() => useKeyPress("Enter"));
|
|
2255
|
+
|
|
2256
|
+
act(() => {
|
|
2257
|
+
const event = new KeyboardEvent("keydown", { key: "ArrowUp" });
|
|
2258
|
+
window.dispatchEvent(event);
|
|
2259
|
+
});
|
|
2260
|
+
|
|
2261
|
+
await waitFor(() => {
|
|
2262
|
+
expect(resultA.current).toBe(true);
|
|
2263
|
+
expect(resultEnter.current).toBe(false);
|
|
2264
|
+
});
|
|
2265
|
+
});
|
|
2266
|
+
|
|
2267
|
+
it("should handle space key", async () => {
|
|
2268
|
+
const { result } = renderHook(() => useKeyPress(" "));
|
|
2269
|
+
|
|
2270
|
+
act(() => {
|
|
2271
|
+
const event = new KeyboardEvent("keydown", { key: " " });
|
|
2272
|
+
window.dispatchEvent(event);
|
|
2273
|
+
});
|
|
2274
|
+
|
|
2275
|
+
await waitFor(() => {
|
|
2276
|
+
expect(result.current).toBe(true);
|
|
2277
|
+
});
|
|
2278
|
+
});
|
|
2279
|
+
|
|
2280
|
+
it("should clean up event listeners on unmount", () => {
|
|
2281
|
+
const removeEventListenerSpy = vi.spyOn(window, "removeEventListener");
|
|
2282
|
+
const { unmount } = renderHook(() => useKeyPress("Enter"));
|
|
2283
|
+
|
|
2284
|
+
unmount();
|
|
2285
|
+
|
|
2286
|
+
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
|
2287
|
+
"keydown",
|
|
2288
|
+
expect.any(Function),
|
|
2289
|
+
);
|
|
2290
|
+
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
|
2291
|
+
"keyup",
|
|
2292
|
+
expect.any(Function),
|
|
2293
|
+
);
|
|
2294
|
+
|
|
2295
|
+
removeEventListenerSpy.mockRestore();
|
|
2296
|
+
});
|
|
2297
|
+
});
|
|
2298
|
+
`,
|
|
2299
|
+
useLocalStorage: `import { useCallback, useEffect, useState } from "react";
|
|
2300
|
+
|
|
2301
|
+
/**
|
|
2302
|
+
* Syncs state with localStorage, persisting across browser sessions.
|
|
2303
|
+
*
|
|
2304
|
+
* @param key - The localStorage key
|
|
2305
|
+
* @param initialValue - The initial value (used if no stored value exists)
|
|
2306
|
+
* @returns A tuple of [value, setValue, removeValue]
|
|
2307
|
+
*
|
|
2308
|
+
* @example
|
|
2309
|
+
* const [theme, setTheme, removeTheme] = useLocalStorage("theme", "light");
|
|
2310
|
+
*
|
|
2311
|
+
* // Update the theme (automatically persisted)
|
|
2312
|
+
* setTheme("dark");
|
|
2313
|
+
*
|
|
2314
|
+
* // Remove from localStorage
|
|
2315
|
+
* removeTheme();
|
|
2316
|
+
*/
|
|
2317
|
+
export function useLocalStorage<T>(
|
|
2318
|
+
key: string,
|
|
2319
|
+
initialValue: T,
|
|
2320
|
+
): [T, (value: ((prev: T) => T) | T) => void, () => void] {
|
|
2321
|
+
// Get initial value from localStorage or use provided initial value
|
|
2322
|
+
const [storedValue, setStoredValue] = useState<T>(() => {
|
|
2323
|
+
if (typeof window === "undefined") {
|
|
2324
|
+
return initialValue;
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
try {
|
|
2328
|
+
const item = window.localStorage.getItem(key);
|
|
2329
|
+
return item ? (JSON.parse(item) as T) : initialValue;
|
|
2330
|
+
} catch (error) {
|
|
2331
|
+
console.warn(\`Error reading localStorage key "\${key}":\`, error);
|
|
2332
|
+
return initialValue;
|
|
2333
|
+
}
|
|
2334
|
+
});
|
|
2335
|
+
|
|
2336
|
+
// Update localStorage when value changes
|
|
2337
|
+
const setValue = useCallback(
|
|
2338
|
+
(value: ((prev: T) => T) | T) => {
|
|
2339
|
+
try {
|
|
2340
|
+
setStoredValue((prev) => {
|
|
2341
|
+
const valueToStore = value instanceof Function ? value(prev) : value;
|
|
2342
|
+
if (typeof window !== "undefined") {
|
|
2343
|
+
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
|
2344
|
+
}
|
|
2345
|
+
return valueToStore;
|
|
2346
|
+
});
|
|
2347
|
+
} catch (error) {
|
|
2348
|
+
console.warn(\`Error setting localStorage key "\${key}":\`, error);
|
|
2349
|
+
}
|
|
2350
|
+
},
|
|
2351
|
+
[key],
|
|
2352
|
+
);
|
|
2353
|
+
|
|
2354
|
+
// Remove from localStorage
|
|
2355
|
+
const removeValue = useCallback(() => {
|
|
2356
|
+
try {
|
|
2357
|
+
if (typeof window !== "undefined") {
|
|
2358
|
+
window.localStorage.removeItem(key);
|
|
2359
|
+
}
|
|
2360
|
+
setStoredValue(initialValue);
|
|
2361
|
+
} catch (error) {
|
|
2362
|
+
console.warn(\`Error removing localStorage key "\${key}":\`, error);
|
|
2363
|
+
}
|
|
2364
|
+
}, [key, initialValue]);
|
|
2365
|
+
|
|
2366
|
+
// Listen for changes in other tabs/windows
|
|
2367
|
+
useEffect(() => {
|
|
2368
|
+
const handleStorageChange = (event: StorageEvent) => {
|
|
2369
|
+
if (event.key === key && event.newValue !== null) {
|
|
2370
|
+
try {
|
|
2371
|
+
setStoredValue(JSON.parse(event.newValue) as T);
|
|
2372
|
+
} catch (error) {
|
|
2373
|
+
console.warn(\`Error parsing localStorage key "\${key}":\`, error);
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
};
|
|
2377
|
+
|
|
2378
|
+
window.addEventListener("storage", handleStorageChange);
|
|
2379
|
+
return () => {
|
|
2380
|
+
window.removeEventListener("storage", handleStorageChange);
|
|
2381
|
+
};
|
|
2382
|
+
}, [key]);
|
|
2383
|
+
|
|
2384
|
+
return [storedValue, setValue, removeValue];
|
|
2385
|
+
}
|
|
2386
|
+
`, useLocalStorage_test: `import { act, renderHook } from "@testing-library/react";
|
|
2387
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2388
|
+
|
|
2389
|
+
import { useLocalStorage } from "./useLocalStorage.js";
|
|
2390
|
+
|
|
2391
|
+
describe("useLocalStorage", () => {
|
|
2392
|
+
beforeEach(() => {
|
|
2393
|
+
localStorage.clear();
|
|
2394
|
+
vi.clearAllMocks();
|
|
2395
|
+
});
|
|
2396
|
+
|
|
2397
|
+
afterEach(() => {
|
|
2398
|
+
localStorage.clear();
|
|
2399
|
+
});
|
|
2400
|
+
|
|
2401
|
+
it("should return initial value when localStorage is empty", () => {
|
|
2402
|
+
const { result } = renderHook(() =>
|
|
2403
|
+
useLocalStorage("testKey", "defaultValue"),
|
|
2404
|
+
);
|
|
2405
|
+
|
|
2406
|
+
expect(result.current[0]).toBe("defaultValue");
|
|
2407
|
+
});
|
|
2408
|
+
|
|
2409
|
+
it("should return stored value from localStorage", () => {
|
|
2410
|
+
localStorage.setItem("testKey", JSON.stringify("storedValue"));
|
|
2411
|
+
|
|
2412
|
+
const { result } = renderHook(() =>
|
|
2413
|
+
useLocalStorage("testKey", "defaultValue"),
|
|
2414
|
+
);
|
|
2415
|
+
|
|
2416
|
+
expect(result.current[0]).toBe("storedValue");
|
|
2417
|
+
});
|
|
2418
|
+
|
|
2419
|
+
it("should update value and localStorage", () => {
|
|
2420
|
+
const { result } = renderHook(() =>
|
|
2421
|
+
useLocalStorage("testKey", "defaultValue"),
|
|
2422
|
+
);
|
|
2423
|
+
|
|
2424
|
+
act(() => {
|
|
2425
|
+
result.current[1]("newValue");
|
|
2426
|
+
});
|
|
2427
|
+
|
|
2428
|
+
expect(result.current[0]).toBe("newValue");
|
|
2429
|
+
expect(localStorage.getItem("testKey")).toBe(JSON.stringify("newValue"));
|
|
2430
|
+
});
|
|
2431
|
+
|
|
2432
|
+
it("should support function updates", () => {
|
|
2433
|
+
const { result } = renderHook(() => useLocalStorage("counter", 0));
|
|
2434
|
+
|
|
2435
|
+
act(() => {
|
|
2436
|
+
result.current[1]((prev) => prev + 1);
|
|
2437
|
+
});
|
|
2438
|
+
|
|
2439
|
+
expect(result.current[0]).toBe(1);
|
|
2440
|
+
|
|
2441
|
+
act(() => {
|
|
2442
|
+
result.current[1]((prev) => prev + 5);
|
|
2443
|
+
});
|
|
2444
|
+
|
|
2445
|
+
expect(result.current[0]).toBe(6);
|
|
2446
|
+
});
|
|
2447
|
+
|
|
2448
|
+
it("should remove value from localStorage", () => {
|
|
2449
|
+
localStorage.setItem("testKey", JSON.stringify("storedValue"));
|
|
2450
|
+
|
|
2451
|
+
const { result } = renderHook(() =>
|
|
2452
|
+
useLocalStorage("testKey", "defaultValue"),
|
|
2453
|
+
);
|
|
2454
|
+
|
|
2455
|
+
act(() => {
|
|
2456
|
+
result.current[2]();
|
|
2457
|
+
});
|
|
2458
|
+
|
|
2459
|
+
expect(result.current[0]).toBe("defaultValue");
|
|
2460
|
+
expect(localStorage.getItem("testKey")).toBeNull();
|
|
2461
|
+
});
|
|
2462
|
+
|
|
2463
|
+
it("should handle complex objects", () => {
|
|
2464
|
+
const initialValue = { age: 30, name: "John" };
|
|
2465
|
+
|
|
2466
|
+
const { result } = renderHook(() => useLocalStorage("user", initialValue));
|
|
2467
|
+
|
|
2468
|
+
expect(result.current[0]).toEqual(initialValue);
|
|
2469
|
+
|
|
2470
|
+
const newValue = { age: 25, name: "Jane" };
|
|
2471
|
+
act(() => {
|
|
2472
|
+
result.current[1](newValue);
|
|
2473
|
+
});
|
|
2474
|
+
|
|
2475
|
+
expect(result.current[0]).toEqual(newValue);
|
|
2476
|
+
const storedValue = localStorage.getItem("user");
|
|
2477
|
+
expect(storedValue).not.toBeNull();
|
|
2478
|
+
expect(JSON.parse(storedValue ?? "")).toEqual(newValue);
|
|
2479
|
+
});
|
|
2480
|
+
|
|
2481
|
+
it("should handle arrays", () => {
|
|
2482
|
+
const { result } = renderHook(() => useLocalStorage<string[]>("items", []));
|
|
2483
|
+
|
|
2484
|
+
act(() => {
|
|
2485
|
+
result.current[1](["a", "b", "c"]);
|
|
2486
|
+
});
|
|
2487
|
+
|
|
2488
|
+
expect(result.current[0]).toEqual(["a", "b", "c"]);
|
|
2489
|
+
});
|
|
2490
|
+
|
|
2491
|
+
it("should handle parse errors when reading from localStorage", () => {
|
|
2492
|
+
localStorage.setItem("badKey", "invalid json");
|
|
2493
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(vi.fn());
|
|
2494
|
+
|
|
2495
|
+
const { result } = renderHook(() =>
|
|
2496
|
+
useLocalStorage("badKey", "defaultValue"),
|
|
2497
|
+
);
|
|
2498
|
+
|
|
2499
|
+
expect(result.current[0]).toBe("defaultValue");
|
|
2500
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
2501
|
+
warnSpy.mockRestore();
|
|
2502
|
+
});
|
|
2503
|
+
|
|
2504
|
+
it("should handle parse errors in storage event", () => {
|
|
2505
|
+
renderHook(() => useLocalStorage("testKey", "defaultValue"));
|
|
2506
|
+
|
|
2507
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(vi.fn());
|
|
2508
|
+
|
|
2509
|
+
act(() => {
|
|
2510
|
+
const event = new StorageEvent("storage", {
|
|
2511
|
+
key: "testKey",
|
|
2512
|
+
newValue: "invalid json",
|
|
2513
|
+
});
|
|
2514
|
+
window.dispatchEvent(event);
|
|
2515
|
+
});
|
|
2516
|
+
|
|
2517
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
2518
|
+
warnSpy.mockRestore();
|
|
2519
|
+
});
|
|
2520
|
+
|
|
2521
|
+
it("should ignore storage events for different keys", () => {
|
|
2522
|
+
const { result } = renderHook(() =>
|
|
2523
|
+
useLocalStorage("testKey", "defaultValue"),
|
|
2524
|
+
);
|
|
2525
|
+
|
|
2526
|
+
act(() => {
|
|
2527
|
+
const event = new StorageEvent("storage", {
|
|
2528
|
+
key: "otherKey",
|
|
2529
|
+
newValue: JSON.stringify("value"),
|
|
2530
|
+
});
|
|
2531
|
+
window.dispatchEvent(event);
|
|
2532
|
+
});
|
|
2533
|
+
|
|
2534
|
+
expect(result.current[0]).toBe("defaultValue");
|
|
2535
|
+
});
|
|
2536
|
+
|
|
2537
|
+
it("should ignore storage events with null newValue", () => {
|
|
2538
|
+
const { result } = renderHook(() =>
|
|
2539
|
+
useLocalStorage("testKey", "defaultValue"),
|
|
2540
|
+
);
|
|
2541
|
+
|
|
2542
|
+
act(() => {
|
|
2543
|
+
const event = new StorageEvent("storage", {
|
|
2544
|
+
key: "testKey",
|
|
2545
|
+
newValue: null,
|
|
2546
|
+
});
|
|
2547
|
+
window.dispatchEvent(event);
|
|
2548
|
+
});
|
|
2549
|
+
|
|
2550
|
+
expect(result.current[0]).toBe("defaultValue");
|
|
2551
|
+
});
|
|
2552
|
+
|
|
2553
|
+
it("should handle updates from other tabs/windows", () => {
|
|
2554
|
+
const { result } = renderHook(() =>
|
|
2555
|
+
useLocalStorage("testKey", "defaultValue"),
|
|
2556
|
+
);
|
|
2557
|
+
|
|
2558
|
+
act(() => {
|
|
2559
|
+
const event = new StorageEvent("storage", {
|
|
2560
|
+
key: "testKey",
|
|
2561
|
+
newValue: JSON.stringify("valueFromOtherTab"),
|
|
2562
|
+
});
|
|
2563
|
+
window.dispatchEvent(event);
|
|
2564
|
+
});
|
|
2565
|
+
|
|
2566
|
+
expect(result.current[0]).toBe("valueFromOtherTab");
|
|
2567
|
+
});
|
|
2568
|
+
|
|
2569
|
+
it("should cleanup event listener on unmount", () => {
|
|
2570
|
+
const removeEventListenerSpy = vi.spyOn(window, "removeEventListener");
|
|
2571
|
+
|
|
2572
|
+
const { unmount } = renderHook(() =>
|
|
2573
|
+
useLocalStorage("testKey", "defaultValue"),
|
|
2574
|
+
);
|
|
2575
|
+
|
|
2576
|
+
unmount();
|
|
2577
|
+
|
|
2578
|
+
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
|
2579
|
+
"storage",
|
|
2580
|
+
expect.any(Function),
|
|
2581
|
+
);
|
|
2582
|
+
removeEventListenerSpy.mockRestore();
|
|
2583
|
+
});
|
|
2584
|
+
});
|
|
2585
|
+
|
|
2586
|
+
it("should handle initial read with null value", () => {
|
|
2587
|
+
const { result } = renderHook(() =>
|
|
2588
|
+
useLocalStorage("nullKey", "defaultValue"),
|
|
2589
|
+
);
|
|
2590
|
+
|
|
2591
|
+
expect(result.current[0]).toBe("defaultValue");
|
|
2592
|
+
});
|
|
2593
|
+
|
|
2594
|
+
it("should handle edge case with empty string key", () => {
|
|
2595
|
+
const { result } = renderHook(() => useLocalStorage("", "defaultValue"));
|
|
2596
|
+
|
|
2597
|
+
act(() => {
|
|
2598
|
+
result.current[1]("newValue");
|
|
2599
|
+
});
|
|
2600
|
+
|
|
2601
|
+
expect(result.current[0]).toBe("newValue");
|
|
2602
|
+
});
|
|
2603
|
+
`,
|
|
2604
|
+
useLockBodyScroll: `import { useEffect, useRef } from "react";
|
|
2605
|
+
|
|
2606
|
+
/**
|
|
2607
|
+
* Temporarily disable scrolling on the document body.
|
|
2608
|
+
* Useful for modals, drawers, and other overlays.
|
|
2609
|
+
*
|
|
2610
|
+
* @example
|
|
2611
|
+
* // Lock body scroll when modal is open
|
|
2612
|
+
* function Modal({ isOpen }) {
|
|
2613
|
+
* useLockBodyScroll(isOpen);
|
|
2614
|
+
*
|
|
2615
|
+
* if (!isOpen) return null;
|
|
2616
|
+
* return <div className="modal">...</div>;
|
|
2617
|
+
* }
|
|
2618
|
+
*
|
|
2619
|
+
* @example
|
|
2620
|
+
* // Always lock when component is mounted
|
|
2621
|
+
* function FullscreenOverlay() {
|
|
2622
|
+
* useLockBodyScroll();
|
|
2623
|
+
* return <div className="overlay">...</div>;
|
|
2624
|
+
* }
|
|
2625
|
+
*/
|
|
2626
|
+
export function useLockBodyScroll(lock = true): void {
|
|
2627
|
+
const originalStyle = useRef<string | undefined>(undefined);
|
|
2628
|
+
|
|
2629
|
+
useEffect(() => {
|
|
2630
|
+
if (typeof document === "undefined") {
|
|
2631
|
+
return;
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
if (!lock) {
|
|
2635
|
+
return;
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
// Store the original overflow style
|
|
2639
|
+
originalStyle.current = document.body.style.overflow;
|
|
2640
|
+
|
|
2641
|
+
// Lock the body scroll
|
|
2642
|
+
document.body.style.overflow = "hidden";
|
|
2643
|
+
|
|
2644
|
+
return () => {
|
|
2645
|
+
// Restore the original overflow style
|
|
2646
|
+
document.body.style.overflow = originalStyle.current ?? "";
|
|
2647
|
+
};
|
|
2648
|
+
}, [lock]);
|
|
2649
|
+
}
|
|
2650
|
+
`, useLockBodyScroll_test: `import { cleanup, renderHook } from "@testing-library/react";
|
|
2651
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2652
|
+
|
|
2653
|
+
import { useLockBodyScroll } from "./useLockBodyScroll.js";
|
|
2654
|
+
|
|
2655
|
+
describe("useLockBodyScroll", () => {
|
|
2656
|
+
beforeEach(() => {
|
|
2657
|
+
document.body.style.overflow = "";
|
|
2658
|
+
});
|
|
2659
|
+
|
|
2660
|
+
afterEach(() => {
|
|
2661
|
+
cleanup();
|
|
2662
|
+
document.body.style.overflow = "";
|
|
2663
|
+
});
|
|
2664
|
+
|
|
2665
|
+
it("should lock body scroll by default", () => {
|
|
2666
|
+
renderHook(() => {
|
|
2667
|
+
useLockBodyScroll();
|
|
2668
|
+
});
|
|
2669
|
+
|
|
2670
|
+
expect(document.body.style.overflow).toBe("hidden");
|
|
2671
|
+
});
|
|
2672
|
+
|
|
2673
|
+
it("should lock body scroll when lock is true", () => {
|
|
2674
|
+
renderHook(() => {
|
|
2675
|
+
useLockBodyScroll(true);
|
|
2676
|
+
});
|
|
2677
|
+
|
|
2678
|
+
expect(document.body.style.overflow).toBe("hidden");
|
|
2679
|
+
});
|
|
2680
|
+
|
|
2681
|
+
it("should not lock body scroll when lock is false", () => {
|
|
2682
|
+
renderHook(() => {
|
|
2683
|
+
useLockBodyScroll(false);
|
|
2684
|
+
});
|
|
2685
|
+
|
|
2686
|
+
expect(document.body.style.overflow).toBe("");
|
|
2687
|
+
});
|
|
2688
|
+
|
|
2689
|
+
it("should restore original overflow on unmount", () => {
|
|
2690
|
+
document.body.style.overflow = "auto";
|
|
2691
|
+
|
|
2692
|
+
const { unmount } = renderHook(() => {
|
|
2693
|
+
useLockBodyScroll();
|
|
2694
|
+
});
|
|
2695
|
+
|
|
2696
|
+
expect(document.body.style.overflow).toBe("hidden");
|
|
2697
|
+
|
|
2698
|
+
unmount();
|
|
2699
|
+
expect(document.body.style.overflow).toBe("auto");
|
|
2700
|
+
});
|
|
2701
|
+
|
|
2702
|
+
it("should toggle lock when lock parameter changes", () => {
|
|
2703
|
+
const { rerender } = renderHook(
|
|
2704
|
+
({ lock }) => {
|
|
2705
|
+
useLockBodyScroll(lock);
|
|
2706
|
+
},
|
|
2707
|
+
{
|
|
2708
|
+
initialProps: { lock: false },
|
|
2709
|
+
},
|
|
2710
|
+
);
|
|
2711
|
+
|
|
2712
|
+
expect(document.body.style.overflow).toBe("");
|
|
2713
|
+
|
|
2714
|
+
rerender({ lock: true });
|
|
2715
|
+
expect(document.body.style.overflow).toBe("hidden");
|
|
2716
|
+
|
|
2717
|
+
rerender({ lock: false });
|
|
2718
|
+
expect(document.body.style.overflow).toBe("");
|
|
2719
|
+
});
|
|
2720
|
+
|
|
2721
|
+
it("should handle empty string as original overflow", () => {
|
|
2722
|
+
document.body.style.overflow = "";
|
|
2723
|
+
|
|
2724
|
+
const { unmount } = renderHook(() => {
|
|
2725
|
+
useLockBodyScroll();
|
|
2726
|
+
});
|
|
2727
|
+
|
|
2728
|
+
expect(document.body.style.overflow).toBe("hidden");
|
|
2729
|
+
|
|
2730
|
+
unmount();
|
|
2731
|
+
expect(document.body.style.overflow).toBe("");
|
|
2732
|
+
});
|
|
2733
|
+
|
|
2734
|
+
it("should preserve scroll-y overflow style", () => {
|
|
2735
|
+
document.body.style.overflow = "scroll";
|
|
2736
|
+
|
|
2737
|
+
const { unmount } = renderHook(() => {
|
|
2738
|
+
useLockBodyScroll();
|
|
2739
|
+
});
|
|
2740
|
+
|
|
2741
|
+
expect(document.body.style.overflow).toBe("hidden");
|
|
2742
|
+
|
|
2743
|
+
unmount();
|
|
2744
|
+
expect(document.body.style.overflow).toBe("scroll");
|
|
2745
|
+
});
|
|
2746
|
+
});
|
|
2747
|
+
`,
|
|
2748
|
+
useMeasure: `import { useCallback, useEffect, useRef, useState } from "react";
|
|
2749
|
+
|
|
2750
|
+
export interface UseMeasureRect {
|
|
2751
|
+
/** Bottom position relative to viewport */
|
|
2752
|
+
bottom: number;
|
|
2753
|
+
/** Element height */
|
|
2754
|
+
height: number;
|
|
2755
|
+
/** Left position relative to viewport */
|
|
2756
|
+
left: number;
|
|
2757
|
+
/** Right position relative to viewport */
|
|
2758
|
+
right: number;
|
|
2759
|
+
/** Top position relative to viewport */
|
|
2760
|
+
top: number;
|
|
2761
|
+
/** Element width */
|
|
2762
|
+
width: number;
|
|
2763
|
+
/** X position (same as left) */
|
|
2764
|
+
x: number;
|
|
2765
|
+
/** Y position (same as top) */
|
|
2766
|
+
y: number;
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
export interface UseMeasureResult<T extends Element> {
|
|
2770
|
+
/** The measured dimensions of the element */
|
|
2771
|
+
rect: UseMeasureRect;
|
|
2772
|
+
/** Ref to attach to the element to measure */
|
|
2773
|
+
ref: React.RefCallback<T>;
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
const defaultRect: UseMeasureRect = {
|
|
2777
|
+
bottom: 0,
|
|
2778
|
+
height: 0,
|
|
2779
|
+
left: 0,
|
|
2780
|
+
right: 0,
|
|
2781
|
+
top: 0,
|
|
2782
|
+
width: 0,
|
|
2783
|
+
x: 0,
|
|
2784
|
+
y: 0,
|
|
2785
|
+
};
|
|
2786
|
+
|
|
2787
|
+
/**
|
|
2788
|
+
* Measure the dimensions of a DOM element using ResizeObserver.
|
|
2789
|
+
*
|
|
2790
|
+
* @returns Object containing ref callback and rect measurements
|
|
2791
|
+
*
|
|
2792
|
+
* @example
|
|
2793
|
+
* const { ref, rect } = useMeasure<HTMLDivElement>();
|
|
2794
|
+
*
|
|
2795
|
+
* return (
|
|
2796
|
+
* <div ref={ref}>
|
|
2797
|
+
* <p>Width: {rect.width}px</p>
|
|
2798
|
+
* <p>Height: {rect.height}px</p>
|
|
2799
|
+
* </div>
|
|
2800
|
+
* );
|
|
2801
|
+
*
|
|
2802
|
+
* @example
|
|
2803
|
+
* // Responsive component
|
|
2804
|
+
* const { ref, rect } = useMeasure<HTMLDivElement>();
|
|
2805
|
+
* const isCompact = rect.width < 400;
|
|
2806
|
+
*
|
|
2807
|
+
* return (
|
|
2808
|
+
* <nav ref={ref} className={isCompact ? 'compact' : 'full'}>
|
|
2809
|
+
* {isCompact ? <MobileMenu /> : <DesktopMenu />}
|
|
2810
|
+
* </nav>
|
|
2811
|
+
* );
|
|
2812
|
+
*/
|
|
2813
|
+
export function useMeasure<
|
|
2814
|
+
T extends Element = HTMLDivElement,
|
|
2815
|
+
>(): UseMeasureResult<T> {
|
|
2816
|
+
const [element, setElement] = useState<null | T>(null);
|
|
2817
|
+
const [rect, setRect] = useState<UseMeasureRect>(defaultRect);
|
|
2818
|
+
|
|
2819
|
+
const observerRef = useRef<null | ResizeObserver>(null);
|
|
2820
|
+
|
|
2821
|
+
const ref = useCallback((node: null | T) => {
|
|
2822
|
+
setElement(node);
|
|
2823
|
+
}, []);
|
|
2824
|
+
|
|
2825
|
+
useEffect(() => {
|
|
2826
|
+
if (!element) {
|
|
2827
|
+
return;
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
if (typeof ResizeObserver === "undefined") {
|
|
2831
|
+
// Fallback: get initial dimensions without observing changes
|
|
2832
|
+
const boundingRect = element.getBoundingClientRect();
|
|
2833
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
2834
|
+
setRect({
|
|
2835
|
+
bottom: boundingRect.bottom,
|
|
2836
|
+
height: boundingRect.height,
|
|
2837
|
+
left: boundingRect.left,
|
|
2838
|
+
right: boundingRect.right,
|
|
2839
|
+
top: boundingRect.top,
|
|
2840
|
+
width: boundingRect.width,
|
|
2841
|
+
x: boundingRect.x,
|
|
2842
|
+
y: boundingRect.y,
|
|
2843
|
+
});
|
|
2844
|
+
return;
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
observerRef.current = new ResizeObserver(([entry]) => {
|
|
2848
|
+
if (entry) {
|
|
2849
|
+
const boundingRect = entry.target.getBoundingClientRect();
|
|
2850
|
+
setRect({
|
|
2851
|
+
bottom: boundingRect.bottom,
|
|
2852
|
+
height: boundingRect.height,
|
|
2853
|
+
left: boundingRect.left,
|
|
2854
|
+
right: boundingRect.right,
|
|
2855
|
+
top: boundingRect.top,
|
|
2856
|
+
width: boundingRect.width,
|
|
2857
|
+
x: boundingRect.x,
|
|
2858
|
+
y: boundingRect.y,
|
|
2859
|
+
});
|
|
2860
|
+
}
|
|
2861
|
+
});
|
|
2862
|
+
|
|
2863
|
+
observerRef.current.observe(element);
|
|
2864
|
+
|
|
2865
|
+
return () => {
|
|
2866
|
+
observerRef.current?.disconnect();
|
|
2867
|
+
};
|
|
2868
|
+
}, [element]);
|
|
2869
|
+
|
|
2870
|
+
return { rect, ref };
|
|
2871
|
+
}
|
|
2872
|
+
`, useMeasure_test: `import { cleanup, render, waitFor } from "@testing-library/react";
|
|
2873
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2874
|
+
|
|
2875
|
+
import { useMeasure } from "./useMeasure.js";
|
|
2876
|
+
|
|
2877
|
+
describe("useMeasure", () => {
|
|
2878
|
+
let mockObserve: ReturnType<typeof vi.fn>;
|
|
2879
|
+
let mockDisconnect: ReturnType<typeof vi.fn>;
|
|
2880
|
+
let mockCallback: (entries: ResizeObserverEntry[]) => void;
|
|
2881
|
+
|
|
2882
|
+
const mockBoundingRect = {
|
|
2883
|
+
bottom: 200,
|
|
2884
|
+
height: 100,
|
|
2885
|
+
left: 50,
|
|
2886
|
+
right: 250,
|
|
2887
|
+
top: 100,
|
|
2888
|
+
width: 200,
|
|
2889
|
+
x: 50,
|
|
2890
|
+
y: 100,
|
|
2891
|
+
};
|
|
2892
|
+
|
|
2893
|
+
beforeEach(() => {
|
|
2894
|
+
mockObserve = vi.fn();
|
|
2895
|
+
mockDisconnect = vi.fn();
|
|
2896
|
+
|
|
2897
|
+
vi.stubGlobal(
|
|
2898
|
+
"ResizeObserver",
|
|
2899
|
+
class MockResizeObserver {
|
|
2900
|
+
disconnect = mockDisconnect;
|
|
2901
|
+
observe = mockObserve;
|
|
2902
|
+
unobserve = vi.fn();
|
|
2903
|
+
constructor(callback: (entries: ResizeObserverEntry[]) => void) {
|
|
2904
|
+
mockCallback = callback;
|
|
2905
|
+
}
|
|
2906
|
+
},
|
|
2907
|
+
);
|
|
2908
|
+
});
|
|
2909
|
+
|
|
2910
|
+
afterEach(() => {
|
|
2911
|
+
cleanup();
|
|
2912
|
+
vi.unstubAllGlobals();
|
|
2913
|
+
});
|
|
2914
|
+
|
|
2915
|
+
it("should return ref and initial rect with zeros", () => {
|
|
2916
|
+
let result: ReturnType<typeof useMeasure> | undefined;
|
|
2917
|
+
|
|
2918
|
+
const Component = () => {
|
|
2919
|
+
result = useMeasure();
|
|
2920
|
+
return <div ref={result.ref}>Test</div>;
|
|
2921
|
+
};
|
|
2922
|
+
|
|
2923
|
+
render(<Component />);
|
|
2924
|
+
|
|
2925
|
+
expect(typeof result?.ref).toBe("function");
|
|
2926
|
+
expect(result?.rect).toEqual({
|
|
2927
|
+
bottom: 0,
|
|
2928
|
+
height: 0,
|
|
2929
|
+
left: 0,
|
|
2930
|
+
right: 0,
|
|
2931
|
+
top: 0,
|
|
2932
|
+
width: 0,
|
|
2933
|
+
x: 0,
|
|
2934
|
+
y: 0,
|
|
2935
|
+
});
|
|
2936
|
+
});
|
|
2937
|
+
|
|
2938
|
+
it("should observe the element", () => {
|
|
2939
|
+
const Component = () => {
|
|
2940
|
+
const { ref } = useMeasure();
|
|
2941
|
+
return <div ref={ref}>Test</div>;
|
|
2942
|
+
};
|
|
2943
|
+
|
|
2944
|
+
render(<Component />);
|
|
2945
|
+
|
|
2946
|
+
expect(mockObserve).toHaveBeenCalled();
|
|
2947
|
+
});
|
|
2948
|
+
|
|
2949
|
+
it("should update rect when element is resized", async () => {
|
|
2950
|
+
let result: ReturnType<typeof useMeasure> | undefined;
|
|
2951
|
+
|
|
2952
|
+
const Component = () => {
|
|
2953
|
+
result = useMeasure();
|
|
2954
|
+
return <div ref={result.ref}>Test</div>;
|
|
2955
|
+
};
|
|
2956
|
+
|
|
2957
|
+
render(<Component />);
|
|
2958
|
+
|
|
2959
|
+
const mockEntry = {
|
|
2960
|
+
target: {
|
|
2961
|
+
getBoundingClientRect: () => mockBoundingRect,
|
|
2962
|
+
},
|
|
2963
|
+
} as unknown as ResizeObserverEntry;
|
|
2964
|
+
|
|
2965
|
+
mockCallback([mockEntry]);
|
|
2966
|
+
|
|
2967
|
+
await waitFor(() => {
|
|
2968
|
+
expect(result?.rect).toEqual(mockBoundingRect);
|
|
2969
|
+
});
|
|
2970
|
+
});
|
|
2971
|
+
|
|
2972
|
+
it("should disconnect on unmount", () => {
|
|
2973
|
+
const Component = () => {
|
|
2974
|
+
const { ref } = useMeasure();
|
|
2975
|
+
return <div ref={ref}>Test</div>;
|
|
2976
|
+
};
|
|
2977
|
+
|
|
2978
|
+
const { unmount } = render(<Component />);
|
|
2979
|
+
unmount();
|
|
2980
|
+
|
|
2981
|
+
expect(mockDisconnect).toHaveBeenCalled();
|
|
2982
|
+
});
|
|
2983
|
+
|
|
2984
|
+
it("should handle null ref", () => {
|
|
2985
|
+
let result: ReturnType<typeof useMeasure>;
|
|
2986
|
+
|
|
2987
|
+
const Component = ({ show }: { show: boolean }) => {
|
|
2988
|
+
result = useMeasure();
|
|
2989
|
+
return show ? <div ref={result.ref}>Test</div> : null;
|
|
2990
|
+
};
|
|
2991
|
+
|
|
2992
|
+
const { rerender } = render(<Component show={true} />);
|
|
2993
|
+
|
|
2994
|
+
expect(mockObserve).toHaveBeenCalledTimes(1);
|
|
2995
|
+
|
|
2996
|
+
rerender(<Component show={false} />);
|
|
2997
|
+
|
|
2998
|
+
// Observer should be disconnected when element is removed
|
|
2999
|
+
expect(mockDisconnect).toHaveBeenCalled();
|
|
3000
|
+
});
|
|
3001
|
+
|
|
3002
|
+
it("should re-observe when element changes", async () => {
|
|
3003
|
+
let result: ReturnType<typeof useMeasure>;
|
|
3004
|
+
|
|
3005
|
+
const Component = ({ id }: { id: string }) => {
|
|
3006
|
+
result = useMeasure();
|
|
3007
|
+
return (
|
|
3008
|
+
<div key={id} ref={result.ref}>
|
|
3009
|
+
{id}
|
|
3010
|
+
</div>
|
|
3011
|
+
);
|
|
3012
|
+
};
|
|
3013
|
+
|
|
3014
|
+
const { rerender } = render(<Component id="first" />);
|
|
3015
|
+
|
|
3016
|
+
expect(mockObserve).toHaveBeenCalledTimes(1);
|
|
3017
|
+
|
|
3018
|
+
rerender(<Component id="second" />);
|
|
3019
|
+
|
|
3020
|
+
await waitFor(() => {
|
|
3021
|
+
expect(mockObserve).toHaveBeenCalledTimes(2);
|
|
3022
|
+
});
|
|
3023
|
+
});
|
|
3024
|
+
|
|
3025
|
+
it("should fallback to getBoundingClientRect when ResizeObserver is undefined", () => {
|
|
3026
|
+
vi.stubGlobal("ResizeObserver", undefined);
|
|
3027
|
+
|
|
3028
|
+
const mockRect = {
|
|
3029
|
+
bottom: 100,
|
|
3030
|
+
height: 50,
|
|
3031
|
+
left: 10,
|
|
3032
|
+
right: 110,
|
|
3033
|
+
top: 50,
|
|
3034
|
+
width: 100,
|
|
3035
|
+
x: 10,
|
|
3036
|
+
y: 50,
|
|
3037
|
+
};
|
|
3038
|
+
|
|
3039
|
+
let result: ReturnType<typeof useMeasure> | undefined;
|
|
3040
|
+
|
|
3041
|
+
const Component = () => {
|
|
3042
|
+
result = useMeasure();
|
|
3043
|
+
return (
|
|
3044
|
+
<div
|
|
3045
|
+
ref={(node) => {
|
|
3046
|
+
if (node) {
|
|
3047
|
+
vi.spyOn(node, "getBoundingClientRect").mockReturnValue(
|
|
3048
|
+
mockRect as DOMRect,
|
|
3049
|
+
);
|
|
3050
|
+
}
|
|
3051
|
+
result?.ref(node);
|
|
3052
|
+
}}
|
|
3053
|
+
>
|
|
3054
|
+
Test
|
|
3055
|
+
</div>
|
|
3056
|
+
);
|
|
3057
|
+
};
|
|
3058
|
+
|
|
3059
|
+
render(<Component />);
|
|
3060
|
+
|
|
3061
|
+
// Should have initial rect from getBoundingClientRect fallback
|
|
3062
|
+
expect(result?.rect.width).toBe(100);
|
|
3063
|
+
expect(result?.rect.height).toBe(50);
|
|
3064
|
+
});
|
|
3065
|
+
});
|
|
3066
|
+
`,
|
|
3067
|
+
useMedia: `import { useEffect, useState } from "react";
|
|
3068
|
+
|
|
3069
|
+
/**
|
|
3070
|
+
* Reacts to CSS media query changes.
|
|
3071
|
+
*
|
|
3072
|
+
* @param query - CSS media query string (e.g., "(max-width: 768px)")
|
|
3073
|
+
* @returns Whether the media query matches
|
|
3074
|
+
*
|
|
3075
|
+
* @example
|
|
3076
|
+
* const isMobile = useMedia("(max-width: 768px)");
|
|
3077
|
+
* const isDarkMode = useMedia("(prefers-color-scheme: dark)");
|
|
3078
|
+
*
|
|
3079
|
+
* return (
|
|
3080
|
+
* <div>
|
|
3081
|
+
* <p>Is mobile: {isMobile ? "Yes" : "No"}</p>
|
|
3082
|
+
* <p>Dark mode: {isDarkMode ? "Yes" : "No"}</p>
|
|
3083
|
+
* </div>
|
|
3084
|
+
* );
|
|
3085
|
+
*/
|
|
3086
|
+
export function useMedia(query: string): boolean {
|
|
3087
|
+
const [matches, setMatches] = useState(false);
|
|
3088
|
+
|
|
3089
|
+
useEffect(() => {
|
|
3090
|
+
// Check if window is defined (SSR safety)
|
|
3091
|
+
if (typeof window === "undefined") {
|
|
3092
|
+
return undefined;
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
try {
|
|
3096
|
+
const mediaQueryList = window.matchMedia(query);
|
|
3097
|
+
|
|
3098
|
+
// Set initial value
|
|
3099
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
3100
|
+
setMatches(mediaQueryList.matches);
|
|
3101
|
+
|
|
3102
|
+
// Create listener function
|
|
3103
|
+
const handleChange = (e: MediaQueryListEvent) => {
|
|
3104
|
+
setMatches(e.matches);
|
|
3105
|
+
};
|
|
3106
|
+
|
|
3107
|
+
// Modern browsers use addEventListener
|
|
3108
|
+
mediaQueryList.addEventListener("change", handleChange);
|
|
3109
|
+
return () => {
|
|
3110
|
+
mediaQueryList.removeEventListener("change", handleChange);
|
|
3111
|
+
};
|
|
3112
|
+
} catch (error) {
|
|
3113
|
+
console.warn(\`Invalid media query: "\${query}"\`, error);
|
|
3114
|
+
return undefined;
|
|
3115
|
+
}
|
|
3116
|
+
}, [query]);
|
|
3117
|
+
|
|
3118
|
+
return matches;
|
|
3119
|
+
}
|
|
3120
|
+
`, useMedia_test: `import { renderHook, waitFor } from "@testing-library/react";
|
|
3121
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3122
|
+
|
|
3123
|
+
import { useMedia } from "./useMedia.js";
|
|
3124
|
+
|
|
3125
|
+
describe("useMedia", () => {
|
|
3126
|
+
beforeEach(() => {
|
|
3127
|
+
vi.clearAllMocks();
|
|
3128
|
+
});
|
|
3129
|
+
|
|
3130
|
+
it("should initialize with false for non-matching query", () => {
|
|
3131
|
+
// Mock window.matchMedia
|
|
3132
|
+
Object.defineProperty(window, "matchMedia", {
|
|
3133
|
+
value: vi.fn(() => ({
|
|
3134
|
+
addEventListener: vi.fn(),
|
|
3135
|
+
matches: false,
|
|
3136
|
+
removeEventListener: vi.fn(),
|
|
3137
|
+
})),
|
|
3138
|
+
writable: true,
|
|
3139
|
+
});
|
|
3140
|
+
|
|
3141
|
+
const { result } = renderHook(() => useMedia("(max-width: 480px)"));
|
|
3142
|
+
expect(result.current).toBe(false);
|
|
3143
|
+
});
|
|
3144
|
+
|
|
3145
|
+
it("should match media query", () => {
|
|
3146
|
+
Object.defineProperty(window, "matchMedia", {
|
|
3147
|
+
value: vi.fn(() => ({
|
|
3148
|
+
addEventListener: vi.fn(),
|
|
3149
|
+
matches: true,
|
|
3150
|
+
removeEventListener: vi.fn(),
|
|
3151
|
+
})),
|
|
3152
|
+
writable: true,
|
|
3153
|
+
});
|
|
3154
|
+
|
|
3155
|
+
const { result } = renderHook(() => useMedia("(min-width: 0px)"));
|
|
3156
|
+
expect(result.current).toBe(true);
|
|
3157
|
+
});
|
|
3158
|
+
|
|
3159
|
+
it("should update on media query change", async () => {
|
|
3160
|
+
let listenerFn: ((e: MediaQueryListEvent) => void) | null = null;
|
|
3161
|
+
|
|
3162
|
+
Object.defineProperty(window, "matchMedia", {
|
|
3163
|
+
value: vi.fn(() => ({
|
|
3164
|
+
addEventListener: vi.fn(
|
|
3165
|
+
(_: string, fn: (e: MediaQueryListEvent) => void) => {
|
|
3166
|
+
listenerFn = fn;
|
|
3167
|
+
},
|
|
3168
|
+
),
|
|
3169
|
+
matches: false,
|
|
3170
|
+
removeEventListener: vi.fn(),
|
|
3171
|
+
})),
|
|
3172
|
+
writable: true,
|
|
3173
|
+
});
|
|
3174
|
+
|
|
3175
|
+
const { result } = renderHook(() => useMedia("(max-width: 768px)"));
|
|
3176
|
+
expect(result.current).toBe(false);
|
|
3177
|
+
|
|
3178
|
+
// Trigger the change listener
|
|
3179
|
+
expect(listenerFn).toBeDefined();
|
|
3180
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
3181
|
+
listenerFn!({
|
|
3182
|
+
matches: true,
|
|
3183
|
+
media: "(max-width: 768px)",
|
|
3184
|
+
} as unknown as MediaQueryListEvent);
|
|
3185
|
+
|
|
3186
|
+
await waitFor(() => {
|
|
3187
|
+
expect(result.current).toBe(true);
|
|
3188
|
+
});
|
|
3189
|
+
});
|
|
3190
|
+
|
|
3191
|
+
it("should handle invalid media query gracefully", () => {
|
|
3192
|
+
const consoleSpy = vi
|
|
3193
|
+
.spyOn(console, "warn")
|
|
3194
|
+
.mockImplementation(() => undefined);
|
|
3195
|
+
|
|
3196
|
+
Object.defineProperty(window, "matchMedia", {
|
|
3197
|
+
value: vi.fn(() => {
|
|
3198
|
+
throw new Error("Invalid media query");
|
|
3199
|
+
}),
|
|
3200
|
+
writable: true,
|
|
3201
|
+
});
|
|
3202
|
+
|
|
3203
|
+
const { result } = renderHook(() => useMedia("invalid()"));
|
|
3204
|
+
expect(result.current).toBe(false);
|
|
3205
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
3206
|
+
|
|
3207
|
+
consoleSpy.mockRestore();
|
|
3208
|
+
});
|
|
3209
|
+
|
|
3210
|
+
it("should work with prefers-color-scheme", () => {
|
|
3211
|
+
Object.defineProperty(window, "matchMedia", {
|
|
3212
|
+
value: vi.fn(() => ({
|
|
3213
|
+
addEventListener: vi.fn(),
|
|
3214
|
+
matches: false,
|
|
3215
|
+
removeEventListener: vi.fn(),
|
|
3216
|
+
})),
|
|
3217
|
+
writable: true,
|
|
3218
|
+
});
|
|
3219
|
+
|
|
3220
|
+
const { result } = renderHook(() =>
|
|
3221
|
+
useMedia("(prefers-color-scheme: dark)"),
|
|
3222
|
+
);
|
|
3223
|
+
expect(typeof result.current).toBe("boolean");
|
|
3224
|
+
});
|
|
3225
|
+
|
|
3226
|
+
it("should update when query changes", () => {
|
|
3227
|
+
Object.defineProperty(window, "matchMedia", {
|
|
3228
|
+
value: vi.fn(() => ({
|
|
3229
|
+
addEventListener: vi.fn(),
|
|
3230
|
+
matches: false,
|
|
3231
|
+
removeEventListener: vi.fn(),
|
|
3232
|
+
})),
|
|
3233
|
+
writable: true,
|
|
3234
|
+
});
|
|
3235
|
+
|
|
3236
|
+
const { rerender, result } = renderHook(({ query }) => useMedia(query), {
|
|
3237
|
+
initialProps: { query: "(max-width: 768px)" },
|
|
3238
|
+
});
|
|
3239
|
+
|
|
3240
|
+
rerender({ query: "(max-width: 480px)" });
|
|
3241
|
+
|
|
3242
|
+
expect(typeof result.current).toBe("boolean");
|
|
3243
|
+
});
|
|
3244
|
+
|
|
3245
|
+
it("should clean up listeners on unmount", () => {
|
|
3246
|
+
const mockRemoveEventListener = vi.fn();
|
|
3247
|
+
|
|
3248
|
+
Object.defineProperty(window, "matchMedia", {
|
|
3249
|
+
value: vi.fn(() => ({
|
|
3250
|
+
addEventListener: vi.fn(),
|
|
3251
|
+
matches: false,
|
|
3252
|
+
removeEventListener: mockRemoveEventListener,
|
|
3253
|
+
})),
|
|
3254
|
+
writable: true,
|
|
3255
|
+
});
|
|
3256
|
+
|
|
3257
|
+
const { unmount } = renderHook(() => useMedia("(max-width: 768px)"));
|
|
3258
|
+
|
|
3259
|
+
unmount();
|
|
3260
|
+
|
|
3261
|
+
expect(mockRemoveEventListener).toHaveBeenCalledWith(
|
|
3262
|
+
"change",
|
|
3263
|
+
expect.any(Function),
|
|
3264
|
+
);
|
|
3265
|
+
});
|
|
3266
|
+
});
|
|
3267
|
+
`,
|
|
3268
|
+
useMount: `import { useEffect } from "react";
|
|
3269
|
+
|
|
3270
|
+
/**
|
|
3271
|
+
* Calls a callback on component mount.
|
|
3272
|
+
*
|
|
3273
|
+
* @param callback - Function to call on mount
|
|
3274
|
+
*
|
|
3275
|
+
* @example
|
|
3276
|
+
* useMount(() => {
|
|
3277
|
+
* console.log("Component mounted");
|
|
3278
|
+
* // Initialize resources
|
|
3279
|
+
* });
|
|
3280
|
+
*/
|
|
3281
|
+
export function useMount(callback: () => void): void {
|
|
3282
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally only run on mount
|
|
3283
|
+
useEffect(callback, []);
|
|
3284
|
+
}
|
|
3285
|
+
`, useMount_test: `import { renderHook } from "@testing-library/react";
|
|
3286
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3287
|
+
|
|
3288
|
+
import { useMount } from "./useMount.js";
|
|
3289
|
+
|
|
3290
|
+
describe("useMount", () => {
|
|
3291
|
+
it("should call callback on mount", () => {
|
|
3292
|
+
const callback = vi.fn();
|
|
3293
|
+
renderHook(() => {
|
|
3294
|
+
useMount(callback);
|
|
3295
|
+
});
|
|
3296
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
3297
|
+
});
|
|
3298
|
+
|
|
3299
|
+
it("should not call callback on rerender", () => {
|
|
3300
|
+
const callback = vi.fn();
|
|
3301
|
+
const { rerender } = renderHook(() => {
|
|
3302
|
+
useMount(callback);
|
|
3303
|
+
});
|
|
3304
|
+
|
|
3305
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
3306
|
+
|
|
3307
|
+
rerender();
|
|
3308
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
3309
|
+
});
|
|
3310
|
+
});
|
|
3311
|
+
`,
|
|
3312
|
+
usePrevious: `import { useState } from "react";
|
|
3313
|
+
|
|
3314
|
+
/**
|
|
3315
|
+
* Tracks the previous value or prop.
|
|
3316
|
+
*
|
|
3317
|
+
* @param value - The current value to track
|
|
3318
|
+
* @returns The previous value from the last render
|
|
3319
|
+
*
|
|
3320
|
+
* @example
|
|
3321
|
+
* const [count, setCount] = useState(0);
|
|
3322
|
+
* const prevCount = usePrevious(count);
|
|
3323
|
+
*
|
|
3324
|
+
* useEffect(() => {
|
|
3325
|
+
* console.log(\`Current: \${count}, Previous: \${prevCount}\`);
|
|
3326
|
+
* }, [count, prevCount]);
|
|
3327
|
+
*/
|
|
3328
|
+
export function usePrevious<T>(value: T): T | undefined {
|
|
3329
|
+
const [current, setCurrent] = useState<T>(value);
|
|
3330
|
+
const [previous, setPrevious] = useState<T | undefined>(undefined);
|
|
3331
|
+
|
|
3332
|
+
if (current !== value) {
|
|
3333
|
+
setPrevious(current);
|
|
3334
|
+
setCurrent(value);
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3337
|
+
return previous;
|
|
3338
|
+
}
|
|
3339
|
+
`, usePrevious_test: `import { renderHook } from "@testing-library/react";
|
|
3340
|
+
import { describe, expect, it } from "vitest";
|
|
3341
|
+
|
|
3342
|
+
import { usePrevious } from "./usePrevious.js";
|
|
3343
|
+
|
|
3344
|
+
describe("usePrevious", () => {
|
|
3345
|
+
it("should return undefined on first render", () => {
|
|
3346
|
+
const { result } = renderHook(() => usePrevious(5));
|
|
3347
|
+
expect(result.current).toBeUndefined();
|
|
3348
|
+
});
|
|
3349
|
+
|
|
3350
|
+
it("should return previous value on subsequent renders", () => {
|
|
3351
|
+
const { rerender, result } = renderHook(({ value }) => usePrevious(value), {
|
|
3352
|
+
initialProps: { value: 1 },
|
|
3353
|
+
});
|
|
3354
|
+
|
|
3355
|
+
expect(result.current).toBeUndefined();
|
|
3356
|
+
|
|
3357
|
+
rerender({ value: 2 });
|
|
3358
|
+
expect(result.current).toBe(1);
|
|
3359
|
+
|
|
3360
|
+
rerender({ value: 3 });
|
|
3361
|
+
expect(result.current).toBe(2);
|
|
3362
|
+
});
|
|
3363
|
+
|
|
3364
|
+
it("should work with different types", () => {
|
|
3365
|
+
const obj = { name: "Alice" };
|
|
3366
|
+
const { rerender, result } = renderHook(({ value }) => usePrevious(value), {
|
|
3367
|
+
initialProps: { value: obj },
|
|
3368
|
+
});
|
|
3369
|
+
|
|
3370
|
+
expect(result.current).toBeUndefined();
|
|
3371
|
+
|
|
3372
|
+
const newObj = { name: "Bob" };
|
|
3373
|
+
rerender({ value: newObj });
|
|
3374
|
+
expect(result.current).toBe(obj);
|
|
3375
|
+
});
|
|
3376
|
+
});
|
|
3377
|
+
`,
|
|
3378
|
+
useScroll: `import { useCallback, useEffect, useState } from "react";
|
|
3379
|
+
|
|
3380
|
+
interface ScrollPosition {
|
|
3381
|
+
x: number;
|
|
3382
|
+
y: number;
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3385
|
+
/**
|
|
3386
|
+
* Tracks scroll position of an element or the window.
|
|
3387
|
+
*
|
|
3388
|
+
* @param ref - Optional ref to an element. If not provided, tracks window scroll
|
|
3389
|
+
* @returns Object with x and y scroll positions
|
|
3390
|
+
*
|
|
3391
|
+
* @example
|
|
3392
|
+
* // Track window scroll
|
|
3393
|
+
* const scroll = useScroll();
|
|
3394
|
+
* console.log(scroll.x, scroll.y);
|
|
3395
|
+
*
|
|
3396
|
+
* @example
|
|
3397
|
+
* // Track element scroll
|
|
3398
|
+
* const elementRef = useRef<HTMLDivElement>(null);
|
|
3399
|
+
* const scroll = useScroll(elementRef);
|
|
3400
|
+
* return <div ref={elementRef} style={{ overflow: 'auto' }}>Content</div>;
|
|
3401
|
+
*/
|
|
3402
|
+
export function useScroll(
|
|
3403
|
+
ref?: React.RefObject<HTMLElement | null>,
|
|
3404
|
+
): ScrollPosition {
|
|
3405
|
+
const [scroll, setScroll] = useState<ScrollPosition>({ x: 0, y: 0 });
|
|
3406
|
+
|
|
3407
|
+
const handleScroll = useCallback(() => {
|
|
3408
|
+
if (ref?.current) {
|
|
3409
|
+
setScroll({
|
|
3410
|
+
x: ref.current.scrollLeft,
|
|
3411
|
+
y: ref.current.scrollTop,
|
|
3412
|
+
});
|
|
3413
|
+
} else if (typeof window !== "undefined") {
|
|
3414
|
+
setScroll({
|
|
3415
|
+
x: window.scrollX,
|
|
3416
|
+
y: window.scrollY,
|
|
3417
|
+
});
|
|
3418
|
+
}
|
|
3419
|
+
}, [ref]);
|
|
3420
|
+
|
|
3421
|
+
useEffect(() => {
|
|
3422
|
+
// Set initial scroll position
|
|
3423
|
+
handleScroll();
|
|
3424
|
+
|
|
3425
|
+
if (ref?.current) {
|
|
3426
|
+
// Listen to element scroll
|
|
3427
|
+
const target = ref.current;
|
|
3428
|
+
target.addEventListener("scroll", handleScroll);
|
|
3429
|
+
return () => {
|
|
3430
|
+
target.removeEventListener("scroll", handleScroll);
|
|
3431
|
+
};
|
|
3432
|
+
} else if (typeof window !== "undefined") {
|
|
3433
|
+
// Listen to window scroll
|
|
3434
|
+
window.addEventListener("scroll", handleScroll);
|
|
3435
|
+
return () => {
|
|
3436
|
+
window.removeEventListener("scroll", handleScroll);
|
|
3437
|
+
};
|
|
3438
|
+
}
|
|
3439
|
+
}, [ref, handleScroll]);
|
|
3440
|
+
|
|
3441
|
+
return scroll;
|
|
3442
|
+
}
|
|
3443
|
+
`, useScroll_test: `import { act, renderHook, waitFor } from "@testing-library/react";
|
|
3444
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3445
|
+
|
|
3446
|
+
import { useScroll } from "./useScroll.js";
|
|
3447
|
+
|
|
3448
|
+
describe("useScroll", () => {
|
|
3449
|
+
it("should initialize with zero scroll position", () => {
|
|
3450
|
+
const { result } = renderHook(() => useScroll());
|
|
3451
|
+
expect(result.current).toEqual({ x: 0, y: 0 });
|
|
3452
|
+
});
|
|
3453
|
+
|
|
3454
|
+
it("should update on window scroll", async () => {
|
|
3455
|
+
const { result } = renderHook(() => useScroll());
|
|
3456
|
+
|
|
3457
|
+
act(() => {
|
|
3458
|
+
Object.defineProperty(window, "scrollX", {
|
|
3459
|
+
value: 100,
|
|
3460
|
+
writable: true,
|
|
3461
|
+
});
|
|
3462
|
+
Object.defineProperty(window, "scrollY", {
|
|
3463
|
+
value: 200,
|
|
3464
|
+
writable: true,
|
|
3465
|
+
});
|
|
3466
|
+
const scrollEvent = new Event("scroll");
|
|
3467
|
+
window.dispatchEvent(scrollEvent);
|
|
3468
|
+
});
|
|
3469
|
+
|
|
3470
|
+
await waitFor(() => {
|
|
3471
|
+
expect(result.current.x).toBe(100);
|
|
3472
|
+
expect(result.current.y).toBe(200);
|
|
3473
|
+
});
|
|
3474
|
+
});
|
|
3475
|
+
|
|
3476
|
+
it("should track element scroll when ref is provided", () => {
|
|
3477
|
+
const mockElement = {
|
|
3478
|
+
addEventListener: vi.fn(),
|
|
3479
|
+
removeEventListener: vi.fn(),
|
|
3480
|
+
scrollLeft: 50,
|
|
3481
|
+
scrollTop: 100,
|
|
3482
|
+
} as unknown as HTMLElement;
|
|
3483
|
+
|
|
3484
|
+
const ref = { current: mockElement };
|
|
3485
|
+
const { result } = renderHook(() => useScroll(ref));
|
|
3486
|
+
|
|
3487
|
+
expect(typeof result.current).toBe("object");
|
|
3488
|
+
expect("x" in result.current && "y" in result.current).toBe(true);
|
|
3489
|
+
});
|
|
3490
|
+
|
|
3491
|
+
it("should clean up event listener on unmount", () => {
|
|
3492
|
+
const removeEventListenerSpy = vi.spyOn(window, "removeEventListener");
|
|
3493
|
+
const { unmount } = renderHook(() => useScroll());
|
|
3494
|
+
|
|
3495
|
+
unmount();
|
|
3496
|
+
|
|
3497
|
+
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
|
3498
|
+
"scroll",
|
|
3499
|
+
expect.any(Function),
|
|
3500
|
+
);
|
|
3501
|
+
|
|
3502
|
+
removeEventListenerSpy.mockRestore();
|
|
3503
|
+
});
|
|
3504
|
+
});
|
|
3505
|
+
`,
|
|
3506
|
+
useSessionStorage: `import { useCallback, useState } from "react";
|
|
3507
|
+
|
|
3508
|
+
/**
|
|
3509
|
+
* Syncs state with sessionStorage, persisting only for the current session.
|
|
3510
|
+
*
|
|
3511
|
+
* @param key - The sessionStorage key
|
|
3512
|
+
* @param initialValue - The initial value (used if no stored value exists)
|
|
3513
|
+
* @returns A tuple of [value, setValue, removeValue]
|
|
3514
|
+
*
|
|
3515
|
+
* @example
|
|
3516
|
+
* const [sessionData, setSessionData, removeSessionData] = useSessionStorage("session", "default");
|
|
3517
|
+
*
|
|
3518
|
+
* // Update the session data (automatically persisted)
|
|
3519
|
+
* setSessionData("newData");
|
|
3520
|
+
*
|
|
3521
|
+
* // Remove from sessionStorage
|
|
3522
|
+
* removeSessionData();
|
|
3523
|
+
*/
|
|
3524
|
+
export function useSessionStorage<T>(
|
|
3525
|
+
key: string,
|
|
3526
|
+
initialValue: T,
|
|
3527
|
+
): [T, (value: ((prev: T) => T) | T) => void, () => void] {
|
|
3528
|
+
// Get initial value from sessionStorage or use provided initial value
|
|
3529
|
+
const [storedValue, setStoredValue] = useState<T>(() => {
|
|
3530
|
+
if (typeof window === "undefined") {
|
|
3531
|
+
return initialValue;
|
|
3532
|
+
}
|
|
3533
|
+
|
|
3534
|
+
try {
|
|
3535
|
+
const item = window.sessionStorage.getItem(key);
|
|
3536
|
+
return item ? (JSON.parse(item) as T) : initialValue;
|
|
3537
|
+
} catch (error) {
|
|
3538
|
+
console.warn(\`Error reading sessionStorage key "\${key}":\`, error);
|
|
3539
|
+
return initialValue;
|
|
3540
|
+
}
|
|
3541
|
+
});
|
|
3542
|
+
|
|
3543
|
+
// Update sessionStorage when value changes
|
|
3544
|
+
const setValue = useCallback(
|
|
3545
|
+
(value: ((prev: T) => T) | T) => {
|
|
3546
|
+
try {
|
|
3547
|
+
setStoredValue((prev) => {
|
|
3548
|
+
const valueToStore = value instanceof Function ? value(prev) : value;
|
|
3549
|
+
if (typeof window !== "undefined") {
|
|
3550
|
+
window.sessionStorage.setItem(key, JSON.stringify(valueToStore));
|
|
3551
|
+
}
|
|
3552
|
+
return valueToStore;
|
|
3553
|
+
});
|
|
3554
|
+
} catch (error) {
|
|
3555
|
+
console.warn(\`Error setting sessionStorage key "\${key}":\`, error);
|
|
3556
|
+
}
|
|
3557
|
+
},
|
|
3558
|
+
[key],
|
|
3559
|
+
);
|
|
3560
|
+
|
|
3561
|
+
// Remove from sessionStorage
|
|
3562
|
+
const removeValue = useCallback(() => {
|
|
3563
|
+
try {
|
|
3564
|
+
if (typeof window !== "undefined") {
|
|
3565
|
+
window.sessionStorage.removeItem(key);
|
|
3566
|
+
}
|
|
3567
|
+
setStoredValue(initialValue);
|
|
3568
|
+
} catch (error) {
|
|
3569
|
+
console.warn(\`Error removing sessionStorage key "\${key}":\`, error);
|
|
3570
|
+
}
|
|
3571
|
+
}, [key, initialValue]);
|
|
3572
|
+
|
|
3573
|
+
return [storedValue, setValue, removeValue];
|
|
3574
|
+
}
|
|
3575
|
+
`, useSessionStorage_test: `import { act, renderHook } from "@testing-library/react";
|
|
3576
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
3577
|
+
|
|
3578
|
+
import { useSessionStorage } from "./useSessionStorage.js";
|
|
3579
|
+
|
|
3580
|
+
describe("useSessionStorage", () => {
|
|
3581
|
+
beforeEach(() => {
|
|
3582
|
+
sessionStorage.clear();
|
|
3583
|
+
});
|
|
3584
|
+
|
|
3585
|
+
afterEach(() => {
|
|
3586
|
+
sessionStorage.clear();
|
|
3587
|
+
});
|
|
3588
|
+
|
|
3589
|
+
it("should initialize with initial value", () => {
|
|
3590
|
+
const { result } = renderHook(() => useSessionStorage("test", "initial"));
|
|
3591
|
+
expect(result.current[0]).toBe("initial");
|
|
3592
|
+
});
|
|
3593
|
+
|
|
3594
|
+
it("should read from sessionStorage if key exists", () => {
|
|
3595
|
+
sessionStorage.setItem("test", JSON.stringify("stored"));
|
|
3596
|
+
const { result } = renderHook(() => useSessionStorage("test", "initial"));
|
|
3597
|
+
expect(result.current[0]).toBe("stored");
|
|
3598
|
+
});
|
|
3599
|
+
|
|
3600
|
+
it("should update sessionStorage when value changes", () => {
|
|
3601
|
+
const { result } = renderHook(() => useSessionStorage("test", "initial"));
|
|
3602
|
+
|
|
3603
|
+
act(() => {
|
|
3604
|
+
result.current[1]("updated");
|
|
3605
|
+
});
|
|
3606
|
+
|
|
3607
|
+
expect(result.current[0]).toBe("updated");
|
|
3608
|
+
expect(sessionStorage.getItem("test")).toBe(JSON.stringify("updated"));
|
|
3609
|
+
});
|
|
3610
|
+
|
|
3611
|
+
it("should support updater function", () => {
|
|
3612
|
+
const { result } = renderHook(() => useSessionStorage("test", 0));
|
|
3613
|
+
|
|
3614
|
+
act(() => {
|
|
3615
|
+
result.current[1]((prev) => prev + 1);
|
|
3616
|
+
});
|
|
3617
|
+
|
|
3618
|
+
expect(result.current[0]).toBe(1);
|
|
3619
|
+
});
|
|
3620
|
+
|
|
3621
|
+
it("should remove value from sessionStorage", () => {
|
|
3622
|
+
sessionStorage.setItem("test", JSON.stringify("value"));
|
|
3623
|
+
const { result } = renderHook(() => useSessionStorage("test", "initial"));
|
|
3624
|
+
|
|
3625
|
+
act(() => {
|
|
3626
|
+
result.current[2]();
|
|
3627
|
+
});
|
|
3628
|
+
|
|
3629
|
+
expect(result.current[0]).toBe("initial");
|
|
3630
|
+
expect(sessionStorage.getItem("test")).toBeNull();
|
|
3631
|
+
});
|
|
3632
|
+
|
|
3633
|
+
it("should handle complex objects", () => {
|
|
3634
|
+
const obj = { name: "test", value: 42 };
|
|
3635
|
+
const { result } = renderHook(() => useSessionStorage("test", obj));
|
|
3636
|
+
|
|
3637
|
+
act(() => {
|
|
3638
|
+
result.current[1]({ name: "updated", value: 100 });
|
|
3639
|
+
});
|
|
3640
|
+
|
|
3641
|
+
expect(result.current[0]).toEqual({ name: "updated", value: 100 });
|
|
3642
|
+
expect(JSON.parse(sessionStorage.getItem("test") ?? "{}")).toEqual({
|
|
3643
|
+
name: "updated",
|
|
3644
|
+
value: 100,
|
|
3645
|
+
});
|
|
3646
|
+
});
|
|
3647
|
+
|
|
3648
|
+
it("should handle parse errors gracefully", () => {
|
|
3649
|
+
sessionStorage.setItem("test", "invalid json");
|
|
3650
|
+
const { result } = renderHook(() => useSessionStorage("test", "fallback"));
|
|
3651
|
+
expect(result.current[0]).toBe("fallback");
|
|
3652
|
+
});
|
|
3653
|
+
});
|
|
3654
|
+
`,
|
|
3655
|
+
useThrottle: `import { useEffect, useRef, useState } from "react";
|
|
3656
|
+
|
|
3657
|
+
/**
|
|
3658
|
+
* Throttles a value to update at most once per specified interval.
|
|
3659
|
+
*
|
|
3660
|
+
* @param value - The value to throttle
|
|
3661
|
+
* @param interval - The throttle interval in milliseconds (default: 500ms)
|
|
3662
|
+
* @returns The throttled value
|
|
3663
|
+
*
|
|
3664
|
+
* @example
|
|
3665
|
+
* const [position, setPosition] = useState({ x: 0, y: 0 });
|
|
3666
|
+
* const throttledPosition = useThrottle(position, 100);
|
|
3667
|
+
*
|
|
3668
|
+
* useEffect(() => {
|
|
3669
|
+
* // This effect runs at most every 100ms
|
|
3670
|
+
* updateCursor(throttledPosition);
|
|
3671
|
+
* }, [throttledPosition]);
|
|
3672
|
+
*/
|
|
3673
|
+
export function useThrottle<T>(value: T, interval = 500): T {
|
|
3674
|
+
const [throttledValue, setThrottledValue] = useState<T>(value);
|
|
3675
|
+
// eslint-disable-next-line react-hooks/purity
|
|
3676
|
+
const lastUpdated = useRef<number>(Date.now());
|
|
3677
|
+
|
|
3678
|
+
useEffect(() => {
|
|
3679
|
+
const now = Date.now();
|
|
3680
|
+
const elapsed = now - lastUpdated.current;
|
|
3681
|
+
|
|
3682
|
+
if (elapsed >= interval) {
|
|
3683
|
+
lastUpdated.current = now;
|
|
3684
|
+
setThrottledValue(value);
|
|
3685
|
+
} else {
|
|
3686
|
+
const timer = setTimeout(() => {
|
|
3687
|
+
lastUpdated.current = Date.now();
|
|
3688
|
+
setThrottledValue(value);
|
|
3689
|
+
}, interval - elapsed);
|
|
3690
|
+
|
|
3691
|
+
return () => {
|
|
3692
|
+
clearTimeout(timer);
|
|
3693
|
+
};
|
|
3694
|
+
}
|
|
3695
|
+
}, [value, interval]);
|
|
3696
|
+
|
|
3697
|
+
return throttledValue;
|
|
3698
|
+
}
|
|
3699
|
+
`, useThrottle_test: `import { act, renderHook } from "@testing-library/react";
|
|
3700
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3701
|
+
|
|
3702
|
+
import { useThrottle } from "./useThrottle.js";
|
|
3703
|
+
|
|
3704
|
+
describe("useThrottle", () => {
|
|
3705
|
+
beforeEach(() => {
|
|
3706
|
+
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
3707
|
+
});
|
|
3708
|
+
|
|
3709
|
+
afterEach(() => {
|
|
3710
|
+
vi.useRealTimers();
|
|
3711
|
+
});
|
|
3712
|
+
|
|
3713
|
+
it("should return initial value immediately", () => {
|
|
3714
|
+
const { result } = renderHook(() => useThrottle("initial", 500));
|
|
3715
|
+
expect(result.current).toBe("initial");
|
|
3716
|
+
});
|
|
3717
|
+
|
|
3718
|
+
it("should throttle rapid updates", () => {
|
|
3719
|
+
const { rerender, result } = renderHook(
|
|
3720
|
+
({ value }) => useThrottle(value, 500),
|
|
3721
|
+
{ initialProps: { value: "a" } },
|
|
3722
|
+
);
|
|
3723
|
+
|
|
3724
|
+
expect(result.current).toBe("a");
|
|
3725
|
+
|
|
3726
|
+
rerender({ value: "b" });
|
|
3727
|
+
expect(result.current).toBe("a");
|
|
3728
|
+
|
|
3729
|
+
rerender({ value: "c" });
|
|
3730
|
+
expect(result.current).toBe("a");
|
|
3731
|
+
|
|
3732
|
+
act(() => {
|
|
3733
|
+
vi.advanceTimersByTime(500);
|
|
3734
|
+
});
|
|
3735
|
+
expect(result.current).toBe("c");
|
|
3736
|
+
});
|
|
3737
|
+
|
|
3738
|
+
it("should allow updates after interval passes", () => {
|
|
3739
|
+
const { rerender, result } = renderHook(
|
|
3740
|
+
({ value }) => useThrottle(value, 500),
|
|
3741
|
+
{ initialProps: { value: "a" } },
|
|
3742
|
+
);
|
|
3743
|
+
|
|
3744
|
+
expect(result.current).toBe("a");
|
|
3745
|
+
|
|
3746
|
+
rerender({ value: "b" });
|
|
3747
|
+
act(() => {
|
|
3748
|
+
vi.advanceTimersByTime(500);
|
|
3749
|
+
});
|
|
3750
|
+
expect(result.current).toBe("b");
|
|
3751
|
+
|
|
3752
|
+
rerender({ value: "c" });
|
|
3753
|
+
act(() => {
|
|
3754
|
+
vi.advanceTimersByTime(500);
|
|
3755
|
+
});
|
|
3756
|
+
expect(result.current).toBe("c");
|
|
3757
|
+
});
|
|
3758
|
+
|
|
3759
|
+
it("should use default interval of 500ms", () => {
|
|
3760
|
+
const { rerender, result } = renderHook(({ value }) => useThrottle(value), {
|
|
3761
|
+
initialProps: { value: "initial" },
|
|
3762
|
+
});
|
|
3763
|
+
|
|
3764
|
+
rerender({ value: "updated" });
|
|
3765
|
+
|
|
3766
|
+
act(() => {
|
|
3767
|
+
vi.advanceTimersByTime(499);
|
|
3768
|
+
});
|
|
3769
|
+
expect(result.current).toBe("initial");
|
|
3770
|
+
|
|
3771
|
+
act(() => {
|
|
3772
|
+
vi.advanceTimersByTime(1);
|
|
3773
|
+
});
|
|
3774
|
+
expect(result.current).toBe("updated");
|
|
3775
|
+
});
|
|
3776
|
+
|
|
3777
|
+
it("should cleanup timer on unmount when throttle pending", () => {
|
|
3778
|
+
const { rerender, unmount } = renderHook(
|
|
3779
|
+
({ value }) => useThrottle(value, 500),
|
|
3780
|
+
{ initialProps: { value: "a" } },
|
|
3781
|
+
);
|
|
3782
|
+
|
|
3783
|
+
rerender({ value: "b" });
|
|
3784
|
+
unmount();
|
|
3785
|
+
expect(true).toBe(true);
|
|
3786
|
+
});
|
|
3787
|
+
});
|
|
3788
|
+
`,
|
|
3789
|
+
useThrottledCallback: `import { useCallback, useEffect, useRef } from "react";
|
|
3790
|
+
|
|
3791
|
+
export interface ThrottledCallbackOptions {
|
|
3792
|
+
/**
|
|
3793
|
+
* Whether to invoke the callback on the leading edge (immediately on first call).
|
|
3794
|
+
* @default true
|
|
3795
|
+
*/
|
|
3796
|
+
leading?: boolean;
|
|
3797
|
+
/**
|
|
3798
|
+
* Whether to invoke the callback on the trailing edge (after the interval).
|
|
3799
|
+
* @default true
|
|
3800
|
+
*/
|
|
3801
|
+
trailing?: boolean;
|
|
3802
|
+
}
|
|
3803
|
+
|
|
3804
|
+
/**
|
|
3805
|
+
* Creates a throttled version of a callback function.
|
|
3806
|
+
* The callback will execute at most once per specified interval.
|
|
3807
|
+
*
|
|
3808
|
+
* @param callback - The function to throttle
|
|
3809
|
+
* @param interval - The throttle interval in milliseconds (default: 500ms)
|
|
3810
|
+
* @param options - Configuration options for leading/trailing edge behavior
|
|
3811
|
+
* @returns A tuple containing [throttledCallback, cancel]
|
|
3812
|
+
*
|
|
3813
|
+
* @example
|
|
3814
|
+
* const [throttledScroll, cancel] = useThrottledCallback((e: Event) => {
|
|
3815
|
+
* console.log("Scroll position:", window.scrollY);
|
|
3816
|
+
* }, 100);
|
|
3817
|
+
*
|
|
3818
|
+
* useEffect(() => {
|
|
3819
|
+
* window.addEventListener("scroll", throttledScroll);
|
|
3820
|
+
* return () => window.removeEventListener("scroll", throttledScroll);
|
|
3821
|
+
* }, [throttledScroll]);
|
|
3822
|
+
*
|
|
3823
|
+
* @example
|
|
3824
|
+
* // Trailing edge only (no immediate execution)
|
|
3825
|
+
* const [throttled] = useThrottledCallback(callback, 500, { leading: false });
|
|
3826
|
+
*/
|
|
3827
|
+
export function useThrottledCallback<T extends unknown[]>(
|
|
3828
|
+
callback: (...args: T) => void,
|
|
3829
|
+
interval = 500,
|
|
3830
|
+
options: ThrottledCallbackOptions = {},
|
|
3831
|
+
): [(...args: T) => void, () => void] {
|
|
3832
|
+
const { leading = true, trailing = true } = options;
|
|
3833
|
+
|
|
3834
|
+
const callbackRef = useRef(callback);
|
|
3835
|
+
const timeoutRef = useRef<null | ReturnType<typeof setTimeout>>(null);
|
|
3836
|
+
const lastArgsRef = useRef<null | T>(null);
|
|
3837
|
+
const lastCallTimeRef = useRef<null | number>(null);
|
|
3838
|
+
|
|
3839
|
+
// Keep callback ref up to date
|
|
3840
|
+
useEffect(() => {
|
|
3841
|
+
callbackRef.current = callback;
|
|
3842
|
+
}, [callback]);
|
|
3843
|
+
|
|
3844
|
+
// Cleanup on unmount
|
|
3845
|
+
useEffect(() => {
|
|
3846
|
+
return () => {
|
|
3847
|
+
if (timeoutRef.current) {
|
|
3848
|
+
clearTimeout(timeoutRef.current);
|
|
3849
|
+
}
|
|
3850
|
+
};
|
|
3851
|
+
}, []);
|
|
3852
|
+
|
|
3853
|
+
const cancel = useCallback(() => {
|
|
3854
|
+
if (timeoutRef.current) {
|
|
3855
|
+
clearTimeout(timeoutRef.current);
|
|
3856
|
+
timeoutRef.current = null;
|
|
3857
|
+
}
|
|
3858
|
+
lastArgsRef.current = null;
|
|
3859
|
+
lastCallTimeRef.current = null;
|
|
3860
|
+
}, []);
|
|
3861
|
+
|
|
3862
|
+
const throttledCallback = useCallback(
|
|
3863
|
+
(...args: T) => {
|
|
3864
|
+
const now = Date.now();
|
|
3865
|
+
const timeSinceLastCall =
|
|
3866
|
+
lastCallTimeRef.current === null
|
|
3867
|
+
? interval
|
|
3868
|
+
: now - lastCallTimeRef.current;
|
|
3869
|
+
|
|
3870
|
+
lastArgsRef.current = args;
|
|
3871
|
+
|
|
3872
|
+
// First call or enough time has passed
|
|
3873
|
+
if (timeSinceLastCall >= interval) {
|
|
3874
|
+
if (leading) {
|
|
3875
|
+
lastCallTimeRef.current = now;
|
|
3876
|
+
callbackRef.current(...args);
|
|
3877
|
+
} else {
|
|
3878
|
+
// Schedule for trailing edge
|
|
3879
|
+
lastCallTimeRef.current = now;
|
|
3880
|
+
if (trailing && !timeoutRef.current) {
|
|
3881
|
+
timeoutRef.current = setTimeout(() => {
|
|
3882
|
+
timeoutRef.current = null;
|
|
3883
|
+
if (lastArgsRef.current) {
|
|
3884
|
+
callbackRef.current(...lastArgsRef.current);
|
|
3885
|
+
}
|
|
3886
|
+
}, interval);
|
|
3887
|
+
}
|
|
3888
|
+
}
|
|
3889
|
+
} else if (trailing && !timeoutRef.current) {
|
|
3890
|
+
// Schedule trailing call
|
|
3891
|
+
const remainingTime = interval - timeSinceLastCall;
|
|
3892
|
+
timeoutRef.current = setTimeout(() => {
|
|
3893
|
+
timeoutRef.current = null;
|
|
3894
|
+
lastCallTimeRef.current = Date.now();
|
|
3895
|
+
if (lastArgsRef.current) {
|
|
3896
|
+
callbackRef.current(...lastArgsRef.current);
|
|
3897
|
+
}
|
|
3898
|
+
}, remainingTime);
|
|
3899
|
+
}
|
|
3900
|
+
},
|
|
3901
|
+
[interval, leading, trailing],
|
|
3902
|
+
);
|
|
3903
|
+
|
|
3904
|
+
return [throttledCallback, cancel];
|
|
3905
|
+
}
|
|
3906
|
+
`, useThrottledCallback_test: `import { act, renderHook } from "@testing-library/react";
|
|
3907
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3908
|
+
|
|
3909
|
+
import { useThrottledCallback } from "./useThrottledCallback.js";
|
|
3910
|
+
|
|
3911
|
+
describe("useThrottledCallback", () => {
|
|
3912
|
+
beforeEach(() => {
|
|
3913
|
+
vi.useFakeTimers();
|
|
3914
|
+
});
|
|
3915
|
+
|
|
3916
|
+
it("should execute immediately on first call (leading edge)", () => {
|
|
3917
|
+
const callback = vi.fn();
|
|
3918
|
+
const { result } = renderHook(() => useThrottledCallback(callback, 500));
|
|
3919
|
+
|
|
3920
|
+
const [throttledCallback] = result.current;
|
|
3921
|
+
|
|
3922
|
+
act(() => {
|
|
3923
|
+
throttledCallback("first");
|
|
3924
|
+
});
|
|
3925
|
+
|
|
3926
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
3927
|
+
expect(callback).toHaveBeenCalledWith("first");
|
|
3928
|
+
});
|
|
3929
|
+
|
|
3930
|
+
it("should throttle subsequent calls within interval", () => {
|
|
3931
|
+
const callback = vi.fn();
|
|
3932
|
+
const { result } = renderHook(() => useThrottledCallback(callback, 500));
|
|
3933
|
+
|
|
3934
|
+
const [throttledCallback] = result.current;
|
|
3935
|
+
|
|
3936
|
+
act(() => {
|
|
3937
|
+
throttledCallback("a");
|
|
3938
|
+
throttledCallback("b");
|
|
3939
|
+
throttledCallback("c");
|
|
3940
|
+
});
|
|
3941
|
+
|
|
3942
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
3943
|
+
expect(callback).toHaveBeenCalledWith("a");
|
|
3944
|
+
|
|
3945
|
+
act(() => {
|
|
3946
|
+
vi.advanceTimersByTime(500);
|
|
3947
|
+
});
|
|
3948
|
+
|
|
3949
|
+
// Trailing call with last args
|
|
3950
|
+
expect(callback).toHaveBeenCalledTimes(2);
|
|
3951
|
+
expect(callback).toHaveBeenLastCalledWith("c");
|
|
3952
|
+
});
|
|
3953
|
+
|
|
3954
|
+
it("should use default interval of 500ms", () => {
|
|
3955
|
+
const callback = vi.fn();
|
|
3956
|
+
const { result } = renderHook(() => useThrottledCallback(callback));
|
|
3957
|
+
|
|
3958
|
+
const [throttledCallback] = result.current;
|
|
3959
|
+
|
|
3960
|
+
act(() => {
|
|
3961
|
+
throttledCallback("first");
|
|
3962
|
+
});
|
|
3963
|
+
|
|
3964
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
3965
|
+
|
|
3966
|
+
act(() => {
|
|
3967
|
+
throttledCallback("second");
|
|
3968
|
+
});
|
|
3969
|
+
|
|
3970
|
+
act(() => {
|
|
3971
|
+
vi.advanceTimersByTime(499);
|
|
3972
|
+
});
|
|
3973
|
+
|
|
3974
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
3975
|
+
|
|
3976
|
+
act(() => {
|
|
3977
|
+
vi.advanceTimersByTime(1);
|
|
3978
|
+
});
|
|
3979
|
+
|
|
3980
|
+
expect(callback).toHaveBeenCalledTimes(2);
|
|
3981
|
+
});
|
|
3982
|
+
|
|
3983
|
+
it("should allow calls after interval has passed", () => {
|
|
3984
|
+
const callback = vi.fn();
|
|
3985
|
+
const { result } = renderHook(() => useThrottledCallback(callback, 500));
|
|
3986
|
+
|
|
3987
|
+
const [throttledCallback] = result.current;
|
|
3988
|
+
|
|
3989
|
+
act(() => {
|
|
3990
|
+
throttledCallback("first");
|
|
3991
|
+
});
|
|
3992
|
+
|
|
3993
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
3994
|
+
|
|
3995
|
+
act(() => {
|
|
3996
|
+
vi.advanceTimersByTime(500);
|
|
3997
|
+
});
|
|
3998
|
+
|
|
3999
|
+
act(() => {
|
|
4000
|
+
throttledCallback("second");
|
|
4001
|
+
});
|
|
4002
|
+
|
|
4003
|
+
expect(callback).toHaveBeenCalledTimes(2);
|
|
4004
|
+
expect(callback).toHaveBeenLastCalledWith("second");
|
|
4005
|
+
});
|
|
4006
|
+
|
|
4007
|
+
it("should cancel pending callback", () => {
|
|
4008
|
+
const callback = vi.fn();
|
|
4009
|
+
const { result } = renderHook(() => useThrottledCallback(callback, 500));
|
|
4010
|
+
|
|
4011
|
+
const [throttledCallback, cancel] = result.current;
|
|
4012
|
+
|
|
4013
|
+
act(() => {
|
|
4014
|
+
throttledCallback("first");
|
|
4015
|
+
throttledCallback("second");
|
|
4016
|
+
});
|
|
4017
|
+
|
|
4018
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
4019
|
+
|
|
4020
|
+
act(() => {
|
|
4021
|
+
cancel();
|
|
4022
|
+
});
|
|
4023
|
+
|
|
4024
|
+
act(() => {
|
|
4025
|
+
vi.advanceTimersByTime(500);
|
|
4026
|
+
});
|
|
4027
|
+
|
|
4028
|
+
// Trailing call was cancelled
|
|
4029
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
4030
|
+
});
|
|
4031
|
+
|
|
4032
|
+
it("should pass all arguments to callback", () => {
|
|
4033
|
+
const callback = vi.fn();
|
|
4034
|
+
const { result } = renderHook(() => useThrottledCallback(callback, 100));
|
|
4035
|
+
|
|
4036
|
+
const [throttledCallback] = result.current;
|
|
4037
|
+
|
|
4038
|
+
act(() => {
|
|
4039
|
+
throttledCallback("arg1", "arg2", 123);
|
|
4040
|
+
});
|
|
4041
|
+
|
|
4042
|
+
expect(callback).toHaveBeenCalledWith("arg1", "arg2", 123);
|
|
4043
|
+
});
|
|
4044
|
+
|
|
4045
|
+
it("should use latest callback reference", () => {
|
|
4046
|
+
const callback1 = vi.fn();
|
|
4047
|
+
const callback2 = vi.fn();
|
|
4048
|
+
|
|
4049
|
+
const { rerender, result } = renderHook(
|
|
4050
|
+
({ cb }) => useThrottledCallback(cb, 500),
|
|
4051
|
+
{ initialProps: { cb: callback1 } },
|
|
4052
|
+
);
|
|
4053
|
+
|
|
4054
|
+
const [throttledCallback] = result.current;
|
|
4055
|
+
|
|
4056
|
+
act(() => {
|
|
4057
|
+
throttledCallback("first");
|
|
4058
|
+
throttledCallback("second");
|
|
4059
|
+
});
|
|
4060
|
+
|
|
4061
|
+
expect(callback1).toHaveBeenCalledTimes(1);
|
|
4062
|
+
|
|
4063
|
+
rerender({ cb: callback2 });
|
|
4064
|
+
|
|
4065
|
+
act(() => {
|
|
4066
|
+
vi.advanceTimersByTime(500);
|
|
4067
|
+
});
|
|
4068
|
+
|
|
4069
|
+
// Trailing call uses new callback
|
|
4070
|
+
expect(callback1).toHaveBeenCalledTimes(1);
|
|
4071
|
+
expect(callback2).toHaveBeenCalledTimes(1);
|
|
4072
|
+
});
|
|
4073
|
+
|
|
4074
|
+
it("should cleanup on unmount", () => {
|
|
4075
|
+
const callback = vi.fn();
|
|
4076
|
+
const { result, unmount } = renderHook(() =>
|
|
4077
|
+
useThrottledCallback(callback, 500),
|
|
4078
|
+
);
|
|
4079
|
+
|
|
4080
|
+
const [throttledCallback] = result.current;
|
|
4081
|
+
|
|
4082
|
+
act(() => {
|
|
4083
|
+
throttledCallback("first");
|
|
4084
|
+
throttledCallback("second");
|
|
4085
|
+
});
|
|
4086
|
+
|
|
4087
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
4088
|
+
|
|
4089
|
+
unmount();
|
|
4090
|
+
|
|
4091
|
+
act(() => {
|
|
4092
|
+
vi.advanceTimersByTime(500);
|
|
4093
|
+
});
|
|
4094
|
+
|
|
4095
|
+
// Trailing call was cleaned up
|
|
4096
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
4097
|
+
});
|
|
4098
|
+
|
|
4099
|
+
describe("options", () => {
|
|
4100
|
+
it("should not execute immediately when leading is false", () => {
|
|
4101
|
+
const callback = vi.fn();
|
|
4102
|
+
const { result } = renderHook(() =>
|
|
4103
|
+
useThrottledCallback(callback, 500, { leading: false }),
|
|
4104
|
+
);
|
|
4105
|
+
|
|
4106
|
+
const [throttledCallback] = result.current;
|
|
4107
|
+
|
|
4108
|
+
act(() => {
|
|
4109
|
+
throttledCallback("first");
|
|
4110
|
+
});
|
|
4111
|
+
|
|
4112
|
+
expect(callback).not.toHaveBeenCalled();
|
|
4113
|
+
|
|
4114
|
+
act(() => {
|
|
4115
|
+
vi.advanceTimersByTime(500);
|
|
4116
|
+
});
|
|
4117
|
+
|
|
4118
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
4119
|
+
expect(callback).toHaveBeenCalledWith("first");
|
|
4120
|
+
});
|
|
4121
|
+
|
|
4122
|
+
it("should not execute trailing call when trailing is false", () => {
|
|
4123
|
+
const callback = vi.fn();
|
|
4124
|
+
const { result } = renderHook(() =>
|
|
4125
|
+
useThrottledCallback(callback, 500, { trailing: false }),
|
|
4126
|
+
);
|
|
4127
|
+
|
|
4128
|
+
const [throttledCallback] = result.current;
|
|
4129
|
+
|
|
4130
|
+
act(() => {
|
|
4131
|
+
throttledCallback("first");
|
|
4132
|
+
throttledCallback("second");
|
|
4133
|
+
throttledCallback("third");
|
|
4134
|
+
});
|
|
4135
|
+
|
|
4136
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
4137
|
+
expect(callback).toHaveBeenCalledWith("first");
|
|
4138
|
+
|
|
4139
|
+
act(() => {
|
|
4140
|
+
vi.advanceTimersByTime(500);
|
|
4141
|
+
});
|
|
4142
|
+
|
|
4143
|
+
// No trailing call
|
|
4144
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
4145
|
+
});
|
|
4146
|
+
|
|
4147
|
+
it("should handle both leading and trailing as false gracefully", () => {
|
|
4148
|
+
const callback = vi.fn();
|
|
4149
|
+
const { result } = renderHook(() =>
|
|
4150
|
+
useThrottledCallback(callback, 500, {
|
|
4151
|
+
leading: false,
|
|
4152
|
+
trailing: false,
|
|
4153
|
+
}),
|
|
4154
|
+
);
|
|
4155
|
+
|
|
4156
|
+
const [throttledCallback] = result.current;
|
|
4157
|
+
|
|
4158
|
+
act(() => {
|
|
4159
|
+
throttledCallback("test");
|
|
4160
|
+
});
|
|
4161
|
+
|
|
4162
|
+
act(() => {
|
|
4163
|
+
vi.advanceTimersByTime(1000);
|
|
4164
|
+
});
|
|
4165
|
+
|
|
4166
|
+
// No calls when both are false
|
|
4167
|
+
expect(callback).not.toHaveBeenCalled();
|
|
4168
|
+
});
|
|
4169
|
+
});
|
|
4170
|
+
});
|
|
4171
|
+
`,
|
|
4172
|
+
useTimeout: `import { useEffect } from "react";
|
|
4173
|
+
|
|
4174
|
+
/**
|
|
4175
|
+
* Calls a callback after a timeout.
|
|
4176
|
+
*
|
|
4177
|
+
* @param callback - Function to call after timeout
|
|
4178
|
+
* @param delay - Timeout delay in milliseconds (null to disable)
|
|
4179
|
+
*
|
|
4180
|
+
* @example
|
|
4181
|
+
* useTimeout(() => {
|
|
4182
|
+
* console.log("Timeout completed");
|
|
4183
|
+
* }, 2000);
|
|
4184
|
+
*
|
|
4185
|
+
* @example
|
|
4186
|
+
* // Disable timeout by passing null
|
|
4187
|
+
* useTimeout(() => {
|
|
4188
|
+
* console.log("This won't run");
|
|
4189
|
+
* }, null);
|
|
4190
|
+
*/
|
|
4191
|
+
export function useTimeout(callback: () => void, delay: null | number): void {
|
|
4192
|
+
useEffect(() => {
|
|
4193
|
+
if (delay === null) return;
|
|
4194
|
+
|
|
4195
|
+
const timeout = setTimeout(callback, delay);
|
|
4196
|
+
return () => {
|
|
4197
|
+
clearTimeout(timeout);
|
|
4198
|
+
};
|
|
4199
|
+
}, [callback, delay]);
|
|
4200
|
+
}
|
|
4201
|
+
`, useTimeout_test: `import { renderHook } from "@testing-library/react";
|
|
4202
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4203
|
+
|
|
4204
|
+
import { useTimeout } from "./useTimeout.js";
|
|
4205
|
+
|
|
4206
|
+
describe("useTimeout", () => {
|
|
4207
|
+
beforeEach(() => {
|
|
4208
|
+
vi.useFakeTimers();
|
|
4209
|
+
});
|
|
4210
|
+
|
|
4211
|
+
afterEach(() => {
|
|
4212
|
+
vi.useRealTimers();
|
|
4213
|
+
});
|
|
4214
|
+
|
|
4215
|
+
it("should call callback after timeout", () => {
|
|
4216
|
+
const callback = vi.fn();
|
|
4217
|
+
renderHook(() => {
|
|
4218
|
+
useTimeout(callback, 2000);
|
|
4219
|
+
});
|
|
4220
|
+
|
|
4221
|
+
expect(callback).not.toHaveBeenCalled();
|
|
4222
|
+
|
|
4223
|
+
vi.advanceTimersByTime(2000);
|
|
4224
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
4225
|
+
});
|
|
4226
|
+
|
|
4227
|
+
it("should cleanup timeout on unmount", () => {
|
|
4228
|
+
const callback = vi.fn();
|
|
4229
|
+
const { unmount } = renderHook(() => {
|
|
4230
|
+
useTimeout(callback, 2000);
|
|
4231
|
+
});
|
|
4232
|
+
|
|
4233
|
+
unmount();
|
|
4234
|
+
|
|
4235
|
+
vi.advanceTimersByTime(2000);
|
|
4236
|
+
expect(callback).not.toHaveBeenCalled();
|
|
4237
|
+
});
|
|
4238
|
+
|
|
4239
|
+
it("should not set timeout when delay is null", () => {
|
|
4240
|
+
const callback = vi.fn();
|
|
4241
|
+
renderHook(() => {
|
|
4242
|
+
useTimeout(callback, null);
|
|
4243
|
+
});
|
|
4244
|
+
|
|
4245
|
+
vi.advanceTimersByTime(5000);
|
|
4246
|
+
expect(callback).not.toHaveBeenCalled();
|
|
4247
|
+
});
|
|
4248
|
+
|
|
4249
|
+
it("should reset timeout when delay changes", () => {
|
|
4250
|
+
const callback = vi.fn();
|
|
4251
|
+
const { rerender } = renderHook(
|
|
4252
|
+
({ delay }: { delay: null | number }) => {
|
|
4253
|
+
useTimeout(callback, delay);
|
|
4254
|
+
},
|
|
4255
|
+
{ initialProps: { delay: 2000 as null | number } },
|
|
4256
|
+
);
|
|
4257
|
+
|
|
4258
|
+
vi.advanceTimersByTime(1000);
|
|
4259
|
+
expect(callback).not.toHaveBeenCalled();
|
|
4260
|
+
|
|
4261
|
+
// Change delay, should reset the timeout
|
|
4262
|
+
rerender({ delay: 3000 });
|
|
4263
|
+
|
|
4264
|
+
vi.advanceTimersByTime(2000);
|
|
4265
|
+
expect(callback).not.toHaveBeenCalled();
|
|
4266
|
+
|
|
4267
|
+
vi.advanceTimersByTime(1000);
|
|
4268
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
4269
|
+
});
|
|
4270
|
+
});
|
|
4271
|
+
`,
|
|
4272
|
+
useToggle: `import { useCallback, useState } from "react";
|
|
4273
|
+
|
|
4274
|
+
/**
|
|
4275
|
+
* Toggle a boolean value with a callback.
|
|
4276
|
+
*
|
|
4277
|
+
* @param initialValue - Initial boolean value (default: false)
|
|
4278
|
+
* @returns Tuple of [value, toggle, setValue]
|
|
4279
|
+
*
|
|
4280
|
+
* @example
|
|
4281
|
+
* const [isOpen, toggle] = useToggle(false);
|
|
4282
|
+
*
|
|
4283
|
+
* return (
|
|
4284
|
+
* <>
|
|
4285
|
+
* <button onClick={toggle}>Toggle</button>
|
|
4286
|
+
* {isOpen && <div>Content</div>}
|
|
4287
|
+
* </>
|
|
4288
|
+
* );
|
|
4289
|
+
*/
|
|
4290
|
+
export function useToggle(
|
|
4291
|
+
initialValue = false,
|
|
4292
|
+
): [boolean, () => void, (value: boolean) => void] {
|
|
4293
|
+
const [value, setValue] = useState(initialValue);
|
|
4294
|
+
|
|
4295
|
+
const toggle = useCallback(() => {
|
|
4296
|
+
setValue((prev) => !prev);
|
|
4297
|
+
}, []);
|
|
4298
|
+
|
|
4299
|
+
return [value, toggle, setValue];
|
|
4300
|
+
}
|
|
4301
|
+
`, useToggle_test: `import { act, renderHook } from "@testing-library/react";
|
|
4302
|
+
import { describe, expect, it } from "vitest";
|
|
4303
|
+
|
|
4304
|
+
import { useToggle } from "./useToggle.js";
|
|
4305
|
+
|
|
4306
|
+
describe("useToggle", () => {
|
|
4307
|
+
it("should initialize with false by default", () => {
|
|
4308
|
+
const { result } = renderHook(() => useToggle());
|
|
4309
|
+
expect(result.current[0]).toBe(false);
|
|
4310
|
+
});
|
|
4311
|
+
|
|
4312
|
+
it("should initialize with provided value", () => {
|
|
4313
|
+
const { result } = renderHook(() => useToggle(true));
|
|
4314
|
+
expect(result.current[0]).toBe(true);
|
|
4315
|
+
});
|
|
4316
|
+
|
|
4317
|
+
it("should toggle value", () => {
|
|
4318
|
+
const { result } = renderHook(() => useToggle());
|
|
4319
|
+
|
|
4320
|
+
act(() => {
|
|
4321
|
+
result.current[1]();
|
|
4322
|
+
});
|
|
4323
|
+
expect(result.current[0]).toBe(true);
|
|
4324
|
+
|
|
4325
|
+
act(() => {
|
|
4326
|
+
result.current[1]();
|
|
4327
|
+
});
|
|
4328
|
+
expect(result.current[0]).toBe(false);
|
|
4329
|
+
});
|
|
4330
|
+
|
|
4331
|
+
it("should set value directly", () => {
|
|
4332
|
+
const { result } = renderHook(() => useToggle());
|
|
4333
|
+
|
|
4334
|
+
act(() => {
|
|
4335
|
+
result.current[2](true);
|
|
4336
|
+
});
|
|
4337
|
+
expect(result.current[0]).toBe(true);
|
|
4338
|
+
|
|
4339
|
+
act(() => {
|
|
4340
|
+
result.current[2](false);
|
|
4341
|
+
});
|
|
4342
|
+
expect(result.current[0]).toBe(false);
|
|
4343
|
+
});
|
|
4344
|
+
});
|
|
4345
|
+
`,
|
|
4346
|
+
useUnmount: `import { useEffect, useRef } from "react";
|
|
4347
|
+
|
|
4348
|
+
/**
|
|
4349
|
+
* Calls a callback on component unmount.
|
|
4350
|
+
*
|
|
4351
|
+
* @param callback - Function to call on unmount
|
|
4352
|
+
*
|
|
4353
|
+
* @example
|
|
4354
|
+
* useUnmount(() => {
|
|
4355
|
+
* console.log("Component unmounting");
|
|
4356
|
+
* // Cleanup resources
|
|
4357
|
+
* });
|
|
4358
|
+
*/
|
|
4359
|
+
export function useUnmount(callback: () => void): void {
|
|
4360
|
+
const callbackRef = useRef(callback);
|
|
4361
|
+
|
|
4362
|
+
useEffect(() => {
|
|
4363
|
+
callbackRef.current = callback;
|
|
4364
|
+
}, [callback]);
|
|
4365
|
+
|
|
4366
|
+
useEffect(() => {
|
|
4367
|
+
return () => {
|
|
4368
|
+
callbackRef.current();
|
|
4369
|
+
};
|
|
4370
|
+
}, []);
|
|
4371
|
+
}
|
|
4372
|
+
`, useUnmount_test: `import { renderHook } from "@testing-library/react";
|
|
4373
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4374
|
+
|
|
4375
|
+
import { useUnmount } from "./useUnmount.js";
|
|
4376
|
+
|
|
4377
|
+
describe("useUnmount", () => {
|
|
4378
|
+
it("should call callback on unmount", () => {
|
|
4379
|
+
const callback = vi.fn();
|
|
4380
|
+
const { unmount } = renderHook(() => {
|
|
4381
|
+
useUnmount(callback);
|
|
4382
|
+
});
|
|
4383
|
+
|
|
4384
|
+
expect(callback).not.toHaveBeenCalled();
|
|
4385
|
+
|
|
4386
|
+
unmount();
|
|
4387
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
4388
|
+
});
|
|
4389
|
+
|
|
4390
|
+
it("should not call callback on rerender", () => {
|
|
4391
|
+
const callback = vi.fn();
|
|
4392
|
+
const { rerender } = renderHook(() => {
|
|
4393
|
+
useUnmount(callback);
|
|
4394
|
+
});
|
|
4395
|
+
|
|
4396
|
+
rerender();
|
|
4397
|
+
expect(callback).not.toHaveBeenCalled();
|
|
4398
|
+
});
|
|
4399
|
+
|
|
4400
|
+
it("should use latest callback on unmount", () => {
|
|
4401
|
+
const callback1 = vi.fn();
|
|
4402
|
+
const callback2 = vi.fn();
|
|
4403
|
+
|
|
4404
|
+
const { rerender, unmount } = renderHook(
|
|
4405
|
+
({ cb }: { cb: () => void }) => {
|
|
4406
|
+
useUnmount(cb);
|
|
4407
|
+
},
|
|
4408
|
+
{ initialProps: { cb: callback1 } },
|
|
4409
|
+
);
|
|
4410
|
+
|
|
4411
|
+
rerender({ cb: callback2 });
|
|
4412
|
+
unmount();
|
|
4413
|
+
|
|
4414
|
+
expect(callback1).not.toHaveBeenCalled();
|
|
4415
|
+
expect(callback2).toHaveBeenCalledTimes(1);
|
|
4416
|
+
});
|
|
4417
|
+
});
|
|
4418
|
+
`,
|
|
4419
|
+
useWindowSize: `import { useEffect, useState } from "react";
|
|
4420
|
+
|
|
4421
|
+
interface WindowSize {
|
|
4422
|
+
height: number | undefined;
|
|
4423
|
+
width: number | undefined;
|
|
4424
|
+
}
|
|
4425
|
+
|
|
4426
|
+
/**
|
|
4427
|
+
* Tracks window dimensions.
|
|
4428
|
+
*
|
|
4429
|
+
* @returns Object with width and height of the window
|
|
4430
|
+
*
|
|
4431
|
+
* @example
|
|
4432
|
+
* const { width, height } = useWindowSize();
|
|
4433
|
+
*
|
|
4434
|
+
* return (
|
|
4435
|
+
* <div>
|
|
4436
|
+
* Window size: {width}x{height}
|
|
4437
|
+
* </div>
|
|
4438
|
+
* );
|
|
4439
|
+
*/
|
|
4440
|
+
export function useWindowSize(): WindowSize {
|
|
4441
|
+
const [windowSize, setWindowSize] = useState<WindowSize>({
|
|
4442
|
+
height: undefined,
|
|
4443
|
+
width: undefined,
|
|
4444
|
+
});
|
|
4445
|
+
|
|
4446
|
+
useEffect(() => {
|
|
4447
|
+
const handleResize = () => {
|
|
4448
|
+
setWindowSize({
|
|
4449
|
+
height: window.innerHeight,
|
|
4450
|
+
width: window.innerWidth,
|
|
4451
|
+
});
|
|
4452
|
+
};
|
|
4453
|
+
|
|
4454
|
+
// Call once on mount
|
|
4455
|
+
handleResize();
|
|
4456
|
+
|
|
4457
|
+
window.addEventListener("resize", handleResize);
|
|
4458
|
+
return () => {
|
|
4459
|
+
window.removeEventListener("resize", handleResize);
|
|
4460
|
+
};
|
|
4461
|
+
}, []);
|
|
4462
|
+
|
|
4463
|
+
return windowSize;
|
|
4464
|
+
}
|
|
4465
|
+
`, useWindowSize_test: `import { act, renderHook } from "@testing-library/react";
|
|
4466
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4467
|
+
|
|
4468
|
+
import { useWindowSize } from "./useWindowSize.js";
|
|
4469
|
+
|
|
4470
|
+
describe("useWindowSize", () => {
|
|
4471
|
+
beforeEach(() => {
|
|
4472
|
+
Object.defineProperty(window, "innerWidth", {
|
|
4473
|
+
configurable: true,
|
|
4474
|
+
value: 1024,
|
|
4475
|
+
writable: true,
|
|
4476
|
+
});
|
|
4477
|
+
Object.defineProperty(window, "innerHeight", {
|
|
4478
|
+
configurable: true,
|
|
4479
|
+
value: 768,
|
|
4480
|
+
writable: true,
|
|
4481
|
+
});
|
|
4482
|
+
});
|
|
4483
|
+
|
|
4484
|
+
it("should return window size", () => {
|
|
4485
|
+
const { result } = renderHook(() => useWindowSize());
|
|
4486
|
+
|
|
4487
|
+
expect(result.current.width).toBe(1024);
|
|
4488
|
+
expect(result.current.height).toBe(768);
|
|
4489
|
+
});
|
|
4490
|
+
|
|
4491
|
+
it("should update on resize", () => {
|
|
4492
|
+
const { result } = renderHook(() => useWindowSize());
|
|
4493
|
+
|
|
4494
|
+
expect(result.current.width).toBe(1024);
|
|
4495
|
+
|
|
4496
|
+
Object.defineProperty(window, "innerWidth", { value: 800, writable: true });
|
|
4497
|
+
Object.defineProperty(window, "innerHeight", {
|
|
4498
|
+
value: 600,
|
|
4499
|
+
writable: true,
|
|
4500
|
+
});
|
|
4501
|
+
|
|
4502
|
+
act(() => {
|
|
4503
|
+
window.dispatchEvent(new Event("resize"));
|
|
4504
|
+
});
|
|
4505
|
+
|
|
4506
|
+
expect(result.current.width).toBe(800);
|
|
4507
|
+
expect(result.current.height).toBe(600);
|
|
4508
|
+
});
|
|
4509
|
+
|
|
4510
|
+
it("should cleanup listener on unmount", () => {
|
|
4511
|
+
const removeSpy = vi.spyOn(window, "removeEventListener");
|
|
4512
|
+
const { unmount } = renderHook(() => useWindowSize());
|
|
4513
|
+
|
|
4514
|
+
unmount();
|
|
4515
|
+
|
|
4516
|
+
expect(removeSpy).toHaveBeenCalledWith("resize", expect.any(Function));
|
|
4517
|
+
});
|
|
4518
|
+
});
|
|
4519
|
+
`,
|
|
4520
|
+
};
|
|
4521
|
+
//# sourceMappingURL=hook-templates.js.map
|