Skip to content

Commit 0673696

Browse files
devmgnclaude
andcommitted
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]>
1 parent 76abd57 commit 0673696

12 files changed

Lines changed: 1427 additions & 162 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: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,20 +144,97 @@ 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+
});
170+
expect(callback).not.toHaveBeenCalled();
171+
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 無効なのでタイマー経過後も追加発火なし
197+
act(() => {
198+
vi.advanceTimersByTime(500);
199+
});
200+
expect(callback).toHaveBeenCalledTimes(1);
201+
});
202+
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+
});
155219
expect(callback).not.toHaveBeenCalled();
156220

221+
// abort 後の呼び出しも無効
157222
act(() => {
223+
result.current();
158224
vi.advanceTimersByTime(500);
159225
});
226+
expect(callback).not.toHaveBeenCalled();
227+
});
160228

161-
expect(callback).toHaveBeenCalledExactlyOnceWith("test");
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);
162239
});
163240
});

src/hooks/useDebouncedCallback/useDebouncedCallback.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import type { DebounceOptions } from "es-toolkit";
2-
import { debounce } from "es-toolkit";
1+
import type { DebounceOptions } from "../../utils/debounce";
32
import { useEffect, useMemo } 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+
export function useDebouncedCallback<Args extends unknown[]>(
6+
callback: (...args: Args) => void,
87
wait: number,
98
options?: DebounceOptions,
109
) {

src/utils/asyncDebounce/asyncDebounce.test.ts

Lines changed: 0 additions & 99 deletions
This file was deleted.

src/utils/asyncDebounce/asyncDebounce.ts

Lines changed: 0 additions & 39 deletions
This file was deleted.

src/utils/asyncDebounce/index.ts

Lines changed: 0 additions & 1 deletion
This file was deleted.

0 commit comments

Comments
 (0)