@assistant-ui/tap 0.4.6 → 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.
Files changed (122) hide show
  1. package/README.md +20 -17
  2. package/dist/core/ResourceFiber.d.ts +2 -2
  3. package/dist/core/ResourceFiber.d.ts.map +1 -1
  4. package/dist/core/ResourceFiber.js +11 -9
  5. package/dist/core/ResourceFiber.js.map +1 -1
  6. package/dist/core/createResourceRoot.d.ts +6 -0
  7. package/dist/core/createResourceRoot.d.ts.map +1 -0
  8. package/dist/core/createResourceRoot.js +32 -0
  9. package/dist/core/createResourceRoot.js.map +1 -0
  10. package/dist/core/helpers/callResourceFn.d.ts.map +1 -0
  11. package/dist/core/helpers/callResourceFn.js.map +1 -0
  12. package/dist/core/helpers/commit.d.ts +4 -0
  13. package/dist/core/helpers/commit.d.ts.map +1 -0
  14. package/dist/core/{commit.js → helpers/commit.js} +2 -2
  15. package/dist/core/helpers/commit.js.map +1 -0
  16. package/dist/core/helpers/env.d.ts.map +1 -0
  17. package/dist/core/helpers/env.js.map +1 -0
  18. package/dist/core/{execution-context.d.ts → helpers/execution-context.d.ts} +1 -1
  19. package/dist/core/helpers/execution-context.d.ts.map +1 -0
  20. package/dist/core/helpers/execution-context.js.map +1 -0
  21. package/dist/core/helpers/root.d.ts +8 -0
  22. package/dist/core/helpers/root.d.ts.map +1 -0
  23. package/dist/core/helpers/root.js +52 -0
  24. package/dist/core/helpers/root.js.map +1 -0
  25. package/dist/core/resource.js +1 -1
  26. package/dist/core/resource.js.map +1 -1
  27. package/dist/core/scheduler.d.ts.map +1 -1
  28. package/dist/core/scheduler.js +12 -1
  29. package/dist/core/scheduler.js.map +1 -1
  30. package/dist/core/types.d.ts +25 -7
  31. package/dist/core/types.d.ts.map +1 -1
  32. package/dist/hooks/tap-effect-event.d.ts.map +1 -1
  33. package/dist/hooks/tap-effect-event.js +3 -2
  34. package/dist/hooks/tap-effect-event.js.map +1 -1
  35. package/dist/hooks/tap-memo.d.ts.map +1 -1
  36. package/dist/hooks/tap-memo.js +16 -17
  37. package/dist/hooks/tap-memo.js.map +1 -1
  38. package/dist/hooks/tap-reducer.d.ts +7 -0
  39. package/dist/hooks/tap-reducer.d.ts.map +1 -0
  40. package/dist/hooks/tap-reducer.js +87 -0
  41. package/dist/hooks/tap-reducer.js.map +1 -0
  42. package/dist/hooks/tap-resource.js +9 -9
  43. package/dist/hooks/tap-resource.js.map +1 -1
  44. package/dist/hooks/tap-resources.d.ts.map +1 -1
  45. package/dist/hooks/tap-resources.js +11 -11
  46. package/dist/hooks/tap-resources.js.map +1 -1
  47. package/dist/hooks/tap-state.d.ts.map +1 -1
  48. package/dist/hooks/tap-state.js +6 -63
  49. package/dist/hooks/tap-state.js.map +1 -1
  50. package/dist/hooks/utils/tapHook.d.ts +1 -1
  51. package/dist/hooks/utils/tapHook.d.ts.map +1 -1
  52. package/dist/hooks/utils/tapHook.js +2 -2
  53. package/dist/hooks/utils/tapHook.js.map +1 -1
  54. package/dist/index.d.ts +3 -3
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +3 -3
  57. package/dist/index.js.map +1 -1
  58. package/dist/react/use-resource.d.ts +1 -1
  59. package/dist/react/use-resource.d.ts.map +1 -1
  60. package/dist/react/use-resource.js +14 -8
  61. package/dist/react/use-resource.js.map +1 -1
  62. package/dist/{tapSubscribableResource.d.ts → tapResourceRoot.d.ts} +3 -3
  63. package/dist/tapResourceRoot.d.ts.map +1 -0
  64. package/dist/tapResourceRoot.js +80 -0
  65. package/dist/tapResourceRoot.js.map +1 -0
  66. package/package.json +1 -1
  67. package/src/__tests__/basic/resourceHandle.test.ts +17 -14
  68. package/src/__tests__/basic/tapReducer.basic.test.ts +200 -0
  69. package/src/__tests__/react/concurrent-mode.test.tsx +1 -4
  70. package/src/__tests__/strictmode/react-strictmode-behavior.test.tsx +215 -2
  71. package/src/__tests__/strictmode/react-strictmode-rerender-sources.test.tsx +77 -0
  72. package/src/__tests__/strictmode/strictmode.test.ts +82 -21
  73. package/src/__tests__/strictmode/tap-strictmode-rerender-sources.test.ts +67 -110
  74. package/src/__tests__/test-utils.ts +5 -1
  75. package/src/core/ResourceFiber.ts +22 -10
  76. package/src/core/createResourceRoot.ts +53 -0
  77. package/src/core/{callResourceFn.ts → helpers/callResourceFn.ts} +1 -1
  78. package/src/core/{commit.ts → helpers/commit.ts} +3 -3
  79. package/src/core/{execution-context.ts → helpers/execution-context.ts} +1 -1
  80. package/src/core/helpers/root.ts +67 -0
  81. package/src/core/resource.ts +1 -1
  82. package/src/core/scheduler.ts +13 -1
  83. package/src/core/types.ts +27 -7
  84. package/src/hooks/tap-effect-event.ts +3 -2
  85. package/src/hooks/tap-memo.ts +24 -19
  86. package/src/hooks/tap-reducer.ts +148 -0
  87. package/src/hooks/tap-resource.ts +9 -9
  88. package/src/hooks/tap-resources.ts +23 -10
  89. package/src/hooks/tap-state.ts +11 -88
  90. package/src/hooks/utils/tapHook.ts +3 -3
  91. package/src/index.ts +3 -3
  92. package/src/react/use-resource.ts +24 -11
  93. package/src/tapResourceRoot.ts +131 -0
  94. package/dist/core/callResourceFn.d.ts.map +0 -1
  95. package/dist/core/callResourceFn.js.map +0 -1
  96. package/dist/core/commit.d.ts +0 -4
  97. package/dist/core/commit.d.ts.map +0 -1
  98. package/dist/core/commit.js.map +0 -1
  99. package/dist/core/createResource.d.ts +0 -15
  100. package/dist/core/createResource.d.ts.map +0 -1
  101. package/dist/core/createResource.js +0 -101
  102. package/dist/core/createResource.js.map +0 -1
  103. package/dist/core/env.d.ts.map +0 -1
  104. package/dist/core/env.js.map +0 -1
  105. package/dist/core/execution-context.d.ts.map +0 -1
  106. package/dist/core/execution-context.js.map +0 -1
  107. package/dist/hooks/tap-inline-resource.d.ts +0 -3
  108. package/dist/hooks/tap-inline-resource.d.ts.map +0 -1
  109. package/dist/hooks/tap-inline-resource.js +0 -5
  110. package/dist/hooks/tap-inline-resource.js.map +0 -1
  111. package/dist/tapSubscribableResource.d.ts.map +0 -1
  112. package/dist/tapSubscribableResource.js +0 -60
  113. package/dist/tapSubscribableResource.js.map +0 -1
  114. package/src/core/createResource.ts +0 -155
  115. package/src/hooks/tap-inline-resource.ts +0 -8
  116. package/src/tapSubscribableResource.ts +0 -101
  117. /package/dist/core/{callResourceFn.d.ts → helpers/callResourceFn.d.ts} +0 -0
  118. /package/dist/core/{callResourceFn.js → helpers/callResourceFn.js} +0 -0
  119. /package/dist/core/{env.d.ts → helpers/env.d.ts} +0 -0
  120. /package/dist/core/{env.js → helpers/env.js} +0 -0
  121. /package/dist/core/{execution-context.js → helpers/execution-context.js} +0 -0
  122. /package/src/core/{env.ts → helpers/env.ts} +0 -0
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi } from "vitest";
2
- import { createResource } from "../../core/createResource";
2
+ import { createResourceRoot } from "../../core/createResourceRoot";
3
3
  import { resource } from "../../core/resource";
4
4
 
5
5
  describe("ResourceHandle - Basic Usage", () => {
@@ -10,41 +10,44 @@ describe("ResourceHandle - Basic Usage", () => {
10
10
  propsUsed: props,
11
11
  };
12
12
  });
13
- const handle = createResource(TestResource(5));
13
+ const root = createResourceRoot();
14
+ const sub = root.render(TestResource(5));
14
15
 
15
- // The handle provides a const API
16
- expect(typeof handle.getValue).toBe("function");
17
- expect(typeof handle.subscribe).toBe("function");
18
- expect(typeof handle.render).toBe("function");
16
+ // The subscribable provides getValue and subscribe
17
+ expect(typeof sub.getValue).toBe("function");
18
+ expect(typeof sub.subscribe).toBe("function");
19
+ expect(typeof root.render).toBe("function");
19
20
 
20
21
  // Initial state
21
- expect(handle.getValue().value).toBe(10);
22
- expect(handle.getValue().propsUsed).toBe(5);
22
+ expect(sub.getValue().value).toBe(10);
23
+ expect(sub.getValue().propsUsed).toBe(5);
23
24
  });
24
25
 
25
26
  it("should allow updating props", () => {
26
27
  const TestResource = resource((props: { multiplier: number }) => {
27
28
  return { result: 10 * props.multiplier };
28
29
  });
29
- const handle = createResource(TestResource({ multiplier: 2 }));
30
+ const root = createResourceRoot();
31
+ const sub = root.render(TestResource({ multiplier: 2 }));
30
32
 
31
33
  // Initial state
32
- expect(handle.getValue().result).toBe(20);
34
+ expect(sub.getValue().result).toBe(20);
33
35
 
34
36
  // Can call render to update props
35
- expect(() => handle.render(TestResource({ multiplier: 3 }))).not.toThrow();
37
+ expect(() => root.render(TestResource({ multiplier: 3 }))).not.toThrow();
36
38
  });
37
39
 
38
40
  it("should support subscribing and unsubscribing", () => {
39
41
  const TestResource = resource(() => ({ timestamp: Date.now() }));
40
- const handle = createResource(TestResource());
42
+ const root = createResourceRoot();
43
+ const sub = root.render(TestResource());
41
44
 
42
45
  const subscriber1 = vi.fn();
43
46
  const subscriber2 = vi.fn();
44
47
 
45
48
  // Can subscribe multiple callbacks
46
- const unsub1 = handle.subscribe(subscriber1);
47
- const unsub2 = handle.subscribe(subscriber2);
49
+ const unsub1 = sub.subscribe(subscriber1);
50
+ const unsub2 = sub.subscribe(subscriber2);
48
51
 
49
52
  // Can unsubscribe individually
50
53
  expect(typeof unsub1).toBe("function");
@@ -0,0 +1,200 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { tapReducer } from "../../hooks/tap-reducer";
3
+ import { tapEffect } from "../../hooks/tap-effect";
4
+ import {
5
+ createTestResource,
6
+ renderTest,
7
+ cleanupAllResources,
8
+ waitForNextTick,
9
+ getCommittedOutput,
10
+ } from "../test-utils";
11
+
12
+ describe("tapReducer - Basic Functionality", () => {
13
+ afterEach(() => {
14
+ cleanupAllResources();
15
+ });
16
+
17
+ describe("Initialization", () => {
18
+ it("should initialize with direct value", () => {
19
+ const reducer = (state: number, action: number) => state + action;
20
+
21
+ const testFiber = createTestResource(() => {
22
+ const [count] = tapReducer(reducer, 0);
23
+ return count;
24
+ });
25
+
26
+ const result = renderTest(testFiber, undefined);
27
+ expect(result).toBe(0);
28
+ });
29
+
30
+ it("should initialize with init function", () => {
31
+ let initCalled = 0;
32
+ const reducer = (state: number, action: number) => state + action;
33
+
34
+ const testFiber = createTestResource(() => {
35
+ const [count] = tapReducer(reducer, 10, (arg) => {
36
+ initCalled++;
37
+ return arg * 2;
38
+ });
39
+ return count;
40
+ });
41
+
42
+ const result = renderTest(testFiber, undefined);
43
+ expect(result).toBe(20);
44
+ expect(initCalled).toBe(1);
45
+
46
+ // Re-render should not call init again
47
+ renderTest(testFiber, undefined);
48
+ expect(initCalled).toBe(1);
49
+ });
50
+ });
51
+
52
+ describe("Dispatch and re-render", () => {
53
+ it("should dispatch actions and trigger re-render", async () => {
54
+ type Action = { type: "increment" } | { type: "decrement" };
55
+ const reducer = (state: number, action: Action) => {
56
+ switch (action.type) {
57
+ case "increment":
58
+ return state + 1;
59
+ case "decrement":
60
+ return state - 1;
61
+ }
62
+ };
63
+
64
+ let dispatchFn: ((action: Action) => void) | null = null;
65
+
66
+ const testFiber = createTestResource(() => {
67
+ const [count, dispatch] = tapReducer(reducer, 0);
68
+
69
+ tapEffect(() => {
70
+ dispatchFn = dispatch;
71
+ });
72
+
73
+ return count;
74
+ });
75
+
76
+ renderTest(testFiber, undefined);
77
+ expect(getCommittedOutput(testFiber)).toBe(0);
78
+
79
+ dispatchFn!({ type: "increment" });
80
+ await waitForNextTick();
81
+ expect(getCommittedOutput(testFiber)).toBe(1);
82
+
83
+ dispatchFn!({ type: "increment" });
84
+ await waitForNextTick();
85
+ expect(getCommittedOutput(testFiber)).toBe(2);
86
+
87
+ dispatchFn!({ type: "decrement" });
88
+ await waitForNextTick();
89
+ expect(getCommittedOutput(testFiber)).toBe(1);
90
+ });
91
+ });
92
+
93
+ describe("Same-state bailout", () => {
94
+ it("should not re-render when reducer returns same state (Object.is)", async () => {
95
+ let renderCount = 0;
96
+ const reducer = (state: number, action: number) =>
97
+ action === 0 ? state : state + action;
98
+
99
+ let dispatchFn: ((action: number) => void) | null = null;
100
+
101
+ const testFiber = createTestResource(() => {
102
+ renderCount++;
103
+ const [count, dispatch] = tapReducer(reducer, 42);
104
+
105
+ tapEffect(() => {
106
+ dispatchFn = dispatch;
107
+ });
108
+
109
+ return count;
110
+ });
111
+
112
+ renderTest(testFiber, undefined);
113
+ expect(renderCount).toBe(1);
114
+
115
+ // Dispatch action that returns same state
116
+ dispatchFn!(0);
117
+ await waitForNextTick();
118
+ expect(renderCount).toBe(1);
119
+ });
120
+ });
121
+
122
+ describe("Reducer function updates", () => {
123
+ it("should use latest reducer reference", async () => {
124
+ let multiplier = 1;
125
+ let dispatchFn: ((action: number) => void) | null = null;
126
+
127
+ const testFiber = createTestResource(() => {
128
+ const reducer = (state: number, action: number) =>
129
+ state + action * multiplier;
130
+ const [count, dispatch] = tapReducer(reducer, 0);
131
+
132
+ tapEffect(() => {
133
+ dispatchFn = dispatch;
134
+ });
135
+
136
+ return count;
137
+ });
138
+
139
+ renderTest(testFiber, undefined);
140
+ expect(getCommittedOutput(testFiber)).toBe(0);
141
+
142
+ // Dispatch with multiplier=1
143
+ dispatchFn!(5);
144
+ await waitForNextTick();
145
+ expect(getCommittedOutput(testFiber)).toBe(5);
146
+
147
+ // Change multiplier and dispatch
148
+ multiplier = 10;
149
+ renderTest(testFiber, undefined); // re-render to update reducer
150
+ dispatchFn!(5);
151
+ await waitForNextTick();
152
+ expect(getCommittedOutput(testFiber)).toBe(55); // 5 + 5*10
153
+ });
154
+ });
155
+
156
+ describe("Multiple dispatches", () => {
157
+ it("should handle multiple dispatches correctly", async () => {
158
+ const reducer = (state: number, action: number) => state + action;
159
+ let dispatchFn: ((action: number) => void) | null = null;
160
+
161
+ const testFiber = createTestResource(() => {
162
+ const [count, dispatch] = tapReducer(reducer, 0);
163
+
164
+ tapEffect(() => {
165
+ dispatchFn = dispatch;
166
+ });
167
+
168
+ return count;
169
+ });
170
+
171
+ renderTest(testFiber, undefined);
172
+ expect(getCommittedOutput(testFiber)).toBe(0);
173
+
174
+ // Multiple dispatches
175
+ dispatchFn!(1);
176
+ dispatchFn!(2);
177
+ dispatchFn!(3);
178
+ await waitForNextTick();
179
+ expect(getCommittedOutput(testFiber)).toBe(6);
180
+ });
181
+ });
182
+
183
+ describe("Dispatch identity stability", () => {
184
+ it("should return same dispatch reference across renders", () => {
185
+ const reducer = (state: number, action: number) => state + action;
186
+ const dispatches: ((action: number) => void)[] = [];
187
+
188
+ const testFiber = createTestResource(() => {
189
+ const [count, dispatch] = tapReducer(reducer, 0);
190
+ dispatches.push(dispatch);
191
+ return count;
192
+ });
193
+
194
+ renderTest(testFiber, undefined);
195
+ renderTest(testFiber, undefined);
196
+
197
+ expect(dispatches[0]).toBe(dispatches[1]);
198
+ });
199
+ });
200
+ });
@@ -10,10 +10,7 @@ const ShouldNeverFallback = () => {
10
10
  };
11
11
 
12
12
  describe("Concurrent Mode with useResource", () => {
13
- // TODO: tapState updates are not rolled back when React discards a concurrent render
14
- // This requires architectural changes to make tapState updates "tentative" until React commits
15
- // For now, tapState behaves like external state (Zustand, Jotai) which has the same limitation
16
- it.skip("should not commit tapState updates when render is discarded", async () => {
13
+ it("should not commit tapState updates when render is discarded", async () => {
17
14
  const TestResource = resource(() => {
18
15
  return tapState(false);
19
16
  });
@@ -5,7 +5,15 @@
5
5
 
6
6
  import { describe, it, expect } from "vitest";
7
7
  import { render } from "@testing-library/react";
8
- import { StrictMode, useState, useEffect, useMemo, useRef } from "react";
8
+ import { act } from "react";
9
+ import {
10
+ StrictMode,
11
+ useState,
12
+ useEffect,
13
+ useMemo,
14
+ useReducer,
15
+ useRef,
16
+ } from "react";
9
17
 
10
18
  describe("React Strict Mode Behavior Verification", () => {
11
19
  describe("Test 1: Effect + setState behavior in strict mode", () => {
@@ -489,7 +497,212 @@ describe("React Strict Mode Behavior Verification", () => {
489
497
  });
490
498
  });
491
499
 
492
- describe("Test 5: setState in effect - strict mode edge cases", () => {
500
+ describe("Test 5: useMemo strict mode behavior", () => {
501
+ it("should double-invoke useMemo factory and use the first result", () => {
502
+ const events: string[] = [];
503
+ let memoCallCount = 0;
504
+
505
+ function TestComponent() {
506
+ const memoValue = useMemo(() => {
507
+ memoCallCount++;
508
+ events.push(`memo-${memoCallCount}`);
509
+ return memoCallCount;
510
+ }, []);
511
+
512
+ events.push(`render memoValue=${memoValue}`);
513
+
514
+ return <div>{memoValue}</div>;
515
+ }
516
+
517
+ render(
518
+ <StrictMode>
519
+ <TestComponent />
520
+ </StrictMode>,
521
+ );
522
+
523
+ expect(events).toEqual([
524
+ "memo-1",
525
+ "memo-2",
526
+ "render memoValue=1",
527
+ "render memoValue=1",
528
+ ]);
529
+ });
530
+ });
531
+
532
+ describe("Test 6: useReducer strict mode behavior", () => {
533
+ it("should double-invoke useReducer initializer and use the first result", () => {
534
+ const events: string[] = [];
535
+ let initCallCount = 0;
536
+
537
+ function TestComponent() {
538
+ const [state] = useReducer(
539
+ (s: number, a: number) => s + a,
540
+ 0,
541
+ (arg) => {
542
+ initCallCount++;
543
+ events.push(`init-${initCallCount}`);
544
+ return arg + initCallCount * 10;
545
+ },
546
+ );
547
+
548
+ events.push(`render state=${state}`);
549
+
550
+ return <div>{state}</div>;
551
+ }
552
+
553
+ render(
554
+ <StrictMode>
555
+ <TestComponent />
556
+ </StrictMode>,
557
+ );
558
+
559
+ expect(events).toEqual([
560
+ "init-1",
561
+ "init-2",
562
+ "render state=10",
563
+ "render state=10",
564
+ ]);
565
+ });
566
+
567
+ it("should double-invoke useReducer reducer on dispatch and use the first result", () => {
568
+ const events: string[] = [];
569
+ let reducerCallCount = 0;
570
+
571
+ function TestComponent() {
572
+ const [state, dispatch] = useReducer((s: number, _a: number) => {
573
+ reducerCallCount++;
574
+ const result = reducerCallCount * 100;
575
+ events.push(`reducer-${reducerCallCount} state=${s} -> ${result}`);
576
+ return result;
577
+ }, 0);
578
+
579
+ events.push(`render state=${state}`);
580
+
581
+ useEffect(() => {
582
+ if (state === 0) {
583
+ events.push("dispatch");
584
+ dispatch(1);
585
+ }
586
+ }, [state]);
587
+
588
+ return <div>{state}</div>;
589
+ }
590
+
591
+ render(
592
+ <StrictMode>
593
+ <TestComponent />
594
+ </StrictMode>,
595
+ );
596
+
597
+ // React behavior: reducer is called 4 times (2 dispatches × 2 strict mode double-calls)
598
+ // Dispatch #1 (effect mount): reducer called twice, SECOND result (200) kept
599
+ // Dispatch #2 (effect remount): reducer called twice, SECOND result (400) kept
600
+ // Note: this is opposite to useMemo/useState which keep the FIRST result!
601
+ expect(reducerCallCount).toBe(4);
602
+ expect(events).toEqual([
603
+ "render state=0",
604
+ "render state=0",
605
+ "dispatch",
606
+ "dispatch",
607
+ "reducer-1 state=0 -> 100",
608
+ "reducer-2 state=0 -> 200",
609
+ "reducer-3 state=200 -> 300",
610
+ "reducer-4 state=200 -> 400",
611
+ "render state=400",
612
+ "render state=400",
613
+ ]);
614
+ });
615
+ });
616
+
617
+ describe("Test 7: useState/useReducer dispatch double-invoke (isolated from effects)", () => {
618
+ it("should double-invoke useState updater and use the first result", () => {
619
+ const events: string[] = [];
620
+ let updaterCallCount = 0;
621
+ let setCountRef: ((fn: (prev: number) => number) => void) | null = null;
622
+
623
+ function TestComponent() {
624
+ const [count, setCount] = useState(0);
625
+ setCountRef = setCount;
626
+
627
+ events.push(`render count=${count}`);
628
+
629
+ return <div>{count}</div>;
630
+ }
631
+
632
+ render(
633
+ <StrictMode>
634
+ <TestComponent />
635
+ </StrictMode>,
636
+ );
637
+
638
+ events.length = 0;
639
+ updaterCallCount = 0;
640
+
641
+ act(() => {
642
+ setCountRef!((prev) => {
643
+ updaterCallCount++;
644
+ const result = updaterCallCount * 100;
645
+ events.push(`updater-${updaterCallCount} prev=${prev} -> ${result}`);
646
+ return result;
647
+ });
648
+ });
649
+
650
+ // useState updater is double-invoked, FIRST result kept
651
+ // (same as useMemo/useState init — NOT like useReducer dispatch!)
652
+ expect(updaterCallCount).toBe(2);
653
+ expect(events).toEqual([
654
+ "updater-1 prev=0 -> 100",
655
+ "updater-2 prev=0 -> 200",
656
+ "render count=100",
657
+ "render count=100",
658
+ ]);
659
+ });
660
+
661
+ it("should double-invoke useReducer reducer and use the first result", () => {
662
+ const events: string[] = [];
663
+ let reducerCallCount = 0;
664
+ let dispatchRef: ((a: number) => void) | null = null;
665
+
666
+ function TestComponent() {
667
+ const [state, dispatch] = useReducer((s: number, _a: number) => {
668
+ reducerCallCount++;
669
+ const result = reducerCallCount * 100;
670
+ events.push(`reducer-${reducerCallCount} state=${s} -> ${result}`);
671
+ return result;
672
+ }, 0);
673
+ dispatchRef = dispatch;
674
+
675
+ events.push(`render state=${state}`);
676
+
677
+ return <div>{state}</div>;
678
+ }
679
+
680
+ render(
681
+ <StrictMode>
682
+ <TestComponent />
683
+ </StrictMode>,
684
+ );
685
+
686
+ events.length = 0;
687
+ reducerCallCount = 0;
688
+
689
+ act(() => {
690
+ dispatchRef!(1);
691
+ });
692
+
693
+ // useReducer reducer is double-invoked, SECOND result kept!
694
+ // This differs from useState updater which keeps the FIRST result.
695
+ expect(reducerCallCount).toBe(2);
696
+ expect(events).toEqual([
697
+ "reducer-1 state=0 -> 100",
698
+ "reducer-2 state=0 -> 200",
699
+ "render state=200",
700
+ "render state=200",
701
+ ]);
702
+ });
703
+ });
704
+
705
+ describe("Test 8: setState in effect - strict mode edge cases", () => {
493
706
  it("should verify which setState is applied when effect calls setState only on first mount", () => {
494
707
  const events: string[] = [];
495
708
  let effectRunCount = 0;
@@ -389,4 +389,81 @@ describe("React Strict Mode - Rerender Sources", () => {
389
389
  ]);
390
390
  });
391
391
  });
392
+
393
+ describe("Source 9: Effect with dependencies calling setState (derived state)", () => {
394
+ it("should handle effect with dependencies and setState", () => {
395
+ const events: string[] = [];
396
+
397
+ function TestComponent() {
398
+ const [count] = useState(0);
399
+ const [doubled, setDoubled] = useState(0);
400
+ events.push(`render count=${count} doubled=${doubled}`);
401
+
402
+ useEffect(() => {
403
+ events.push(`effect count=${count}`);
404
+ setDoubled(count * 2);
405
+ return () => {
406
+ events.push(`cleanup count=${count}`);
407
+ };
408
+ }, [count]);
409
+
410
+ return <div>{doubled}</div>;
411
+ }
412
+
413
+ render(
414
+ <StrictMode>
415
+ <TestComponent />
416
+ </StrictMode>,
417
+ );
418
+
419
+ // setDoubled(0*2) = setDoubled(0) is a no-op, so no extra render
420
+ expect(events).toEqual([
421
+ "render count=0 doubled=0",
422
+ "render count=0 doubled=0",
423
+ "effect count=0",
424
+ "cleanup count=0",
425
+ "effect count=0",
426
+ ]);
427
+ });
428
+
429
+ it("should handle effect with dependencies and setState after state change", () => {
430
+ const events: string[] = [];
431
+
432
+ function TestComponent() {
433
+ const [count, setCount] = useState(0);
434
+ const [doubled, setDoubled] = useState(0);
435
+ events.push(`render count=${count} doubled=${doubled}`);
436
+
437
+ useEffect(() => {
438
+ events.push(`effect count=${count}`);
439
+ setDoubled(count * 2);
440
+ return () => {
441
+ events.push(`cleanup count=${count}`);
442
+ };
443
+ }, [count]);
444
+
445
+ return <button onClick={() => setCount((c) => c + 1)}>Click</button>;
446
+ }
447
+
448
+ const { getByRole } = render(
449
+ <StrictMode>
450
+ <TestComponent />
451
+ </StrictMode>,
452
+ );
453
+
454
+ events.length = 0;
455
+
456
+ fireEvent.click(getByRole("button"));
457
+
458
+ // Double-render with new count, effect sets doubled=2, triggers another double-render
459
+ expect(events).toEqual([
460
+ "render count=1 doubled=0",
461
+ "render count=1 doubled=0",
462
+ "cleanup count=0",
463
+ "effect count=1",
464
+ "render count=1 doubled=2",
465
+ "render count=1 doubled=2",
466
+ ]);
467
+ });
468
+ });
392
469
  });