Skip to content

Commit 8347b09

Browse files
devmgnclaude
andauthored
refactor(debounce): replace es-toolkit with custom debounce (#2707)
* refactor(debounce): replace es-toolkit with custom debounce implementation Drops the `es-toolkit` dependency in favor of an in-repo debounce utility. The custom implementation fixes a re-entrancy bug present in es-toolkit (pending args lost when the callback re-invokes the debounced function), preserves `this` context, and adds `maxWait` support plus runtime input validation. - Add `src/utils/debounce` with `debounce` and `DebouncedFunction<Args>` - Remove `src/utils/asyncDebounce` (thin wrapper over es-toolkit) - Retype `useDebouncedCallback` with `Args extends unknown[]` (no `any`) Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * fix : lint * refactor(debounce): rename baselineTime, unify wait, document stability - Rename `lastInvokeTime` → `baselineTime` to reflect its actual role as the maxWait baseline - Replace sentinel `0` with `undefined` so `Date.now() === 0` edge cases do not misfire - Unify parameter name `debounceMs` → `wait` across `debounce()` (matches lodash/es-toolkit and `useDebouncedCallback`) - Add JSDoc to `useDebouncedCallback` warning about passing stable references for `callback` / `options` No behavior change. 92/92 tests pass, `pnpm check` clean. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * 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 * fix: lint --------- Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
1 parent 76abd57 commit 8347b09

12 files changed

Lines changed: 1638 additions & 168 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
- **TanStack Query** with queryOptions helper
88
- **React Hook Form + Zod v4** with @hookform/resolvers
99
- **UI**: @radix-ui primitives, tailwind-variants, tailwind-merge
10-
- **Utilities**: es-toolkit (lodash alternative)
10+
- **Utilities**: custom debounce (`src/utils/debounce`)
1111
- **nuqs** for URL state management (NuqsAdapter in RootProvider)
1212
- **OxC** (Oxlint + Oxfmt) for linting and formatting
1313
- jsPlugins: @tanstack/eslint-plugin-query, eslint-plugin-react-hooks

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
"@radix-ui/react-slot": "1.2.4",
3434
"@tanstack/react-query": "5.99.0",
3535
"@tanstack/react-query-devtools": "5.99.0",
36-
"es-toolkit": "1.45.1",
3736
"next": "16.2.4",
3837
"nuqs": "2.8.9",
3938
"react": "19.2.5",

pnpm-lock.yaml

Lines changed: 0 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app/(sandbox)/dummy/page.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"use client";
22

33
import { FaceIcon } from "@radix-ui/react-icons";
4-
import { isArray } from "es-toolkit/compat";
54
import { Backdrop } from "../../../components/Backdrop";
65
import { Card } from "../../../components/Card";
76
import { Spinner } from "../../../components/Spinner";
@@ -11,7 +10,6 @@ import { useDisclosure } from "../../../hooks/useDisclosure";
1110
import { useIsComposing } from "../../../hooks/useIsComposing";
1211
import { useLocalStorage } from "../../../hooks/useLocalStorage";
1312
import { useMediaQuery } from "../../../hooks/useMediaQuery";
14-
import { asyncDebounce } from "../../../utils/asyncDebounce";
1513
import { createCustomEvent } from "../../../utils/createCustomEvent";
1614
import { isKeyOf } from "../../../utils/isKeyOf";
1715
import { isValueOf } from "../../../utils/isValueOf";
@@ -25,16 +23,13 @@ export default function Page() {
2523
useIsComposing();
2624
useLocalStorage("dummy", "");
2725
useMediaQuery("(min-width: 768px)");
28-
asyncDebounce(() => {
29-
// noop
30-
}, 1000);
3126
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
3227
createCustomEvent("" as keyof GlobalEventHandlersEventMap);
3328
const _isServer = isServer;
3429
const _isDevelopment = isDevelopment;
3530
const _isKeyOf = isKeyOf({}, "");
3631
const _isValueOf = isValueOf({}, "");
37-
const _isArray = isArray([]);
32+
const _isArray = Array.isArray([]);
3833

3934
return (
4035
<>

src/hooks/useDebouncedCallback/useDebouncedCallback.test.ts

Lines changed: 164 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,20 +144,182 @@ describe(useDebouncedCallback, () => {
144144
expect(callback).toHaveBeenCalledTimes(1);
145145
});
146146

147-
it("schedule メソッドでdebounce実行をスケジュールできること", () => {
147+
it("schedule メソッドで保留中のタイマーが延長され lastArgs が保持されること", () => {
148148
const callback = vi.fn();
149149
const { result } = renderHook(() => useDebouncedCallback(callback, 500));
150150

151151
act(() => {
152152
result.current("test");
153+
});
154+
155+
// 発火直前まで進める
156+
act(() => {
157+
vi.advanceTimersByTime(400);
158+
});
159+
expect(callback).not.toHaveBeenCalled();
160+
161+
// schedule でタイマーを再スタート
162+
act(() => {
153163
result.current.schedule();
154164
});
165+
166+
// 旧タイマーが発火するはずの時間を過ぎても未発火
167+
act(() => {
168+
vi.advanceTimersByTime(100);
169+
});
155170
expect(callback).not.toHaveBeenCalled();
156171

172+
// 新タイマー分の経過で lastArgs を保持したまま発火
173+
act(() => {
174+
vi.advanceTimersByTime(400);
175+
});
176+
expect(callback).toHaveBeenCalledExactlyOnceWith("test");
177+
});
178+
179+
it("edges: ['leading'] オプションで先頭のみ実行され trailing は発火しないこと", () => {
180+
const callback = vi.fn();
181+
const { result } = renderHook(() =>
182+
useDebouncedCallback(callback, 500, { edges: ["leading"] }),
183+
);
184+
185+
act(() => {
186+
result.current();
187+
});
188+
expect(callback).toHaveBeenCalledTimes(1);
189+
190+
// クールダウン中の呼び出しは発火しない
191+
act(() => {
192+
result.current();
193+
});
194+
expect(callback).toHaveBeenCalledTimes(1);
195+
196+
// trailing 無効なのでタイマー経過後も追加発火なし
157197
act(() => {
158198
vi.advanceTimersByTime(500);
159199
});
200+
expect(callback).toHaveBeenCalledTimes(1);
201+
});
160202

161-
expect(callback).toHaveBeenCalledExactlyOnceWith("test");
203+
it("AbortSignal が abort されると保留中および以降の呼び出しが無効化されること", () => {
204+
const callback = vi.fn();
205+
const controller = new AbortController();
206+
const { result } = renderHook(() =>
207+
useDebouncedCallback(callback, 500, { signal: controller.signal }),
208+
);
209+
210+
act(() => {
211+
result.current();
212+
});
213+
214+
// abort で保留中の実行がキャンセルされる
215+
act(() => {
216+
controller.abort();
217+
vi.advanceTimersByTime(500);
218+
});
219+
expect(callback).not.toHaveBeenCalled();
220+
221+
// abort 後の呼び出しも無効
222+
act(() => {
223+
result.current();
224+
vi.advanceTimersByTime(500);
225+
});
226+
expect(callback).not.toHaveBeenCalled();
227+
});
228+
229+
it("依存が変化しない再レンダリングで同一の debounced 関数が返されること", () => {
230+
const callback = vi.fn();
231+
const { result, rerender } = renderHook(
232+
({ wait }) => useDebouncedCallback(callback, wait),
233+
{ initialProps: { wait: 500 } },
234+
);
235+
236+
const prev = result.current;
237+
rerender({ wait: 500 });
238+
expect(result.current).toBe(prev);
239+
});
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);
162324
});
163325
});

src/hooks/useDebouncedCallback/useDebouncedCallback.ts

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

5-
// oxlint-disable-next-line typescript-eslint/no-explicit-any -- es-toolkit の debounce<F extends (...args: any[]) => void> に合わせる
6-
export function useDebouncedCallback<F extends (...args: any[]) => void>(
7-
callback: F,
5+
/**
6+
* 最新の `callback` 参照を ref で保持することで、インラインのアロー関数を渡しても
7+
* 保留中のタイマー・leading クールダウン状態が失われないようにする。
8+
* debounced 関数は `wait`/`edges`/`signal`/`maxWait` が変化したときだけ再生成される。
9+
* `edges` は boolean 正規化しているため `undefined` と `["trailing"]`、
10+
* `["leading","trailing"]` と `["trailing","leading"]` はそれぞれ同じ扱いになる。
11+
*/
12+
export function useDebouncedCallback<Args extends unknown[]>(
13+
callback: (...args: Args) => void,
814
wait: number,
915
options?: DebounceOptions,
1016
) {
11-
const debouncedFn = useMemo(
12-
() => debounce(callback, wait, options),
13-
[callback, options, wait],
14-
);
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]);
1544

1645
useEffect(() => {
1746
return () => {
18-
debouncedFn.cancel();
47+
debouncedFn.dispose();
1948
};
2049
}, [debouncedFn]);
2150

0 commit comments

Comments
 (0)