From f02ebba751e3fdd8e426b50297c71ba9a75c5740 Mon Sep 17 00:00:00 2001 From: Alexander Kireev Date: Tue, 23 Jun 2026 03:19:54 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20=F0=9F=90=9B=20useDeepCompareEffect=20th?= =?UTF-8?q?rows=20on=20null-prototype=20objects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isDeepEqual` delegated to `fast-deep-equal/react`, which assumes every object inherits `valueOf`/`toString` from `Object.prototype` and calls them unconditionally. Objects created with `Object.create(null)` have neither, so the comparison threw `TypeError: a.valueOf is not a function` (closes #2700). Inline the comparator and guard the `valueOf`/`toString` shortcuts behind a real-method check so null-prototype objects fall through to the own-keys comparison. Behaviour is otherwise identical (verified by a 200k-case fuzz against the upstream module). The now-unused `fast-deep-equal` direct dependency is dropped. --- package.json | 1 - src/misc/isDeepEqual.ts | 81 +++++++++++++++++++++++++++++- tests/useDeepCompareEffect.test.ts | 26 ++++++++++ 3 files changed, 105 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 6b82e7614a..ca5864ba56 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,6 @@ "@types/js-cookie": "^3.0.0", "@xobotyi/scrollbar-width": "^1.9.5", "copy-to-clipboard": "^3.3.1", - "fast-deep-equal": "^3.1.3", "fast-shallow-equal": "^1.0.0", "js-cookie": "^3.0.0", "nano-css": "^5.6.2", diff --git a/src/misc/isDeepEqual.ts b/src/misc/isDeepEqual.ts index 2cff67378a..186425e407 100644 --- a/src/misc/isDeepEqual.ts +++ b/src/misc/isDeepEqual.ts @@ -1,3 +1,80 @@ -import isDeepEqualReact from 'fast-deep-equal/react'; +// Based on `fast-deep-equal/react` (MIT, Evgeny Poberezkin), inlined so that +// objects with a `null` prototype (e.g. created via `Object.create(null)`) can be +// compared without throwing. The upstream module assumes every object inherits +// `valueOf`/`toString` from `Object.prototype` and calls them unconditionally, +// which throws `TypeError: a.valueOf is not a function` for null-prototype objects. +// See https://github.com/streamich/react-use/issues/2700 and +// https://github.com/epoberezkin/fast-deep-equal/issues/111. +const isDeepEqual = (a: any, b: any): boolean => { + if (a === b) { + return true; + } -export default isDeepEqualReact; + if (a && b && typeof a === 'object' && typeof b === 'object') { + if (a.constructor !== b.constructor) { + return false; + } + + let length: number; + let i: number; + + if (Array.isArray(a)) { + length = a.length; + if (length !== b.length) { + return false; + } + for (i = length; i-- !== 0; ) { + if (!isDeepEqual(a[i], b[i])) { + return false; + } + } + return true; + } + + if (a.constructor === RegExp) { + return a.source === b.source && a.flags === b.flags; + } + // Guard the `valueOf`/`toString` shortcuts behind a real-method check so + // null-prototype objects fall through to the own-keys comparison below. + if (a.valueOf !== Object.prototype.valueOf && typeof a.valueOf === 'function') { + return a.valueOf() === b.valueOf(); + } + if (a.toString !== Object.prototype.toString && typeof a.toString === 'function') { + return a.toString() === b.toString(); + } + + const keys = Object.keys(a); + length = keys.length; + if (length !== Object.keys(b).length) { + return false; + } + + for (i = length; i-- !== 0; ) { + if (!Object.prototype.hasOwnProperty.call(b, keys[i])) { + return false; + } + } + + for (i = length; i-- !== 0; ) { + const key = keys[i]; + + if (key === '_owner' && a.$$typeof) { + // React-specific: avoid traversing React elements' _owner, which contains + // circular references and is not needed when comparing the elements themselves. + continue; + } + + if (!isDeepEqual(a[key], b[key])) { + return false; + } + } + + return true; + } + + // true if both NaN, false otherwise (`x !== x` is the canonical NaN check) + // eslint-disable-next-line no-self-compare + return a !== a && b !== b; +}; + +export default isDeepEqual; diff --git a/tests/useDeepCompareEffect.test.ts b/tests/useDeepCompareEffect.test.ts index d76897772a..05ddb9bfbc 100644 --- a/tests/useDeepCompareEffect.test.ts +++ b/tests/useDeepCompareEffect.test.ts @@ -39,3 +39,29 @@ it('should run clean-up provided on unmount', () => { unmount(); expect(mockEffectCleanup).toHaveBeenCalledTimes(1); }); + +it('should not throw when comparing null-prototype objects', () => { + const mockEffect = jest.fn(); + const createNullProtoDep = (value: number) => { + const dep = Object.create(null); + dep.value = value; + return dep; + }; + + let dep = createNullProtoDep(1); + const { rerender, result } = renderHook(() => useDeepCompareEffect(mockEffect, [dep])); + expect(result.error).toBeUndefined(); + expect(mockEffect).toHaveBeenCalledTimes(1); + + // Same shape -> comparison must not throw and the effect must not re-run. + dep = createNullProtoDep(1); + rerender(); + expect(result.error).toBeUndefined(); + expect(mockEffect).toHaveBeenCalledTimes(1); + + // Different value -> comparison must not throw and the effect should re-run. + dep = createNullProtoDep(2); + rerender(); + expect(result.error).toBeUndefined(); + expect(mockEffect).toHaveBeenCalledTimes(2); +});