Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/internalize-debounce-react.md
Original file line number Diff line number Diff line change
@@ -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).
2 changes: 0 additions & 2 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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",
Expand Down
35 changes: 35 additions & 0 deletions packages/react/src/modules/core/debounce/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
type DebouncedFunction<TArgs extends unknown[]> = {
(...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 = <TArgs extends unknown[]>(
func: (...args: TArgs) => void,
wait = 0,
): DebouncedFunction<TArgs> => {
let timeout: ReturnType<typeof setTimeout> | 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;
};
3 changes: 2 additions & 1 deletion packages/react/src/modules/core/hooks/useOnBottomScroll.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import debounce from "lodash.debounce";
import { RefObject, useCallback, useEffect, useMemo } from "react";

import { debounce } from "../debounce";

type OnBottomScrollOptions = {
ref: RefObject<HTMLDivElement | null>;
callback: () => void;
Expand Down
82 changes: 82 additions & 0 deletions packages/react/test/core/debounce.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
10 changes: 4 additions & 6 deletions packages/react/test/core/useOnBottomScroll.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
18 changes: 0 additions & 18 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
Loading