From d69399cdb00c12b7d645131f051dbcdf75c6a2b2 Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Wed, 17 Jun 2026 09:18:46 -0500 Subject: [PATCH] chore(react-core): internalize fast-deep-equal (KNO-13810) --- .../internalize-fast-deep-equal-react-core.md | 5 ++ packages/react-core/package.json | 1 - .../src/modules/core/deepEqual/index.ts | 53 +++++++++++++++ .../modules/core/hooks/useStableOptions.ts | 5 +- .../react-core/test/core/deepEqual.test.ts | 65 +++++++++++++++++++ yarn.lock | 1 - 6 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 .changeset/internalize-fast-deep-equal-react-core.md create mode 100644 packages/react-core/src/modules/core/deepEqual/index.ts create mode 100644 packages/react-core/test/core/deepEqual.test.ts diff --git a/.changeset/internalize-fast-deep-equal-react-core.md b/.changeset/internalize-fast-deep-equal-react-core.md new file mode 100644 index 000000000..6639a9bb3 --- /dev/null +++ b/.changeset/internalize-fast-deep-equal-react-core.md @@ -0,0 +1,5 @@ +--- +"@knocklabs/react-core": patch +--- + +Internalize `fast-deep-equal` in `@knocklabs/react-core` with a small inlined deep-equality util and drop the runtime dependency, removing it from consumers' install graphs. diff --git a/packages/react-core/package.json b/packages/react-core/package.json index 7e723480e..8250da578 100644 --- a/packages/react-core/package.json +++ b/packages/react-core/package.json @@ -50,7 +50,6 @@ "@knocklabs/client": "workspace:^", "@tanstack/react-store": "^0.7.3", "date-fns": "^4.0.0", - "fast-deep-equal": "^3.1.3", "swr": "^2.4.1" }, "devDependencies": { diff --git a/packages/react-core/src/modules/core/deepEqual/index.ts b/packages/react-core/src/modules/core/deepEqual/index.ts new file mode 100644 index 000000000..737e0cffc --- /dev/null +++ b/packages/react-core/src/modules/core/deepEqual/index.ts @@ -0,0 +1,53 @@ +/** + * Deep structural equality check. Recursively compares primitives, plain + * objects, arrays, `Date`, and `RegExp` values. Does not special-case Map/Set. + */ +export default function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + + if (a && b && typeof a === "object" && typeof b === "object") { + const objA = a as Record; + const objB = b as Record; + + if (objA.constructor !== objB.constructor) return false; + + if (Array.isArray(a) && Array.isArray(b)) { + const length = a.length; + if (length !== b.length) return false; + for (let i = length; i-- !== 0; ) { + if (!deepEqual(a[i], b[i])) return false; + } + return true; + } + + if (a.constructor === RegExp) { + const reA = a as RegExp; + const reB = b as RegExp; + return reA.source === reB.source && reA.flags === reB.flags; + } + if (objA.valueOf !== Object.prototype.valueOf) { + return objA.valueOf() === objB.valueOf(); + } + if (objA.toString !== Object.prototype.toString) { + return objA.toString() === objB.toString(); + } + + const keys = Object.keys(objA); + const length = keys.length; + if (length !== Object.keys(objB).length) return false; + + for (let i = length; i-- !== 0; ) { + if (!Object.prototype.hasOwnProperty.call(objB, keys[i]!)) return false; + } + + for (let i = length; i-- !== 0; ) { + const key = keys[i]!; + if (!deepEqual(objA[key], objB[key])) return false; + } + + return true; + } + + // true if both NaN, false otherwise + return a !== a && b !== b; +} diff --git a/packages/react-core/src/modules/core/hooks/useStableOptions.ts b/packages/react-core/src/modules/core/hooks/useStableOptions.ts index 8645db605..c091e7df1 100644 --- a/packages/react-core/src/modules/core/hooks/useStableOptions.ts +++ b/packages/react-core/src/modules/core/hooks/useStableOptions.ts @@ -1,13 +1,14 @@ -import fastDeepEqual from "fast-deep-equal"; import { useMemo, useRef } from "react"; +import deepEqual from "../deepEqual"; + export default function useStableOptions(options: T): T { const optionsRef = useRef(undefined); return useMemo(() => { const currentOptions = optionsRef.current; - if (currentOptions && fastDeepEqual(options, currentOptions)) { + if (currentOptions && deepEqual(options, currentOptions)) { return currentOptions; } diff --git a/packages/react-core/test/core/deepEqual.test.ts b/packages/react-core/test/core/deepEqual.test.ts new file mode 100644 index 000000000..28a5ba720 --- /dev/null +++ b/packages/react-core/test/core/deepEqual.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "vitest"; + +import deepEqual from "../../src/modules/core/deepEqual"; + +describe("deepEqual", () => { + test("treats identical references and equal primitives as equal", () => { + const obj = { a: 1 }; + expect(deepEqual(obj, obj)).toBe(true); + expect(deepEqual(1, 1)).toBe(true); + expect(deepEqual("a", "a")).toBe(true); + expect(deepEqual(true, true)).toBe(true); + expect(deepEqual(null, null)).toBe(true); + expect(deepEqual(undefined, undefined)).toBe(true); + }); + + test("distinguishes differing primitives", () => { + expect(deepEqual(1, 2)).toBe(false); + expect(deepEqual("a", "b")).toBe(false); + expect(deepEqual(null, undefined)).toBe(false); + expect(deepEqual(0, false)).toBe(false); + }); + + test("treats NaN as equal to NaN", () => { + expect(deepEqual(NaN, NaN)).toBe(true); + }); + + test("compares nested objects structurally", () => { + expect(deepEqual({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } })).toBe(true); + expect(deepEqual({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 3 } })).toBe(false); + }); + + test("is independent of object key order", () => { + expect(deepEqual({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true); + }); + + test("distinguishes objects with differing key counts", () => { + expect(deepEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false); + expect(deepEqual({ a: 1, b: 2 }, { a: 1 })).toBe(false); + }); + + test("compares arrays by length and element", () => { + expect(deepEqual([1, 2, 3], [1, 2, 3])).toBe(true); + expect(deepEqual([1, 2], [1, 2, 3])).toBe(false); + expect(deepEqual([{ a: 1 }], [{ a: 1 }])).toBe(true); + }); + + test("does not treat an array and an object as equal", () => { + expect(deepEqual([], {})).toBe(false); + }); + + test("compares Date values by time", () => { + expect(deepEqual(new Date("2020-01-01"), new Date("2020-01-01"))).toBe( + true, + ); + expect(deepEqual(new Date("2020-01-01"), new Date("2021-01-01"))).toBe( + false, + ); + }); + + test("compares RegExp values by source and flags", () => { + expect(deepEqual(/abc/gi, /abc/gi)).toBe(true); + expect(deepEqual(/abc/g, /abc/i)).toBe(false); + expect(deepEqual(/abc/, /abd/)).toBe(false); + }); +}); diff --git a/yarn.lock b/yarn.lock index 415d29f55..be61dd625 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5044,7 +5044,6 @@ __metadata: eslint: "npm:^8.56.0" eslint-plugin-react-hooks: "npm:^5.2.0" eslint-plugin-react-refresh: "npm:^0.5.2" - fast-deep-equal: "npm:^3.1.3" jsdom: "npm:^29.1.0" react: "npm:^19.2.5" react-dom: "npm:^19.2.5"