react-jitter 0.3.1 → 0.5.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`.
@@ -218,6 +256,33 @@ Here is an example of the `change` object when `includeArguments` is enabled:
218
256
 
219
257
  In this example, the `arguments` field shows that the `UserContext` was used, and the `changedKeys` field shows that the `user` property has changed.
220
258
 
259
+ ### Detecting Unstable Hooks in Unit Tests
260
+
261
+ React Jitter can also be a powerful tool for improving code quality within your unit tests.
262
+
263
+ You can leverage this to write tests that fail if a hook becomes unstable, catching performance regressions early in the a testing setup where you might initialize React Jitter in a global setup file, you can easily override the `onHookChange` handler on a per-test basis.
264
+
265
+ ```javascript
266
+ // Example of a Vitest/Jest test
267
+ it('should not have unstable hooks', () => {
268
+ const unstableChanges = [];
269
+ // Initialize React Jitter in a global setup file (e.g., setupTests.js)
270
+ // Then, override the onHookChange handler for specific tests.
271
+ window.reactJitter.onHookChange = (change) => {
272
+ // You can ignore mocked hooks or handle them specifically
273
+ if (change.unstable && !change.isMocked) {
274
+ unstableChanges.push(change);
275
+ }
276
+ };
277
+
278
+ render(<MyComponent />);
279
+
280
+ // Assert that no unstable values were detected during the render
281
+ expect(unstableChanges).toHaveLength(0);
282
+ });
283
+ ```
284
+
285
+ The `onHookChange` callback's `change` object includes an `isMocked` boolean property. This is automatically set to `true` if React Jitter detects that the hook has been mocked (e.g., using `jest.fn()` or `vi.fn()`). This allows you to reliably identify and assert against unstable values in your test environment.
221
286
 
222
287
  ## How It Works
223
288
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-jitter",
3
- "version": "0.3.1",
3
+ "version": "0.5.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
  ".": {
@@ -40,7 +40,9 @@ type HookEndEvent = {
40
40
  line: number;
41
41
  offset: number;
42
42
  arguments?: string[];
43
+ isMocked?: boolean;
43
44
  };
45
+ type HookAddress = Pick<HookEndEvent, 'hook' | 'file' | 'line' | 'offset' | 'arguments'>;
44
46
  type ReactJitterOptions = {
45
47
  enabled?: boolean;
46
48
  onHookChange?: (change: HookChange) => void;
@@ -51,6 +53,7 @@ type ReactJitterOptions = {
51
53
  }) => void;
52
54
  };
53
55
  type Scope = z.infer<typeof ScopeSchema>;
56
+ type Comparator = 'deepEqual' | 'circularDeepEqual';
54
57
 
55
58
  type HookCall = HookChange & HookEndEvent & {
56
59
  scope: Scope;
@@ -62,6 +65,7 @@ declare global {
62
65
  reactJitter?: {
63
66
  enabled?: boolean;
64
67
  onHookChange?: (change: HookCall) => void;
68
+ selectComparator?: (hookAddress: HookAddress) => Comparator;
65
69
  onRender?: (scope: Scope & {
66
70
  hookResults: Record<string, unknown>;
67
71
  renderCount: number;
@@ -79,6 +83,7 @@ declare function useJitterScope(scope: Scope): {
79
83
  s: (id: string) => void;
80
84
  e: (hookResult: unknown, hookEndEvent: HookEndEvent) => unknown;
81
85
  re: <T>(renderResult: T) => T;
86
+ m: (value: unknown) => boolean;
82
87
  };
83
88
  declare function reactJitter(options: ReactJitterOptions): void;
84
89
 
@@ -40,7 +40,9 @@ type HookEndEvent = {
40
40
  line: number;
41
41
  offset: number;
42
42
  arguments?: string[];
43
+ isMocked?: boolean;
43
44
  };
45
+ type HookAddress = Pick<HookEndEvent, 'hook' | 'file' | 'line' | 'offset' | 'arguments'>;
44
46
  type ReactJitterOptions = {
45
47
  enabled?: boolean;
46
48
  onHookChange?: (change: HookChange) => void;
@@ -51,6 +53,7 @@ type ReactJitterOptions = {
51
53
  }) => void;
52
54
  };
53
55
  type Scope = z.infer<typeof ScopeSchema>;
56
+ type Comparator = 'deepEqual' | 'circularDeepEqual';
54
57
 
55
58
  type HookCall = HookChange & HookEndEvent & {
56
59
  scope: Scope;
@@ -62,6 +65,7 @@ declare global {
62
65
  reactJitter?: {
63
66
  enabled?: boolean;
64
67
  onHookChange?: (change: HookCall) => void;
68
+ selectComparator?: (hookAddress: HookAddress) => Comparator;
65
69
  onRender?: (scope: Scope & {
66
70
  hookResults: Record<string, unknown>;
67
71
  renderCount: number;
@@ -79,6 +83,7 @@ declare function useJitterScope(scope: Scope): {
79
83
  s: (id: string) => void;
80
84
  e: (hookResult: unknown, hookEndEvent: HookEndEvent) => unknown;
81
85
  re: <T>(renderResult: T) => T;
86
+ m: (value: unknown) => boolean;
82
87
  };
83
88
  declare function reactJitter(options: ReactJitterOptions): void;
84
89
 
@@ -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,
@@ -560,6 +592,9 @@ function useJitterScope(scope) {
560
592
  if (hookEndEvent.arguments) {
561
593
  hookCall.arguments = hookEndEvent.arguments;
562
594
  }
595
+ if (hookEndEvent.isMocked) {
596
+ hookCall.isMocked = hookEndEvent.isMocked;
597
+ }
563
598
  scopes[scopeId].hookChanges.push(hookCall);
564
599
  callOnHookChange(hookCall);
565
600
  }
@@ -571,6 +606,12 @@ function useJitterScope(scope) {
571
606
  re: (renderResult) => {
572
607
  callOnRender(scopes[scopeId]);
573
608
  return renderResult;
609
+ },
610
+ m: (value) => {
611
+ if (typeof value !== "function") {
612
+ return false;
613
+ }
614
+ return "mockImplementation" in value || "mockReturnValue" in value;
574
615
  }
575
616
  };
576
617
  }
@@ -621,12 +662,6 @@ function getScopeCount(scope) {
621
662
  }
622
663
  return scopeCounter[scope.id]++;
623
664
  }
624
- function compareChanges(prev, current) {
625
- if (prev !== "undefined" && prev !== current) {
626
- return getChanges(prev, current);
627
- }
628
- return null;
629
- }
630
665
  // Annotate the CommonJS export names for ESM import in node:
631
666
  0 && (module.exports = {
632
667
  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,
@@ -525,6 +557,9 @@ function useJitterScope(scope) {
525
557
  if (hookEndEvent.arguments) {
526
558
  hookCall.arguments = hookEndEvent.arguments;
527
559
  }
560
+ if (hookEndEvent.isMocked) {
561
+ hookCall.isMocked = hookEndEvent.isMocked;
562
+ }
528
563
  scopes[scopeId].hookChanges.push(hookCall);
529
564
  callOnHookChange(hookCall);
530
565
  }
@@ -536,6 +571,12 @@ function useJitterScope(scope) {
536
571
  re: (renderResult) => {
537
572
  callOnRender(scopes[scopeId]);
538
573
  return renderResult;
574
+ },
575
+ m: (value) => {
576
+ if (typeof value !== "function") {
577
+ return false;
578
+ }
579
+ return "mockImplementation" in value || "mockReturnValue" in value;
539
580
  }
540
581
  };
541
582
  }
@@ -586,12 +627,6 @@ function getScopeCount(scope) {
586
627
  }
587
628
  return scopeCounter[scope.id]++;
588
629
  }
589
- function compareChanges(prev, current) {
590
- if (prev !== "undefined" && prev !== current) {
591
- return getChanges(prev, current);
592
- }
593
- return null;
594
- }
595
630
  export {
596
631
  reactJitter,
597
632
  useJitterScope