Skip to content

Commit 5c6a72d

Browse files
committed
refactor(debounce): stabilize callback ref, add dispose, tidy tests
- useDebouncedCallback: hold latest callback in a ref so inline arrows no longer drop pending timers; normalize edges via booleans so array reference/order changes don't recreate the debounced instance - debounce: add dispose() to detach the external signal abort listener via an internal AbortController, short-circuit schedule() when there is nothing to do, and switch Reflect.apply to func.apply - tests: parameterize validation and "no-op from terminal state" matrices with it.for, add retention/dispose/edges-identity cases, and cover the maxWait forced-fire branch; 100% coverage on both files
1 parent 292b965 commit 5c6a72d

4 files changed

Lines changed: 348 additions & 154 deletions

File tree

src/hooks/useDebouncedCallback/useDebouncedCallback.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,4 +237,89 @@ describe(useDebouncedCallback, () => {
237237
rerender({ wait: 500 });
238238
expect(result.current).toBe(prev);
239239
});
240+
241+
it("毎レンダリングで新しいアロー関数を渡しても保留中のタイマーと引数が失われず、最新のコールバックで発火すること", () => {
242+
const spy = vi.fn();
243+
let current = 0;
244+
const { result, rerender } = renderHook(
245+
({ value }) =>
246+
useDebouncedCallback((arg: string) => {
247+
spy(value, arg);
248+
}, 500),
249+
{ initialProps: { value: current } },
250+
);
251+
252+
// 最初の呼び出しでタイマー開始
253+
const first = result.current;
254+
act(() => {
255+
first("args");
256+
});
257+
258+
// value を更新して再レンダリング。ここで debounced が再生成されると
259+
// タイマーが失われてしまう。
260+
current = 1;
261+
rerender({ value: current });
262+
263+
// 同一の debounced 関数が返り続けること
264+
expect(result.current).toBe(first);
265+
266+
act(() => {
267+
vi.advanceTimersByTime(500);
268+
});
269+
270+
// 最新の callback (value=1) で、保持された引数 "args" で発火する
271+
expect(spy).toHaveBeenCalledExactlyOnceWith(1, "args");
272+
});
273+
274+
it("options.edges の配列参照が毎レンダリング変わっても、内容が同じなら同一 debounced 関数が返ること", () => {
275+
const callback = vi.fn();
276+
const { result, rerender } = renderHook(
277+
({ edges }) =>
278+
useDebouncedCallback(callback, 500, {
279+
edges,
280+
}),
281+
{ initialProps: { edges: ["leading"] as Array<"leading" | "trailing"> } },
282+
);
283+
284+
const prev = result.current;
285+
// 内容は同じで新しい配列参照を渡す
286+
rerender({
287+
edges: ["leading"] as Array<"leading" | "trailing">,
288+
});
289+
290+
expect(result.current).toBe(prev);
291+
});
292+
293+
it("edges が undefined と ['trailing'] (default 相当) で同一 debounced を返すこと", () => {
294+
const callback = vi.fn();
295+
const { result, rerender } = renderHook(
296+
({ edges }: { edges: Array<"leading" | "trailing"> | undefined }) =>
297+
useDebouncedCallback(callback, 500, { edges }),
298+
{
299+
initialProps: {
300+
edges: undefined as Array<"leading" | "trailing"> | undefined,
301+
},
302+
},
303+
);
304+
305+
const prev = result.current;
306+
rerender({ edges: ["trailing"] });
307+
expect(result.current).toBe(prev);
308+
});
309+
310+
it("edges の順序が異なっても内容が同じなら同一 debounced を返すこと", () => {
311+
const callback = vi.fn();
312+
const { result, rerender } = renderHook(
313+
({ edges }) => useDebouncedCallback(callback, 500, { edges }),
314+
{
315+
initialProps: {
316+
edges: ["leading", "trailing"] as Array<"leading" | "trailing">,
317+
},
318+
},
319+
);
320+
321+
const prev = result.current;
322+
rerender({ edges: ["trailing", "leading"] });
323+
expect(result.current).toBe(prev);
324+
});
240325
});

src/hooks/useDebouncedCallback/useDebouncedCallback.ts

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,50 @@
11
import type { DebounceOptions } from "../../utils/debounce";
2-
import { useEffect, useMemo } from "react";
2+
import { useEffect, useMemo, useRef } from "react";
33
import { debounce } from "../../utils/debounce";
44

55
/**
6-
* `callback` と `options` は安定参照で渡すこと。毎レンダーで新しい参照を渡すと
7-
* `useMemo` が再計算され、保留中のタイマーと leading クールダウン状態が失われる。
8-
* `options` が静的な場合はコンポーネント外か `useMemo` で固定する。
6+
* 最新の `callback` 参照を ref で保持することで、インラインのアロー関数を渡しても
7+
* 保留中のタイマー・leading クールダウン状態が失われないようにする。
8+
* debounced 関数は `wait`/`edges`/`signal`/`maxWait` が変化したときだけ再生成される。
9+
* `edges` は boolean 正規化しているため `undefined` と `["trailing"]`、
10+
* `["leading","trailing"]` と `["trailing","leading"]` はそれぞれ同じ扱いになる。
911
*/
1012
export function useDebouncedCallback<Args extends unknown[]>(
1113
callback: (...args: Args) => void,
1214
wait: number,
1315
options?: DebounceOptions,
1416
) {
15-
const debouncedFn = useMemo(
16-
() => debounce(callback, wait, options),
17-
[callback, options, wait],
18-
);
17+
const callbackRef = useRef(callback);
18+
useEffect(() => {
19+
callbackRef.current = callback;
20+
}, [callback]);
21+
22+
const rawEdges = options?.edges;
23+
// debounce 側 default (["trailing"]) と整合させる
24+
const hasLeading = rawEdges?.includes("leading") ?? false;
25+
const hasTrailing = rawEdges?.includes("trailing") ?? true;
26+
const signal = options?.signal;
27+
const maxWait = options?.maxWait;
28+
29+
const debouncedFn = useMemo(() => {
30+
const invokeLatest = (...args: Args) => {
31+
callbackRef.current(...args);
32+
};
33+
const edges: Array<"leading" | "trailing"> = [];
34+
if (hasLeading) {
35+
edges.push("leading");
36+
}
37+
if (hasTrailing) {
38+
edges.push("trailing");
39+
}
40+
// ref は debounce のタイマーから非同期に読まれるだけで、レンダリング中には評価しない
41+
// oxlint-disable-next-line react-compiler-rules/refs
42+
return debounce(invokeLatest, wait, { edges, signal, maxWait });
43+
}, [hasLeading, hasTrailing, maxWait, signal, wait]);
1944

2045
useEffect(() => {
2146
return () => {
22-
debouncedFn.cancel();
47+
debouncedFn.dispose();
2348
};
2449
}, [debouncedFn]);
2550

0 commit comments

Comments
 (0)