react-jitter 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -128,6 +128,44 @@ window.reactJitter.onRender = (render) => {
128
128
 
129
129
  Modern bundlers will tree-shake the `import` and the function call from your production build, so it will have zero performance impact.
130
130
 
131
+ ### Advanced: Custom Comparator Selection
132
+
133
+ 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.
134
+
135
+ ```js
136
+ // Set a custom comparator selector
137
+ window.reactJitter.selectComparator = (hookAddress) => {
138
+ // Use circularDeepEqual for hooks that might return circular structures
139
+ if (hookAddress.hook === 'useSelector' || hookAddress.hook === 'useReduxState') {
140
+ return 'circularDeepEqual';
141
+ }
142
+
143
+ // Use deepEqual for everything else (default)
144
+ return 'deepEqual';
145
+ };
146
+ ```
147
+
148
+ The `hookAddress` parameter contains information about the hook:
149
+
150
+ ```typescript
151
+ {
152
+ hook: string; // Hook name, e.g., "useState", "useContext"
153
+ file: string; // File path where the hook is called
154
+ line: number; // Line number
155
+ offset: number; // Column offset
156
+ arguments?: string[]; // Hook arguments (if includeArguments is enabled)
157
+ }
158
+ ```
159
+
160
+ **Available Comparators:**
161
+
162
+ - `deepEqual` (default): Fast deep equality check that handles most cases. Will throw an error if it encounters deeply nested or circular structures.
163
+ - `circularDeepEqual`: Slower but handles circular references safely. Use this when your hooks return data with circular dependencies or extremely deep nesting.
164
+
165
+ **When to Use `circularDeepEqual`:**
166
+
167
+ 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.
168
+
131
169
  ## API and Configuration
132
170
 
133
171
  The `reactJitter` function accepts a configuration object with two callbacks: `onHookChange` and `onRender`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-jitter",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "A developer tool for debugging React performance issues caused by hook changes and component re-renders.",
5
5
  "exports": {
6
6
  ".": {
@@ -41,6 +41,7 @@ type HookEndEvent = {
41
41
  offset: number;
42
42
  arguments?: string[];
43
43
  };
44
+ type HookAddress = Pick<HookEndEvent, 'hook' | 'file' | 'line' | 'offset' | 'arguments'>;
44
45
  type ReactJitterOptions = {
45
46
  enabled?: boolean;
46
47
  onHookChange?: (change: HookChange) => void;
@@ -51,6 +52,7 @@ type ReactJitterOptions = {
51
52
  }) => void;
52
53
  };
53
54
  type Scope = z.infer<typeof ScopeSchema>;
55
+ type Comparator = 'deepEqual' | 'circularDeepEqual';
54
56
 
55
57
  type HookCall = HookChange & HookEndEvent & {
56
58
  scope: Scope;
@@ -62,6 +64,7 @@ declare global {
62
64
  reactJitter?: {
63
65
  enabled?: boolean;
64
66
  onHookChange?: (change: HookCall) => void;
67
+ selectComparator?: (hookAddress: HookAddress) => Comparator;
65
68
  onRender?: (scope: Scope & {
66
69
  hookResults: Record<string, unknown>;
67
70
  renderCount: number;
@@ -41,6 +41,7 @@ type HookEndEvent = {
41
41
  offset: number;
42
42
  arguments?: string[];
43
43
  };
44
+ type HookAddress = Pick<HookEndEvent, 'hook' | 'file' | 'line' | 'offset' | 'arguments'>;
44
45
  type ReactJitterOptions = {
45
46
  enabled?: boolean;
46
47
  onHookChange?: (change: HookChange) => void;
@@ -51,6 +52,7 @@ type ReactJitterOptions = {
51
52
  }) => void;
52
53
  };
53
54
  type Scope = z.infer<typeof ScopeSchema>;
55
+ type Comparator = 'deepEqual' | 'circularDeepEqual';
54
56
 
55
57
  type HookCall = HookChange & HookEndEvent & {
56
58
  scope: Scope;
@@ -62,6 +64,7 @@ declare global {
62
64
  reactJitter?: {
63
65
  enabled?: boolean;
64
66
  onHookChange?: (change: HookCall) => void;
67
+ selectComparator?: (hookAddress: HookAddress) => Comparator;
65
68
  onRender?: (scope: Scope & {
66
69
  hookResults: Record<string, unknown>;
67
70
  renderCount: number;
@@ -447,7 +447,8 @@ function createCustomEqual(options) {
447
447
  }
448
448
 
449
449
  // src/utils/getChanges.ts
450
- function getChanges(prev, next) {
450
+ function getChanges(prev, next, comparator = "deepEqual") {
451
+ const equals = comparator === "circularDeepEqual" ? circularDeepEqual : deepEqual;
451
452
  const changedKeys = [];
452
453
  const unstableKeys = [];
453
454
  const isObject = (v) => v !== null && typeof v === "object";
@@ -466,7 +467,7 @@ function getChanges(prev, next) {
466
467
  }
467
468
  const max = Math.max(prev.length, next.length);
468
469
  for (let i = 0; i < max; i++) {
469
- const deepEqItem = deepEqual(prev[i], next[i]);
470
+ const deepEqItem = equals(prev[i], next[i]);
470
471
  const refDiffItem = isObject(prev[i]) && isObject(next[i]) && prev[i] !== next[i];
471
472
  if (!deepEqItem || refDiffItem) {
472
473
  const key = String(i);
@@ -479,7 +480,7 @@ function getChanges(prev, next) {
479
480
  } else if (isObject(prev) && isObject(next)) {
480
481
  const allKeys = /* @__PURE__ */ new Set([...Object.keys(prev), ...Object.keys(next)]);
481
482
  for (const key of allKeys) {
482
- const deepEqProp = deepEqual(prev[key], next[key]);
483
+ const deepEqProp = equals(prev[key], next[key]);
483
484
  const refDiffProp = isObject(prev[key]) && isObject(next[key]) && prev[key] !== next[key];
484
485
  if (!deepEqProp || refDiffProp) {
485
486
  changedKeys.push(key);
@@ -489,7 +490,7 @@ function getChanges(prev, next) {
489
490
  }
490
491
  }
491
492
  } else {
492
- const deepEqRoot = deepEqual(prev, next);
493
+ const deepEqRoot = equals(prev, next);
493
494
  const refDiffRoot = isObject(prev) && isObject(next) && prev !== next;
494
495
  const unstable = refDiffRoot && deepEqRoot;
495
496
  const changed = !deepEqRoot || refDiffRoot;
@@ -500,7 +501,7 @@ function getChanges(prev, next) {
500
501
  };
501
502
  }
502
503
  const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
503
- const unstableRoot = isPlainObject(prev) && isPlainObject(next) && prev !== next && deepEqual(prev, next);
504
+ const unstableRoot = isPlainObject(prev) && isPlainObject(next) && prev !== next && equals(prev, next);
504
505
  if (unstableRoot && changedKeys.length === 0) {
505
506
  changedKeys.push("");
506
507
  unstableKeys.push("");
@@ -512,6 +513,31 @@ function getChanges(prev, next) {
512
513
  };
513
514
  }
514
515
 
516
+ // src/utils/compareChanges.ts
517
+ function compareChanges(hookAddress, prev, current) {
518
+ var _a, _b, _c;
519
+ if (prev !== "undefined" && prev !== current) {
520
+ 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";
521
+ try {
522
+ return getChanges(prev, current, comparator);
523
+ } catch (error) {
524
+ const errorMessage = error instanceof Error ? error.message : String(error);
525
+ const isRecursionError = /(?:maximum call stack(?: size)? exceeded|too much recursion|stack overflow)/i.test(
526
+ errorMessage
527
+ );
528
+ if (isRecursionError && comparator !== "circularDeepEqual") {
529
+ throw new Error(
530
+ `Maximum call stack size exceeded. Please use the "circularDeepEqual" comparator with selectComparator option.
531
+ Hook address: ${JSON.stringify(hookAddress, null, 2)}.`,
532
+ { cause: error }
533
+ );
534
+ }
535
+ throw error;
536
+ }
537
+ }
538
+ return null;
539
+ }
540
+
515
541
  // src/index.ts
516
542
  var scopes = {};
517
543
  var hookStack = /* @__PURE__ */ new Map();
@@ -544,7 +570,13 @@ function useJitterScope(scope) {
544
570
  const hookId = `${scopeId}-${hookEndEvent.id}`;
545
571
  if (shouldReportChanges()) {
546
572
  const prevResult = currentScope.hookResults[hookId];
547
- const changes = compareChanges(prevResult, hookResult);
573
+ const hookAddress = {
574
+ hook: hookEndEvent.hook,
575
+ file: hookEndEvent.file,
576
+ line: hookEndEvent.line,
577
+ offset: hookEndEvent.offset
578
+ };
579
+ const changes = compareChanges(hookAddress, prevResult, hookResult);
548
580
  if (changes) {
549
581
  const hookCall = {
550
582
  hook: hookEndEvent.hook,
@@ -621,12 +653,6 @@ function getScopeCount(scope) {
621
653
  }
622
654
  return scopeCounter[scope.id]++;
623
655
  }
624
- function compareChanges(prev, current) {
625
- if (prev !== "undefined" && prev !== current) {
626
- return getChanges(prev, current);
627
- }
628
- return null;
629
- }
630
656
  // Annotate the CommonJS export names for ESM import in node:
631
657
  0 && (module.exports = {
632
658
  reactJitter,
@@ -412,7 +412,8 @@ function createCustomEqual(options) {
412
412
  }
413
413
 
414
414
  // src/utils/getChanges.ts
415
- function getChanges(prev, next) {
415
+ function getChanges(prev, next, comparator = "deepEqual") {
416
+ const equals = comparator === "circularDeepEqual" ? circularDeepEqual : deepEqual;
416
417
  const changedKeys = [];
417
418
  const unstableKeys = [];
418
419
  const isObject = (v) => v !== null && typeof v === "object";
@@ -431,7 +432,7 @@ function getChanges(prev, next) {
431
432
  }
432
433
  const max = Math.max(prev.length, next.length);
433
434
  for (let i = 0; i < max; i++) {
434
- const deepEqItem = deepEqual(prev[i], next[i]);
435
+ const deepEqItem = equals(prev[i], next[i]);
435
436
  const refDiffItem = isObject(prev[i]) && isObject(next[i]) && prev[i] !== next[i];
436
437
  if (!deepEqItem || refDiffItem) {
437
438
  const key = String(i);
@@ -444,7 +445,7 @@ function getChanges(prev, next) {
444
445
  } else if (isObject(prev) && isObject(next)) {
445
446
  const allKeys = /* @__PURE__ */ new Set([...Object.keys(prev), ...Object.keys(next)]);
446
447
  for (const key of allKeys) {
447
- const deepEqProp = deepEqual(prev[key], next[key]);
448
+ const deepEqProp = equals(prev[key], next[key]);
448
449
  const refDiffProp = isObject(prev[key]) && isObject(next[key]) && prev[key] !== next[key];
449
450
  if (!deepEqProp || refDiffProp) {
450
451
  changedKeys.push(key);
@@ -454,7 +455,7 @@ function getChanges(prev, next) {
454
455
  }
455
456
  }
456
457
  } else {
457
- const deepEqRoot = deepEqual(prev, next);
458
+ const deepEqRoot = equals(prev, next);
458
459
  const refDiffRoot = isObject(prev) && isObject(next) && prev !== next;
459
460
  const unstable = refDiffRoot && deepEqRoot;
460
461
  const changed = !deepEqRoot || refDiffRoot;
@@ -465,7 +466,7 @@ function getChanges(prev, next) {
465
466
  };
466
467
  }
467
468
  const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
468
- const unstableRoot = isPlainObject(prev) && isPlainObject(next) && prev !== next && deepEqual(prev, next);
469
+ const unstableRoot = isPlainObject(prev) && isPlainObject(next) && prev !== next && equals(prev, next);
469
470
  if (unstableRoot && changedKeys.length === 0) {
470
471
  changedKeys.push("");
471
472
  unstableKeys.push("");
@@ -477,6 +478,31 @@ function getChanges(prev, next) {
477
478
  };
478
479
  }
479
480
 
481
+ // src/utils/compareChanges.ts
482
+ function compareChanges(hookAddress, prev, current) {
483
+ var _a, _b, _c;
484
+ if (prev !== "undefined" && prev !== current) {
485
+ 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";
486
+ try {
487
+ return getChanges(prev, current, comparator);
488
+ } catch (error) {
489
+ const errorMessage = error instanceof Error ? error.message : String(error);
490
+ const isRecursionError = /(?:maximum call stack(?: size)? exceeded|too much recursion|stack overflow)/i.test(
491
+ errorMessage
492
+ );
493
+ if (isRecursionError && comparator !== "circularDeepEqual") {
494
+ throw new Error(
495
+ `Maximum call stack size exceeded. Please use the "circularDeepEqual" comparator with selectComparator option.
496
+ Hook address: ${JSON.stringify(hookAddress, null, 2)}.`,
497
+ { cause: error }
498
+ );
499
+ }
500
+ throw error;
501
+ }
502
+ }
503
+ return null;
504
+ }
505
+
480
506
  // src/index.ts
481
507
  var scopes = {};
482
508
  var hookStack = /* @__PURE__ */ new Map();
@@ -509,7 +535,13 @@ function useJitterScope(scope) {
509
535
  const hookId = `${scopeId}-${hookEndEvent.id}`;
510
536
  if (shouldReportChanges()) {
511
537
  const prevResult = currentScope.hookResults[hookId];
512
- const changes = compareChanges(prevResult, hookResult);
538
+ const hookAddress = {
539
+ hook: hookEndEvent.hook,
540
+ file: hookEndEvent.file,
541
+ line: hookEndEvent.line,
542
+ offset: hookEndEvent.offset
543
+ };
544
+ const changes = compareChanges(hookAddress, prevResult, hookResult);
513
545
  if (changes) {
514
546
  const hookCall = {
515
547
  hook: hookEndEvent.hook,
@@ -586,12 +618,6 @@ function getScopeCount(scope) {
586
618
  }
587
619
  return scopeCounter[scope.id]++;
588
620
  }
589
- function compareChanges(prev, current) {
590
- if (prev !== "undefined" && prev !== current) {
591
- return getChanges(prev, current);
592
- }
593
- return null;
594
- }
595
621
  export {
596
622
  reactJitter,
597
623
  useJitterScope