diff --git a/.changeset/internalize-debounce-react.md b/.changeset/internalize-debounce-react.md new file mode 100644 index 000000000..0a4c707f0 --- /dev/null +++ b/.changeset/internalize-debounce-react.md @@ -0,0 +1,5 @@ +--- +"@knocklabs/react": patch +--- + +Internalize `lodash.debounce` in `@knocklabs/react` with a small trailing-edge debounce util and drop the `lodash.debounce` runtime dependency (and its `@types/lodash.debounce` dev dependency). diff --git a/packages/react/package.json b/packages/react/package.json index 1049787b9..1c9887c55 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -88,7 +88,6 @@ "@telegraph/tokens": ">=0.2.0", "@telegraph/tooltip": ">=0.2.2", "@telegraph/typography": ">=0.4.0", - "lodash.debounce": "^4.0.8", "lucide-react": "^0.544.0" }, "devDependencies": { @@ -98,7 +97,6 @@ "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", "@types/eslint-plugin-jsx-a11y": "^6", - "@types/lodash.debounce": "^4.0.9", "@types/react": "^19.2.14", "@types/react-dom": "^19.1.6", "@typescript-eslint/eslint-plugin": "^8.59.4", diff --git a/packages/react/src/modules/core/debounce/index.ts b/packages/react/src/modules/core/debounce/index.ts new file mode 100644 index 000000000..38127612e --- /dev/null +++ b/packages/react/src/modules/core/debounce/index.ts @@ -0,0 +1,35 @@ +type DebouncedFunction = { + (...args: TArgs): void; + cancel: () => void; +}; + +/** + * Minimal trailing-edge debounce, replacing `lodash.debounce` for our single + * use case (no `leading`/`maxWait` options). Invokes `func` once `wait` + * milliseconds have elapsed since the last call. + */ +export const debounce = ( + func: (...args: TArgs) => void, + wait = 0, +): DebouncedFunction => { + let timeout: ReturnType | null = null; + + const debounced = (...args: TArgs) => { + if (timeout !== null) { + clearTimeout(timeout); + } + timeout = setTimeout(() => { + timeout = null; + func(...args); + }, wait); + }; + + debounced.cancel = () => { + if (timeout !== null) { + clearTimeout(timeout); + timeout = null; + } + }; + + return debounced; +}; diff --git a/packages/react/src/modules/core/hooks/useOnBottomScroll.ts b/packages/react/src/modules/core/hooks/useOnBottomScroll.ts index 3e5d3e9ef..d10bd53dd 100644 --- a/packages/react/src/modules/core/hooks/useOnBottomScroll.ts +++ b/packages/react/src/modules/core/hooks/useOnBottomScroll.ts @@ -1,6 +1,7 @@ -import debounce from "lodash.debounce"; import { RefObject, useCallback, useEffect, useMemo } from "react"; +import { debounce } from "../debounce"; + type OnBottomScrollOptions = { ref: RefObject; callback: () => void; diff --git a/packages/react/test/core/debounce.test.ts b/packages/react/test/core/debounce.test.ts new file mode 100644 index 000000000..6347c7144 --- /dev/null +++ b/packages/react/test/core/debounce.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { debounce } from "../../src/modules/core/debounce"; + +describe("debounce", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("does not invoke the function before the wait elapses", () => { + const fn = vi.fn(); + const debounced = debounce(fn, 200); + + debounced(); + vi.advanceTimersByTime(199); + + expect(fn).not.toHaveBeenCalled(); + }); + + test("invokes the function once after the wait elapses", () => { + const fn = vi.fn(); + const debounced = debounce(fn, 200); + + debounced(); + vi.advanceTimersByTime(200); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + test("coalesces rapid calls into a single trailing invocation", () => { + const fn = vi.fn(); + const debounced = debounce(fn, 200); + + debounced(); + vi.advanceTimersByTime(100); + debounced(); + vi.advanceTimersByTime(100); + debounced(); + vi.advanceTimersByTime(200); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + test("invokes with the arguments from the most recent call", () => { + const fn = vi.fn(); + const debounced = debounce(fn, 200); + + debounced("first"); + debounced("second"); + vi.advanceTimersByTime(200); + + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith("second"); + }); + + test("cancel() prevents a pending invocation", () => { + const fn = vi.fn(); + const debounced = debounce(fn, 200); + + debounced(); + debounced.cancel(); + vi.advanceTimersByTime(200); + + expect(fn).not.toHaveBeenCalled(); + }); + + test("allows a new invocation after the previous one fires", () => { + const fn = vi.fn(); + const debounced = debounce(fn, 200); + + debounced(); + vi.advanceTimersByTime(200); + debounced(); + vi.advanceTimersByTime(200); + + expect(fn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/react/test/core/useOnBottomScroll.test.tsx b/packages/react/test/core/useOnBottomScroll.test.tsx index 6a1d01e9a..bb236d68a 100644 --- a/packages/react/test/core/useOnBottomScroll.test.tsx +++ b/packages/react/test/core/useOnBottomScroll.test.tsx @@ -5,12 +5,10 @@ import { describe, expect, test, vi } from "vitest"; import useOnBottomScroll from "../../src/modules/core/hooks/useOnBottomScroll"; import { renderWithProviders } from "../test-utils"; -// Mock debounce so callback executes immediately -vi.mock("lodash.debounce", () => { - return { - default: (fn: unknown) => fn, - }; -}); +// Mock debounce so the callback executes immediately +vi.mock("../../src/modules/core/debounce", () => ({ + debounce: (fn: () => void) => fn, +})); describe("useOnBottomScroll", () => { function TestComponent({ diff --git a/yarn.lock b/yarn.lock index 4105814e9..415d29f55 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5122,7 +5122,6 @@ __metadata: "@testing-library/dom": "npm:^10.4.1" "@testing-library/react": "npm:^16.3.2" "@types/eslint-plugin-jsx-a11y": "npm:^6" - "@types/lodash.debounce": "npm:^4.0.9" "@types/react": "npm:^19.2.14" "@types/react-dom": "npm:^19.1.6" "@typescript-eslint/eslint-plugin": "npm:^8.59.4" @@ -5134,7 +5133,6 @@ __metadata: eslint-plugin-react-hooks: "npm:^5.2.0" eslint-plugin-react-refresh: "npm:^0.5.2" jsdom: "npm:^29.1.0" - lodash.debounce: "npm:^4.0.8" lucide-react: "npm:^0.544.0" next: "npm:15.3.6" react: "npm:^19.2.5" @@ -8332,22 +8330,6 @@ __metadata: languageName: node linkType: hard -"@types/lodash.debounce@npm:^4.0.9": - version: 4.0.9 - resolution: "@types/lodash.debounce@npm:4.0.9" - dependencies: - "@types/lodash": "npm:*" - checksum: 10c0/9fbb24e5e52616faf60ba5c82d8c6517f4b86fc6e9ab353b4c56c0760f63d9bf53af3f2d8f6c37efa48090359fb96dba1087d497758511f6c40677002191d042 - languageName: node - linkType: hard - -"@types/lodash@npm:*": - version: 4.17.16 - resolution: "@types/lodash@npm:4.17.16" - checksum: 10c0/cf017901b8ab1d7aabc86d5189d9288f4f99f19a75caf020c0e2c77b8d4cead4db0d0b842d009b029339f92399f49f34377dd7c2721053388f251778b4c23534 - languageName: node - linkType: hard - "@types/ms@npm:*": version: 2.1.0 resolution: "@types/ms@npm:2.1.0"