Skip to content
Merged
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/silver-terms-raise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-jitter-runtime": minor
---

Add support for selectComparator method to support comparison of circular objects.
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
3 changes: 3 additions & 0 deletions runtime/dist/index.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type HookEndEvent = {
offset: number;
arguments?: string[];
};
type HookAddress = Pick<HookEndEvent, 'hook' | 'file' | 'line' | 'offset' | 'arguments'>;
type ReactJitterOptions = {
enabled?: boolean;
onHookChange?: (change: HookChange) => void;
Expand All @@ -51,6 +52,7 @@ type ReactJitterOptions = {
}) => void;
};
type Scope = z.infer<typeof ScopeSchema>;
type Comparator = 'deepEqual' | 'circularDeepEqual';

type HookCall = HookChange & HookEndEvent & {
scope: Scope;
Expand All @@ -62,6 +64,7 @@ declare global {
reactJitter?: {
enabled?: boolean;
onHookChange?: (change: HookCall) => void;
selectComparator?: (hookAddress: HookAddress) => Comparator;
onRender?: (scope: Scope & {
hookResults: Record<string, unknown>;
renderCount: number;
Expand Down
3 changes: 3 additions & 0 deletions runtime/dist/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type HookEndEvent = {
offset: number;
arguments?: string[];
};
type HookAddress = Pick<HookEndEvent, 'hook' | 'file' | 'line' | 'offset' | 'arguments'>;
type ReactJitterOptions = {
enabled?: boolean;
onHookChange?: (change: HookChange) => void;
Expand All @@ -51,6 +52,7 @@ type ReactJitterOptions = {
}) => void;
};
type Scope = z.infer<typeof ScopeSchema>;
type Comparator = 'deepEqual' | 'circularDeepEqual';

type HookCall = HookChange & HookEndEvent & {
scope: Scope;
Expand All @@ -62,6 +64,7 @@ declare global {
reactJitter?: {
enabled?: boolean;
onHookChange?: (change: HookCall) => void;
selectComparator?: (hookAddress: HookAddress) => Comparator;
onRender?: (scope: Scope & {
hookResults: Record<string, unknown>;
renderCount: number;
Expand Down
50 changes: 38 additions & 12 deletions runtime/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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;
Expand All @@ -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("");
Expand All @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
50 changes: 38 additions & 12 deletions runtime/dist/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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;
Expand All @@ -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("");
Expand All @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "vitest"
"test": "vitest run"
},
"publishConfig": {
"access": "public"
Expand Down
21 changes: 11 additions & 10 deletions runtime/src/index.ts
Original file line number Diff line number Diff line change
@@ -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 & {
Expand Down Expand Up @@ -34,6 +36,7 @@ declare global {
reactJitter?: {
enabled?: boolean;
onHookChange?: (change: HookCall) => void;
selectComparator?: (hookAddress: HookAddress) => Comparator;
onRender?: (
scope: Scope & {
hookResults: Record<string, unknown>;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
7 changes: 7 additions & 0 deletions runtime/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ export type HookEndEvent = {
arguments?: string[];
};

export type HookAddress = Pick<
HookEndEvent,
'hook' | 'file' | 'line' | 'offset' | 'arguments'
>;

export type ReactJitterGlobal = z.infer<typeof ReactJitterGlobalSchema>;

export type ReactJitterOptions = {
Expand All @@ -59,3 +64,5 @@ export type ReactJitterOptions = {
};

export type Scope = z.infer<typeof ScopeSchema>;

export type Comparator = 'deepEqual' | 'circularDeepEqual';
Loading