diff --git a/.changeset/silver-terms-raise.md b/.changeset/silver-terms-raise.md new file mode 100644 index 0000000..e341554 --- /dev/null +++ b/.changeset/silver-terms-raise.md @@ -0,0 +1,5 @@ +--- +"react-jitter-runtime": minor +--- + +Add support for selectComparator method to support comparison of circular objects. diff --git a/README.md b/README.md index a8e7afd..4cf44f7 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,44 @@ window.reactJitter.onRender = (render) => { Modern bundlers will tree-shake the `import` and the function call from your production build, so it will have zero performance impact. +### Advanced: Custom Comparator Selection + +By default, React Jitter uses the `deepEqual` comparator to detect changes in hook values. However, you can customize which comparator is used on a per-hook basis using the `selectComparator` function. This is useful when dealing with circular data structures or when you need different comparison strategies for different hooks. + +```js +// Set a custom comparator selector +window.reactJitter.selectComparator = (hookAddress) => { + // Use circularDeepEqual for hooks that might return circular structures + if (hookAddress.hook === 'useSelector' || hookAddress.hook === 'useReduxState') { + return 'circularDeepEqual'; + } + + // Use deepEqual for everything else (default) + return 'deepEqual'; +}; +``` + +The `hookAddress` parameter contains information about the hook: + +```typescript +{ + hook: string; // Hook name, e.g., "useState", "useContext" + file: string; // File path where the hook is called + line: number; // Line number + offset: number; // Column offset + arguments?: string[]; // Hook arguments (if includeArguments is enabled) +} +``` + +**Available Comparators:** + +- `deepEqual` (default): Fast deep equality check that handles most cases. Will throw an error if it encounters deeply nested or circular structures. +- `circularDeepEqual`: Slower but handles circular references safely. Use this when your hooks return data with circular dependencies or extremely deep nesting. + +**When to Use `circularDeepEqual`:** + +If you see an error like "Maximum call stack size exceeded. Please use the 'circularDeepEqual' comparator", you should configure `selectComparator` to return `'circularDeepEqual'` for the specific hook mentioned in the error message. + ## API and Configuration The `reactJitter` function accepts a configuration object with two callbacks: `onHookChange` and `onRender`. diff --git a/biome.json b/biome.json index 84d6eab..369d631 100644 --- a/biome.json +++ b/biome.json @@ -1,6 +1,6 @@ { "$schema": "https://biomejs.dev/schemas/2.1.3/schema.json", - "assist": { "actions": { "source": { "organizeImports": "on" } } }, + "assist": { "actions": { "source": { "organizeImports": "off" } } }, "linter": { "enabled": true, "rules": { diff --git a/runtime/dist/index.d.mts b/runtime/dist/index.d.mts index 9ebf2a4..bf4c368 100644 --- a/runtime/dist/index.d.mts +++ b/runtime/dist/index.d.mts @@ -41,6 +41,7 @@ type HookEndEvent = { offset: number; arguments?: string[]; }; +type HookAddress = Pick; type ReactJitterOptions = { enabled?: boolean; onHookChange?: (change: HookChange) => void; @@ -51,6 +52,7 @@ type ReactJitterOptions = { }) => void; }; type Scope = z.infer; +type Comparator = 'deepEqual' | 'circularDeepEqual'; type HookCall = HookChange & HookEndEvent & { scope: Scope; @@ -62,6 +64,7 @@ declare global { reactJitter?: { enabled?: boolean; onHookChange?: (change: HookCall) => void; + selectComparator?: (hookAddress: HookAddress) => Comparator; onRender?: (scope: Scope & { hookResults: Record; renderCount: number; diff --git a/runtime/dist/index.d.ts b/runtime/dist/index.d.ts index 9ebf2a4..bf4c368 100644 --- a/runtime/dist/index.d.ts +++ b/runtime/dist/index.d.ts @@ -41,6 +41,7 @@ type HookEndEvent = { offset: number; arguments?: string[]; }; +type HookAddress = Pick; type ReactJitterOptions = { enabled?: boolean; onHookChange?: (change: HookChange) => void; @@ -51,6 +52,7 @@ type ReactJitterOptions = { }) => void; }; type Scope = z.infer; +type Comparator = 'deepEqual' | 'circularDeepEqual'; type HookCall = HookChange & HookEndEvent & { scope: Scope; @@ -62,6 +64,7 @@ declare global { reactJitter?: { enabled?: boolean; onHookChange?: (change: HookCall) => void; + selectComparator?: (hookAddress: HookAddress) => Comparator; onRender?: (scope: Scope & { hookResults: Record; renderCount: number; diff --git a/runtime/dist/index.js b/runtime/dist/index.js index 57731dd..b93f307 100644 --- a/runtime/dist/index.js +++ b/runtime/dist/index.js @@ -447,7 +447,8 @@ function createCustomEqual(options) { } // src/utils/getChanges.ts -function getChanges(prev, next) { +function getChanges(prev, next, comparator = "deepEqual") { + const equals = comparator === "circularDeepEqual" ? circularDeepEqual : deepEqual; const changedKeys = []; const unstableKeys = []; const isObject = (v) => v !== null && typeof v === "object"; @@ -466,7 +467,7 @@ function getChanges(prev, next) { } const max = Math.max(prev.length, next.length); for (let i = 0; i < max; i++) { - const deepEqItem = deepEqual(prev[i], next[i]); + const deepEqItem = equals(prev[i], next[i]); const refDiffItem = isObject(prev[i]) && isObject(next[i]) && prev[i] !== next[i]; if (!deepEqItem || refDiffItem) { const key = String(i); @@ -479,7 +480,7 @@ function getChanges(prev, next) { } else if (isObject(prev) && isObject(next)) { const allKeys = /* @__PURE__ */ new Set([...Object.keys(prev), ...Object.keys(next)]); for (const key of allKeys) { - const deepEqProp = deepEqual(prev[key], next[key]); + const deepEqProp = equals(prev[key], next[key]); const refDiffProp = isObject(prev[key]) && isObject(next[key]) && prev[key] !== next[key]; if (!deepEqProp || refDiffProp) { changedKeys.push(key); @@ -489,7 +490,7 @@ function getChanges(prev, next) { } } } else { - const deepEqRoot = deepEqual(prev, next); + const deepEqRoot = equals(prev, next); const refDiffRoot = isObject(prev) && isObject(next) && prev !== next; const unstable = refDiffRoot && deepEqRoot; const changed = !deepEqRoot || refDiffRoot; @@ -500,7 +501,7 @@ function getChanges(prev, next) { }; } const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v); - const unstableRoot = isPlainObject(prev) && isPlainObject(next) && prev !== next && deepEqual(prev, next); + const unstableRoot = isPlainObject(prev) && isPlainObject(next) && prev !== next && equals(prev, next); if (unstableRoot && changedKeys.length === 0) { changedKeys.push(""); unstableKeys.push(""); @@ -512,6 +513,31 @@ function getChanges(prev, next) { }; } +// src/utils/compareChanges.ts +function compareChanges(hookAddress, prev, current) { + var _a, _b, _c; + if (prev !== "undefined" && prev !== current) { + const comparator = (_c = (_b = (_a = window == null ? void 0 : window.reactJitter) == null ? void 0 : _a.selectComparator) == null ? void 0 : _b.call(_a, hookAddress)) != null ? _c : "deepEqual"; + try { + return getChanges(prev, current, comparator); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const isRecursionError = /(?:maximum call stack(?: size)? exceeded|too much recursion|stack overflow)/i.test( + errorMessage + ); + if (isRecursionError && comparator !== "circularDeepEqual") { + throw new Error( + `Maximum call stack size exceeded. Please use the "circularDeepEqual" comparator with selectComparator option. +Hook address: ${JSON.stringify(hookAddress, null, 2)}.`, + { cause: error } + ); + } + throw error; + } + } + return null; +} + // src/index.ts var scopes = {}; var hookStack = /* @__PURE__ */ new Map(); @@ -544,7 +570,13 @@ function useJitterScope(scope) { const hookId = `${scopeId}-${hookEndEvent.id}`; if (shouldReportChanges()) { const prevResult = currentScope.hookResults[hookId]; - const changes = compareChanges(prevResult, hookResult); + const hookAddress = { + hook: hookEndEvent.hook, + file: hookEndEvent.file, + line: hookEndEvent.line, + offset: hookEndEvent.offset + }; + const changes = compareChanges(hookAddress, prevResult, hookResult); if (changes) { const hookCall = { hook: hookEndEvent.hook, @@ -621,12 +653,6 @@ function getScopeCount(scope) { } return scopeCounter[scope.id]++; } -function compareChanges(prev, current) { - if (prev !== "undefined" && prev !== current) { - return getChanges(prev, current); - } - return null; -} // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { reactJitter, diff --git a/runtime/dist/index.mjs b/runtime/dist/index.mjs index db1ccef..f429472 100644 --- a/runtime/dist/index.mjs +++ b/runtime/dist/index.mjs @@ -412,7 +412,8 @@ function createCustomEqual(options) { } // src/utils/getChanges.ts -function getChanges(prev, next) { +function getChanges(prev, next, comparator = "deepEqual") { + const equals = comparator === "circularDeepEqual" ? circularDeepEqual : deepEqual; const changedKeys = []; const unstableKeys = []; const isObject = (v) => v !== null && typeof v === "object"; @@ -431,7 +432,7 @@ function getChanges(prev, next) { } const max = Math.max(prev.length, next.length); for (let i = 0; i < max; i++) { - const deepEqItem = deepEqual(prev[i], next[i]); + const deepEqItem = equals(prev[i], next[i]); const refDiffItem = isObject(prev[i]) && isObject(next[i]) && prev[i] !== next[i]; if (!deepEqItem || refDiffItem) { const key = String(i); @@ -444,7 +445,7 @@ function getChanges(prev, next) { } else if (isObject(prev) && isObject(next)) { const allKeys = /* @__PURE__ */ new Set([...Object.keys(prev), ...Object.keys(next)]); for (const key of allKeys) { - const deepEqProp = deepEqual(prev[key], next[key]); + const deepEqProp = equals(prev[key], next[key]); const refDiffProp = isObject(prev[key]) && isObject(next[key]) && prev[key] !== next[key]; if (!deepEqProp || refDiffProp) { changedKeys.push(key); @@ -454,7 +455,7 @@ function getChanges(prev, next) { } } } else { - const deepEqRoot = deepEqual(prev, next); + const deepEqRoot = equals(prev, next); const refDiffRoot = isObject(prev) && isObject(next) && prev !== next; const unstable = refDiffRoot && deepEqRoot; const changed = !deepEqRoot || refDiffRoot; @@ -465,7 +466,7 @@ function getChanges(prev, next) { }; } const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v); - const unstableRoot = isPlainObject(prev) && isPlainObject(next) && prev !== next && deepEqual(prev, next); + const unstableRoot = isPlainObject(prev) && isPlainObject(next) && prev !== next && equals(prev, next); if (unstableRoot && changedKeys.length === 0) { changedKeys.push(""); unstableKeys.push(""); @@ -477,6 +478,31 @@ function getChanges(prev, next) { }; } +// src/utils/compareChanges.ts +function compareChanges(hookAddress, prev, current) { + var _a, _b, _c; + if (prev !== "undefined" && prev !== current) { + const comparator = (_c = (_b = (_a = window == null ? void 0 : window.reactJitter) == null ? void 0 : _a.selectComparator) == null ? void 0 : _b.call(_a, hookAddress)) != null ? _c : "deepEqual"; + try { + return getChanges(prev, current, comparator); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const isRecursionError = /(?:maximum call stack(?: size)? exceeded|too much recursion|stack overflow)/i.test( + errorMessage + ); + if (isRecursionError && comparator !== "circularDeepEqual") { + throw new Error( + `Maximum call stack size exceeded. Please use the "circularDeepEqual" comparator with selectComparator option. +Hook address: ${JSON.stringify(hookAddress, null, 2)}.`, + { cause: error } + ); + } + throw error; + } + } + return null; +} + // src/index.ts var scopes = {}; var hookStack = /* @__PURE__ */ new Map(); @@ -509,7 +535,13 @@ function useJitterScope(scope) { const hookId = `${scopeId}-${hookEndEvent.id}`; if (shouldReportChanges()) { const prevResult = currentScope.hookResults[hookId]; - const changes = compareChanges(prevResult, hookResult); + const hookAddress = { + hook: hookEndEvent.hook, + file: hookEndEvent.file, + line: hookEndEvent.line, + offset: hookEndEvent.offset + }; + const changes = compareChanges(hookAddress, prevResult, hookResult); if (changes) { const hookCall = { hook: hookEndEvent.hook, @@ -586,12 +618,6 @@ function getScopeCount(scope) { } return scopeCounter[scope.id]++; } -function compareChanges(prev, current) { - if (prev !== "undefined" && prev !== current) { - return getChanges(prev, current); - } - return null; -} export { reactJitter, useJitterScope diff --git a/runtime/package.json b/runtime/package.json index 25f90a2..b7ea21a 100644 --- a/runtime/package.json +++ b/runtime/package.json @@ -34,7 +34,7 @@ "scripts": { "build": "tsup", "dev": "tsup --watch", - "test": "vitest" + "test": "vitest run" }, "publishConfig": { "access": "public" diff --git a/runtime/src/index.ts b/runtime/src/index.ts index 23b328a..856e32b 100644 --- a/runtime/src/index.ts +++ b/runtime/src/index.ts @@ -1,12 +1,14 @@ import type { HookChange, HookEndEvent, + HookAddress, ReactJitterOptions, Scope, + Comparator, } from './types'; import React from 'react'; -import { getChanges } from './utils/getChanges'; +import { compareChanges } from './utils/compareChanges'; type HookCall = HookChange & HookEndEvent & { @@ -34,6 +36,7 @@ declare global { reactJitter?: { enabled?: boolean; onHookChange?: (change: HookCall) => void; + selectComparator?: (hookAddress: HookAddress) => Comparator; onRender?: ( scope: Scope & { hookResults: Record; @@ -88,7 +91,13 @@ export function useJitterScope(scope: Scope) { if (shouldReportChanges()) { const prevResult = currentScope.hookResults[hookId]; - const changes = compareChanges(prevResult, hookResult); + const hookAddress = { + hook: hookEndEvent.hook, + file: hookEndEvent.file, + line: hookEndEvent.line, + offset: hookEndEvent.offset, + }; + const changes = compareChanges(hookAddress, prevResult, hookResult); if (changes) { const hookCall: HookCall = { hook: hookEndEvent.hook, @@ -186,11 +195,3 @@ function getScopeCount(scope: Scope) { return scopeCounter[scope.id]++; } - -function compareChanges(prev: unknown, current: unknown) { - if (prev !== 'undefined' && prev !== current) { - return getChanges(prev, current); - } - - return null; -} diff --git a/runtime/src/types.ts b/runtime/src/types.ts index e3c4a7e..4fa68cb 100644 --- a/runtime/src/types.ts +++ b/runtime/src/types.ts @@ -44,6 +44,11 @@ export type HookEndEvent = { arguments?: string[]; }; +export type HookAddress = Pick< + HookEndEvent, + 'hook' | 'file' | 'line' | 'offset' | 'arguments' +>; + export type ReactJitterGlobal = z.infer; export type ReactJitterOptions = { @@ -59,3 +64,5 @@ export type ReactJitterOptions = { }; export type Scope = z.infer; + +export type Comparator = 'deepEqual' | 'circularDeepEqual'; diff --git a/runtime/src/utils/compareChanges.test.ts b/runtime/src/utils/compareChanges.test.ts new file mode 100644 index 0000000..f1f25f0 --- /dev/null +++ b/runtime/src/utils/compareChanges.test.ts @@ -0,0 +1,107 @@ +import { expect, test, describe, beforeEach, afterEach, vi } from 'vitest'; +import { compareChanges } from './compareChanges'; +import type { HookAddress } from '../types'; + +describe('compareChanges', () => { + const hookAddress: HookAddress = { + hook: 'useState', + file: 'src/utils/test.tsx', + line: 10, + offset: 5, + }; + + let originalWindow: typeof globalThis.window; + + beforeEach(() => { + originalWindow = globalThis.window; + }); + + afterEach(() => { + globalThis.window = originalWindow; + }); + + test('returns null when prev is "undefined" string', () => { + expect(compareChanges(hookAddress, 'undefined', { a: 1 })).toBeNull(); + }); + + test('returns null when prev === current', () => { + const value = { a: 1 }; + expect(compareChanges(hookAddress, value, value)).toBeNull(); + }); + + test('returns getChanges result when values differ', () => { + const result = compareChanges(hookAddress, { a: 1 }, { a: 2 }); + expect(result).toMatchObject({ + unstable: expect.any(Boolean), + unstableKeys: expect.any(Array), + changedKeys: expect.any(Array), + }); + }); + + test('defaults to deepEqual comparator', () => { + globalThis.window = {} as typeof globalThis.window; + const result = compareChanges(hookAddress, { a: 1 }, { a: 2 }); + expect(result).not.toBeNull(); + }); + + test('calls selectComparator with hookAddress', () => { + const selectComparator = vi.fn(() => 'deepEqual' as const); + globalThis.window = { + reactJitter: { selectComparator }, + } as unknown as typeof globalThis.window; + + compareChanges(hookAddress, { a: 1 }, { a: 2 }); + + expect(selectComparator).toHaveBeenCalledWith(hookAddress); + }); + + test('uses circularDeepEqual when selected', () => { + const selectComparator = vi.fn(() => 'circularDeepEqual' as const); + globalThis.window = { + reactJitter: { selectComparator }, + } as unknown as typeof globalThis.window; + + const obj = { value: 1 } as Record; + obj.self = obj; + + const result = compareChanges(hookAddress, obj, obj); + expect(result).toBeNull(); + }); + + test('throws enhanced error for stack overflow with deepEqual', () => { + const obj1 = { value: 1 } as Record; + obj1.self = obj1; + + const obj2 = { value: 1 } as Record; + obj2.self = obj2; + + try { + compareChanges(hookAddress, obj1, obj2); + throw new Error('Expected compareChanges to throw an error'); + } catch (error) { + expect((error as Error).message).toContain('Maximum call stack'); + expect((error as Error).message).toContain('circularDeepEqual'); + expect((error as Error).message).toContain('useState'); + expect((error as Error & { cause?: unknown }).cause).toBeDefined(); + } + }); + + test('does not throw enhanced error with circularDeepEqual', () => { + const selectComparator = vi.fn(() => 'circularDeepEqual' as const); + globalThis.window = { + reactJitter: { selectComparator }, + } as unknown as typeof globalThis.window; + + const obj1 = { value: 1 } as Record; + obj1.self = obj1; + + const obj2 = { value: 1 } as Record; + obj2.self = obj2; + + try { + compareChanges(hookAddress, obj1, obj2); + } catch (error) { + expect((error as Error).message).not.toContain('circularDeepEqual'); + } + }); +}); diff --git a/runtime/src/utils/compareChanges.ts b/runtime/src/utils/compareChanges.ts new file mode 100644 index 0000000..3696237 --- /dev/null +++ b/runtime/src/utils/compareChanges.ts @@ -0,0 +1,36 @@ +import { getChanges } from './getChanges'; +import type { HookAddress } from '../types'; + +export function compareChanges( + hookAddress: HookAddress, + prev: unknown, + current: unknown, +) { + if (prev !== 'undefined' && prev !== current) { + const comparator = + window?.reactJitter?.selectComparator?.(hookAddress) ?? 'deepEqual'; + + try { + return getChanges(prev, current, comparator); + } catch (error: unknown) { + // Extract error message regardless of error type + const errorMessage = + error instanceof Error ? error.message : String(error); + const isRecursionError = + /(?:maximum call stack(?: size)? exceeded|too much recursion|stack overflow)/i.test( + errorMessage, + ); + + if (isRecursionError && comparator !== 'circularDeepEqual') { + throw new Error( + `Maximum call stack size exceeded. Please use the "circularDeepEqual" comparator with selectComparator option. \nHook address: ${JSON.stringify(hookAddress, null, 2)}.`, + { cause: error }, + ); + } + + throw error; + } + } + + return null; +} diff --git a/runtime/src/utils/getChanges.test.ts b/runtime/src/utils/getChanges.test.ts index 598c9f1..945ae77 100644 --- a/runtime/src/utils/getChanges.test.ts +++ b/runtime/src/utils/getChanges.test.ts @@ -176,4 +176,26 @@ describe('getChanges', () => { changedKeys: ['user', 'todos'], }); }); + + test('handles circular objects', () => { + const prev = { value: 1 } as Record; + prev.self = prev; + + const next = { value: 1 } as Record; + next.self = next; + + // Different references but same circular structure + expect(getChanges(prev, next, 'circularDeepEqual')).toEqual({ + unstable: true, + unstableKeys: ['self'], + changedKeys: ['self'], + }); + + // Same reference circular object + expect(getChanges(prev, prev, 'circularDeepEqual')).toEqual({ + unstable: false, + unstableKeys: [], + changedKeys: [], + }); + }); }); diff --git a/runtime/src/utils/getChanges.ts b/runtime/src/utils/getChanges.ts index afa22a6..db01373 100644 --- a/runtime/src/utils/getChanges.ts +++ b/runtime/src/utils/getChanges.ts @@ -1,6 +1,13 @@ -import { deepEqual } from 'fast-equals'; +import { deepEqual, circularDeepEqual } from 'fast-equals'; +import type { Comparator } from '../types'; -export function getChanges(prev: unknown, next: unknown) { +export function getChanges( + prev: unknown, + next: unknown, + comparator: Comparator = 'deepEqual', +) { + const equals = + comparator === 'circularDeepEqual' ? circularDeepEqual : deepEqual; const changedKeys = []; const unstableKeys = []; const isObject = (v: unknown): v is Record => @@ -26,7 +33,7 @@ export function getChanges(prev: unknown, next: unknown) { const max = Math.max(prev.length, next.length); for (let i = 0; i < max; i++) { - const deepEqItem = deepEqual(prev[i], next[i]); + const deepEqItem = equals(prev[i], next[i]); const refDiffItem = isObject(prev[i]) && isObject(next[i]) && prev[i] !== next[i]; if (!deepEqItem || refDiffItem) { @@ -42,7 +49,7 @@ export function getChanges(prev: unknown, next: unknown) { } else if (isObject(prev) && isObject(next)) { const allKeys = new Set([...Object.keys(prev), ...Object.keys(next)]); for (const key of allKeys) { - const deepEqProp = deepEqual(prev[key], next[key]); + const deepEqProp = equals(prev[key], next[key]); const refDiffProp = isObject(prev[key]) && isObject(next[key]) && prev[key] !== next[key]; if (!deepEqProp || refDiffProp) { @@ -55,7 +62,7 @@ export function getChanges(prev: unknown, next: unknown) { // primitives (or mismatched types other than array↔object) } else { - const deepEqRoot = deepEqual(prev, next); + const deepEqRoot = equals(prev, next); const refDiffRoot = isObject(prev) && isObject(next) && prev !== next; const unstable = refDiffRoot && deepEqRoot; const changed = !deepEqRoot || refDiffRoot; @@ -73,7 +80,7 @@ export function getChanges(prev: unknown, next: unknown) { isPlainObject(prev) && isPlainObject(next) && prev !== next && - deepEqual(prev, next); + equals(prev, next); if (unstableRoot && changedKeys.length === 0) { // For plain object reference change, ensure root sentinel marks both changed and unstable