@assistant-ui/tap 0.6.1 → 0.7.1

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 (213) hide show
  1. package/README.md +9 -6
  2. package/dist/core/ResourceFiber.d.ts +5 -5
  3. package/dist/core/ResourceFiber.d.ts.map +1 -1
  4. package/dist/core/ResourceFiber.js +26 -18
  5. package/dist/core/ResourceFiber.js.map +1 -1
  6. package/dist/core/createTapRoot.d.ts +9 -0
  7. package/dist/core/createTapRoot.d.ts.map +1 -0
  8. package/dist/core/createTapRoot.js +27 -0
  9. package/dist/core/createTapRoot.js.map +1 -0
  10. package/dist/core/helpers/commit.d.ts +1 -1
  11. package/dist/core/helpers/commit.d.ts.map +1 -1
  12. package/dist/core/helpers/commit.js +6 -1
  13. package/dist/core/helpers/commit.js.map +1 -1
  14. package/dist/core/helpers/execution-context.d.ts +4 -5
  15. package/dist/core/helpers/execution-context.d.ts.map +1 -1
  16. package/dist/core/helpers/execution-context.js +1 -7
  17. package/dist/core/helpers/execution-context.js.map +1 -1
  18. package/dist/core/helpers/root.d.ts +3 -2
  19. package/dist/core/helpers/root.d.ts.map +1 -1
  20. package/dist/core/helpers/root.js +19 -15
  21. package/dist/core/helpers/root.js.map +1 -1
  22. package/dist/core/react-dispatcher.d.ts.map +1 -1
  23. package/dist/core/react-dispatcher.js +14 -14
  24. package/dist/core/react-dispatcher.js.map +1 -1
  25. package/dist/core/resource.d.ts +2 -4
  26. package/dist/core/resource.d.ts.map +1 -1
  27. package/dist/core/resource.js +5 -10
  28. package/dist/core/resource.js.map +1 -1
  29. package/dist/core/scheduler.d.ts +2 -2
  30. package/dist/core/scheduler.d.ts.map +1 -1
  31. package/dist/core/scheduler.js +2 -2
  32. package/dist/core/scheduler.js.map +1 -1
  33. package/dist/core/types.d.ts +27 -25
  34. package/dist/core/types.d.ts.map +1 -1
  35. package/dist/hooks/useResource.d.ts +2 -2
  36. package/dist/hooks/useResource.d.ts.map +1 -1
  37. package/dist/hooks/useResource.js +14 -20
  38. package/dist/hooks/useResource.js.map +1 -1
  39. package/dist/hooks/useResources.d.ts +1 -1
  40. package/dist/hooks/useResources.d.ts.map +1 -1
  41. package/dist/hooks/useResources.js +18 -27
  42. package/dist/hooks/useResources.js.map +1 -1
  43. package/dist/hooks/useTapHost.d.ts +21 -0
  44. package/dist/hooks/useTapHost.d.ts.map +1 -0
  45. package/dist/hooks/useTapHost.js +30 -0
  46. package/dist/hooks/useTapHost.js.map +1 -0
  47. package/dist/hooks/useTapRoot.d.ts +18 -0
  48. package/dist/hooks/useTapRoot.d.ts.map +1 -0
  49. package/dist/hooks/useTapRoot.js +77 -0
  50. package/dist/hooks/useTapRoot.js.map +1 -0
  51. package/dist/hooks/utils/depsShallowEqual.d.ts.map +1 -1
  52. package/dist/hooks/utils/depsShallowEqual.js +5 -2
  53. package/dist/hooks/utils/depsShallowEqual.js.map +1 -1
  54. package/dist/hooks/utils/useCell.d.ts +2 -2
  55. package/dist/hooks/utils/useCell.d.ts.map +1 -1
  56. package/dist/hooks/utils/useCell.js.map +1 -1
  57. package/dist/hooks/utils/useDevStrictMode.d.ts +5 -0
  58. package/dist/hooks/utils/useDevStrictMode.d.ts.map +1 -0
  59. package/dist/hooks/utils/useDevStrictMode.js +25 -0
  60. package/dist/hooks/utils/useDevStrictMode.js.map +1 -0
  61. package/dist/hooks/utils/useRenderMemo.d.ts +5 -0
  62. package/dist/hooks/utils/useRenderMemo.d.ts.map +1 -0
  63. package/dist/hooks/utils/useRenderMemo.js +25 -0
  64. package/dist/hooks/utils/useRenderMemo.js.map +1 -0
  65. package/dist/hooks/utils/useResourceFiberHostUtils.d.ts +10 -0
  66. package/dist/hooks/utils/useResourceFiberHostUtils.d.ts.map +1 -0
  67. package/dist/hooks/utils/useResourceFiberHostUtils.js +46 -0
  68. package/dist/hooks/utils/useResourceFiberHostUtils.js.map +1 -0
  69. package/dist/index.d.ts +7 -4
  70. package/dist/index.js +7 -4
  71. package/dist/{hooks → react-hooks}/index.d.ts +6 -6
  72. package/dist/{hooks → react-hooks}/index.js +5 -5
  73. package/dist/{hooks → react-hooks}/use.d.ts +1 -1
  74. package/dist/{hooks → react-hooks}/use.d.ts.map +1 -1
  75. package/dist/{hooks → react-hooks}/use.js +1 -1
  76. package/dist/react-hooks/use.js.map +1 -0
  77. package/dist/{hooks → react-hooks}/useCallback.d.ts +1 -1
  78. package/dist/react-hooks/useCallback.d.ts.map +1 -0
  79. package/dist/{hooks → react-hooks}/useCallback.js +1 -1
  80. package/dist/react-hooks/useCallback.js.map +1 -0
  81. package/dist/{hooks → react-hooks}/useEffect.d.ts +1 -1
  82. package/dist/react-hooks/useEffect.d.ts.map +1 -0
  83. package/dist/react-hooks/useEffect.js +35 -0
  84. package/dist/react-hooks/useEffect.js.map +1 -0
  85. package/dist/{hooks → react-hooks}/useEffectEvent.d.ts +1 -1
  86. package/dist/react-hooks/useEffectEvent.d.ts.map +1 -0
  87. package/dist/{hooks → react-hooks}/useEffectEvent.js +2 -2
  88. package/dist/react-hooks/useEffectEvent.js.map +1 -0
  89. package/dist/{hooks → react-hooks}/useMemo.d.ts +1 -1
  90. package/dist/react-hooks/useMemo.d.ts.map +1 -0
  91. package/dist/{hooks → react-hooks}/useMemo.js +3 -3
  92. package/dist/react-hooks/useMemo.js.map +1 -0
  93. package/dist/{hooks → react-hooks}/useMemoCache.d.ts +1 -1
  94. package/dist/react-hooks/useMemoCache.d.ts.map +1 -0
  95. package/dist/{hooks → react-hooks}/useMemoCache.js +1 -1
  96. package/dist/react-hooks/useMemoCache.js.map +1 -0
  97. package/dist/react-hooks/useReducer.d.ts +9 -0
  98. package/dist/react-hooks/useReducer.d.ts.map +1 -0
  99. package/dist/react-hooks/useReducer.js +120 -0
  100. package/dist/react-hooks/useReducer.js.map +1 -0
  101. package/dist/{hooks → react-hooks}/useRef.d.ts +1 -1
  102. package/dist/react-hooks/useRef.d.ts.map +1 -0
  103. package/dist/{hooks → react-hooks}/useRef.js +1 -1
  104. package/dist/react-hooks/useRef.js.map +1 -0
  105. package/dist/{hooks → react-hooks}/useState.d.ts +1 -1
  106. package/dist/react-hooks/useState.d.ts.map +1 -0
  107. package/dist/{hooks → react-hooks}/useState.js +3 -3
  108. package/dist/react-hooks/useState.js.map +1 -0
  109. package/dist/react-shim/index.js +11 -11
  110. package/dist/react-shim/index.js.map +1 -1
  111. package/package.json +1 -1
  112. package/src/__tests__/basic/resourceHandle.test.ts +32 -22
  113. package/src/__tests__/basic/tapEffect.basic.test.ts +8 -8
  114. package/src/__tests__/basic/tapReducer.basic.test.ts +16 -14
  115. package/src/__tests__/basic/tapResources.basic.test.ts +19 -16
  116. package/src/__tests__/basic/tapState.basic.test.ts +11 -11
  117. package/src/__tests__/bench/hosts.bench.tsx +124 -0
  118. package/src/__tests__/bench/tree.bench.tsx +166 -0
  119. package/src/__tests__/errors/errors.effect-errors.test.ts +12 -13
  120. package/src/__tests__/errors/errors.render-errors.test.ts +65 -22
  121. package/src/__tests__/lifecycle/lifecycle.dependencies.test.ts +19 -19
  122. package/src/__tests__/lifecycle/lifecycle.mount-unmount.test.ts +14 -14
  123. package/src/__tests__/parity/describeParity.tsx +217 -0
  124. package/src/__tests__/parity/parity.adversarial.test.tsx +375 -0
  125. package/src/__tests__/parity/parity.basics.test.tsx +281 -0
  126. package/src/__tests__/parity/parity.divergences.test.tsx +208 -0
  127. package/src/__tests__/parity/parity.smoke.test.tsx +43 -0
  128. package/src/__tests__/react/concurrent-mode.test.tsx +10 -6
  129. package/src/__tests__/react/concurrent-pending-updates.test.tsx +351 -0
  130. package/src/__tests__/react/concurrent-render-phase.test.tsx +350 -0
  131. package/src/__tests__/react/react-shim.test.tsx +1 -1
  132. package/src/__tests__/react/useResource.test.tsx +41 -26
  133. package/src/__tests__/react/useTapHost.test.tsx +233 -0
  134. package/src/__tests__/react-dispatcher.test.ts +4 -4
  135. package/src/__tests__/rules/rules.hook-count.test.ts +21 -21
  136. package/src/__tests__/rules/rules.hook-order.test.ts +17 -17
  137. package/src/__tests__/strictmode/strictmode-parity.test.tsx +420 -0
  138. package/src/__tests__/strictmode/strictmode.test.ts +39 -209
  139. package/src/__tests__/test-utils.ts +33 -23
  140. package/src/core/ResourceFiber.ts +43 -35
  141. package/src/core/createTapRoot.ts +45 -0
  142. package/src/core/helpers/commit.ts +12 -2
  143. package/src/core/helpers/execution-context.ts +4 -13
  144. package/src/core/helpers/root.ts +24 -12
  145. package/src/core/react-dispatcher.ts +10 -9
  146. package/src/core/resource.ts +5 -20
  147. package/src/core/scheduler.ts +1 -1
  148. package/src/core/types.ts +27 -21
  149. package/src/hooks/useResource.ts +18 -27
  150. package/src/hooks/useResources.ts +18 -42
  151. package/src/hooks/useTapHost.ts +60 -0
  152. package/src/hooks/useTapRoot.ts +135 -0
  153. package/src/hooks/utils/depsShallowEqual.ts +12 -2
  154. package/src/hooks/utils/useCell.ts +2 -2
  155. package/src/hooks/utils/useDevStrictMode.ts +34 -0
  156. package/src/hooks/utils/useRenderMemo.ts +27 -0
  157. package/src/hooks/utils/useResourceFiberHostUtils.ts +61 -0
  158. package/src/index.ts +6 -3
  159. package/src/{hooks → react-hooks}/index.ts +4 -4
  160. package/src/react-hooks/useEffect.ts +58 -0
  161. package/src/{hooks → react-hooks}/useMemo.ts +1 -1
  162. package/src/react-hooks/useReducer.ts +254 -0
  163. package/src/{hooks → react-hooks}/useState.ts +2 -2
  164. package/src/react-shim/index.ts +1 -1
  165. package/dist/core/createResourceRoot.d.ts +0 -11
  166. package/dist/core/createResourceRoot.d.ts.map +0 -1
  167. package/dist/core/createResourceRoot.js +0 -31
  168. package/dist/core/createResourceRoot.js.map +0 -1
  169. package/dist/core/helpers/callResourceFn.d.ts +0 -1
  170. package/dist/core/helpers/callResourceFn.js +0 -19
  171. package/dist/core/helpers/callResourceFn.js.map +0 -1
  172. package/dist/hooks/use.js.map +0 -1
  173. package/dist/hooks/useCallback.d.ts.map +0 -1
  174. package/dist/hooks/useCallback.js.map +0 -1
  175. package/dist/hooks/useEffect.d.ts.map +0 -1
  176. package/dist/hooks/useEffect.js +0 -40
  177. package/dist/hooks/useEffect.js.map +0 -1
  178. package/dist/hooks/useEffectEvent.d.ts.map +0 -1
  179. package/dist/hooks/useEffectEvent.js.map +0 -1
  180. package/dist/hooks/useMemo.d.ts.map +0 -1
  181. package/dist/hooks/useMemo.js.map +0 -1
  182. package/dist/hooks/useMemoCache.d.ts.map +0 -1
  183. package/dist/hooks/useMemoCache.js.map +0 -1
  184. package/dist/hooks/useReducer.d.ts +0 -21
  185. package/dist/hooks/useReducer.d.ts.map +0 -1
  186. package/dist/hooks/useReducer.js +0 -81
  187. package/dist/hooks/useReducer.js.map +0 -1
  188. package/dist/hooks/useRef.d.ts.map +0 -1
  189. package/dist/hooks/useRef.js.map +0 -1
  190. package/dist/hooks/useResourceRoot.d.ts +0 -20
  191. package/dist/hooks/useResourceRoot.d.ts.map +0 -1
  192. package/dist/hooks/useResourceRoot.js +0 -77
  193. package/dist/hooks/useResourceRoot.js.map +0 -1
  194. package/dist/hooks/useState.d.ts.map +0 -1
  195. package/dist/hooks/useState.js.map +0 -1
  196. package/dist/react/hooks.d.ts +0 -25
  197. package/dist/react/hooks.d.ts.map +0 -1
  198. package/dist/react/hooks.js +0 -69
  199. package/dist/react/hooks.js.map +0 -1
  200. package/src/__tests__/strictmode/react-strictmode-behavior.test.tsx +0 -920
  201. package/src/__tests__/strictmode/react-strictmode-rerender-sources.test.tsx +0 -488
  202. package/src/__tests__/strictmode/tap-strictmode-rerender-sources.test.ts +0 -687
  203. package/src/core/createResourceRoot.ts +0 -53
  204. package/src/core/helpers/callResourceFn.ts +0 -21
  205. package/src/hooks/useEffect.ts +0 -72
  206. package/src/hooks/useReducer.ts +0 -160
  207. package/src/hooks/useResourceRoot.ts +0 -130
  208. package/src/react/hooks.ts +0 -112
  209. /package/src/{hooks → react-hooks}/use.ts +0 -0
  210. /package/src/{hooks → react-hooks}/useCallback.ts +0 -0
  211. /package/src/{hooks → react-hooks}/useEffectEvent.ts +0 -0
  212. /package/src/{hooks → react-hooks}/useMemoCache.ts +0 -0
  213. /package/src/{hooks → react-hooks}/useRef.ts +0 -0
@@ -1,920 +0,0 @@
1
- /**
2
- * Tests to verify React's strict mode behavior
3
- * These tests verify React's own behavior, not tap's implementation
4
- */
5
- /* oxlint-disable react/exhaustive-deps -- intentional missing-dep patterns for strict-mode tests */
6
-
7
- import { describe, it, expect } from "vitest";
8
- import { render } from "@testing-library/react";
9
- import { act } from "react";
10
- import {
11
- StrictMode,
12
- useState,
13
- useEffect,
14
- useMemo,
15
- useReducer,
16
- useRef,
17
- } from "react";
18
-
19
- describe("React Strict Mode Behavior Verification", () => {
20
- describe("Test 1: Effect + setState behavior in strict mode", () => {
21
- it("should mount, setState in effect, unmount, remount with OLD state, then rerender with NEW state", () => {
22
- const events: string[] = [];
23
-
24
- function TestComponent() {
25
- const [count, setCount] = useState(() => {
26
- events.push("useState init");
27
- return 0;
28
- });
29
-
30
- events.push(`render count=${count}`);
31
-
32
- useEffect(() => {
33
- events.push(`effect mount count=${count}`);
34
- if (count === 0) {
35
- setCount(1);
36
- }
37
-
38
- return () => {
39
- events.push(`effect cleanup count=${count}`);
40
- };
41
- }, [count]);
42
-
43
- return <div>Count: {count}</div>;
44
- }
45
-
46
- render(
47
- <StrictMode>
48
- <TestComponent />
49
- </StrictMode>,
50
- );
51
-
52
- // ACTUAL React behavior observed:
53
- // 1. Render twice (double-render): useState init called twice
54
- // 2. Effect mounts with count=0 and calls setState(1)
55
- // 3. Effect unmounts (strict mode)
56
- // 4. Effect remounts with count=0 and calls setState(1)
57
- // 5. setState causes rerender with count=1 (double-render)
58
- // 6. Effect with [count] deps reruns, cleanup old effect, mount new
59
-
60
- expect(events).toEqual([
61
- "useState init",
62
- "useState init",
63
- "render count=0",
64
- "render count=0",
65
- "effect mount count=0",
66
- "effect cleanup count=0",
67
- "effect mount count=0",
68
- "render count=1",
69
- "render count=1",
70
- "effect cleanup count=0",
71
- "effect mount count=1",
72
- ]);
73
- });
74
-
75
- it("should show that setState in effect during mount is applied after strict mode cycle", () => {
76
- const events: string[] = [];
77
-
78
- function TestComponent() {
79
- const [value, setValue] = useState("initial");
80
-
81
- events.push(`render value=${value}`);
82
-
83
- useEffect(() => {
84
- events.push(`effect mount value=${value}`);
85
- if (value === "initial") {
86
- setValue("updated");
87
- }
88
-
89
- return () => {
90
- events.push(`effect cleanup value=${value}`);
91
- };
92
- }, [value]);
93
-
94
- return <div>{value}</div>;
95
- }
96
-
97
- render(
98
- <StrictMode>
99
- <TestComponent />
100
- </StrictMode>,
101
- );
102
-
103
- // ACTUAL React behavior observed:
104
- // 1. Double-render with value=initial (no useState init log because it's a constant)
105
- // 2. Effect mounts and calls setValue
106
- // 3. Effect unmounts (strict mode)
107
- // 4. Effect remounts with value=initial and calls setValue
108
- // 5. setState causes rerender with value=updated (double-render)
109
- // 6. Effect with [value] deps reruns, cleanup old effect, mount new
110
-
111
- expect(events).toEqual([
112
- "render value=initial",
113
- "render value=initial",
114
- "effect mount value=initial",
115
- "effect cleanup value=initial",
116
- "effect mount value=initial",
117
- "render value=updated",
118
- "render value=updated",
119
- "effect cleanup value=initial",
120
- "effect mount value=updated",
121
- ]);
122
- });
123
- });
124
-
125
- describe("Test 2: Render/commit sequence with useState and useMemo", () => {
126
- it("should show the sequence: render → useState init (dropped) → useMemo (dropped) → render → commit → commit(stale?) → render → commit", () => {
127
- const events: string[] = [];
128
-
129
- function TestComponent() {
130
- const renderCount = useRef(0);
131
- renderCount.current++;
132
-
133
- events.push(`render #${renderCount.current}`);
134
-
135
- const [state] = useState(() => {
136
- events.push(`useState init #${renderCount.current}`);
137
- return "state";
138
- });
139
-
140
- const memoValue = useMemo(() => {
141
- events.push(`useMemo #${renderCount.current}`);
142
- return `memo-${renderCount.current}`;
143
- }, []);
144
-
145
- useEffect(() => {
146
- events.push(`effect commit #${renderCount.current} state=${state}`);
147
- return () => {
148
- events.push(`effect cleanup #${renderCount.current}`);
149
- };
150
- }, [state]);
151
-
152
- return <div>{memoValue}</div>;
153
- }
154
-
155
- render(
156
- <StrictMode>
157
- <TestComponent />
158
- </StrictMode>,
159
- );
160
-
161
- // ACTUAL React behavior observed:
162
- // 1. Renders twice (double-render): both useState and useMemo called twice
163
- // 2. The state/memo results are NOT dropped - both are kept
164
- // 3. Commits the effects once
165
- // 4. Unmounts and remounts effects (strict mode)
166
-
167
- expect(events).toEqual([
168
- "render #1",
169
- "useState init #1",
170
- "useState init #1",
171
- "useMemo #1",
172
- "useMemo #1",
173
- "render #2",
174
- "effect commit #2 state=state",
175
- "effect cleanup #2",
176
- "effect commit #2 state=state",
177
- ]);
178
- });
179
-
180
- it("should verify that useState initializer is called twice but second value is used", () => {
181
- const events: string[] = [];
182
- let initCallCount = 0;
183
-
184
- function TestComponent() {
185
- const [value] = useState(() => {
186
- initCallCount++;
187
- events.push(`useState init call #${initCallCount}`);
188
- return initCallCount;
189
- });
190
-
191
- events.push(`render value=${value}`);
192
-
193
- return <div>{value}</div>;
194
- }
195
-
196
- render(
197
- <StrictMode>
198
- <TestComponent />
199
- </StrictMode>,
200
- );
201
-
202
- // ACTUAL React behavior: useState initializer is called twice,
203
- // but the FIRST value is kept (not the second)!
204
- expect(events).toEqual([
205
- "useState init call #1",
206
- "useState init call #2",
207
- "render value=1",
208
- "render value=1",
209
- ]);
210
- });
211
- });
212
-
213
- describe("Test 3: Component tree vs per-component remounting", () => {
214
- it("should show whether React remounts entire tree or per-component", () => {
215
- const events: string[] = [];
216
-
217
- function Parent() {
218
- events.push("Parent render");
219
-
220
- useEffect(() => {
221
- events.push("Parent effect mount");
222
- return () => {
223
- events.push("Parent effect cleanup");
224
- };
225
- }, []);
226
-
227
- return (
228
- <div>
229
- <Child1 />
230
- <Child2 />
231
- </div>
232
- );
233
- }
234
-
235
- function Child1() {
236
- events.push("Child1 render");
237
-
238
- useEffect(() => {
239
- events.push("Child1 effect mount");
240
- return () => {
241
- events.push("Child1 effect cleanup");
242
- };
243
- }, []);
244
-
245
- return <div>Child1</div>;
246
- }
247
-
248
- function Child2() {
249
- events.push("Child2 render");
250
-
251
- useEffect(() => {
252
- events.push("Child2 effect mount");
253
- return () => {
254
- events.push("Child2 effect cleanup");
255
- };
256
- }, []);
257
-
258
- return <div>Child2</div>;
259
- }
260
-
261
- render(
262
- <StrictMode>
263
- <Parent />
264
- </StrictMode>,
265
- );
266
-
267
- // ACTUAL React behavior:
268
- // 1. Parent renders twice, then each child renders twice
269
- // 2. Effects mount in child-to-parent order (children first, then parent)
270
- // 3. Then unmounts all effects and remounts all (strict mode)
271
-
272
- expect(events).toEqual([
273
- "Parent render",
274
- "Parent render",
275
- "Child1 render",
276
- "Child1 render",
277
- "Child2 render",
278
- "Child2 render",
279
- "Child1 effect mount",
280
- "Child2 effect mount",
281
- "Parent effect mount",
282
- "Parent effect cleanup",
283
- "Child1 effect cleanup",
284
- "Child2 effect cleanup",
285
- "Child1 effect mount",
286
- "Child2 effect mount",
287
- "Parent effect mount",
288
- ]);
289
- });
290
-
291
- it("should verify that nested components follow the same pattern with state updates", () => {
292
- const events: string[] = [];
293
-
294
- function Parent() {
295
- const [parentState, setParentState] = useState(0);
296
- events.push(`Parent render state=${parentState}`);
297
-
298
- useEffect(() => {
299
- events.push(`Parent effect mount state=${parentState}`);
300
- if (parentState === 0) {
301
- setParentState(1);
302
- }
303
- return () => {
304
- events.push(`Parent effect cleanup state=${parentState}`);
305
- };
306
- }, [parentState]);
307
-
308
- return (
309
- <div>
310
- <Child parentState={parentState} />
311
- </div>
312
- );
313
- }
314
-
315
- function Child({ parentState }: { parentState: number }) {
316
- events.push(`Child render parentState=${parentState}`);
317
-
318
- useEffect(() => {
319
- events.push(`Child effect mount parentState=${parentState}`);
320
- return () => {
321
- events.push(`Child effect cleanup parentState=${parentState}`);
322
- };
323
- }, [parentState]);
324
-
325
- return <div>Child</div>;
326
- }
327
-
328
- render(
329
- <StrictMode>
330
- <Parent />
331
- </StrictMode>,
332
- );
333
-
334
- // ACTUAL React behavior:
335
- // 1. Parent double-render, child double-render
336
- // 2. Effects mount in child-to-parent order
337
- // 3. Unmount/remount all (strict mode)
338
- // 4. State update causes parent double-render, child double-render
339
- // 5. Effects update (no remount), child-to-parent order
340
-
341
- expect(events).toEqual([
342
- "Parent render state=0",
343
- "Parent render state=0",
344
- "Child render parentState=0",
345
- "Child render parentState=0",
346
- "Child effect mount parentState=0",
347
- "Parent effect mount state=0",
348
- "Parent effect cleanup state=0",
349
- "Child effect cleanup parentState=0",
350
- "Child effect mount parentState=0",
351
- "Parent effect mount state=0",
352
- "Parent render state=1",
353
- "Parent render state=1",
354
- "Child render parentState=1",
355
- "Child render parentState=1",
356
- "Child effect cleanup parentState=0",
357
- "Parent effect cleanup state=0",
358
- "Child effect mount parentState=1",
359
- "Parent effect mount state=1",
360
- ]);
361
- });
362
- });
363
-
364
- describe("Test 4: Delayed mount behavior (subtree mounted after initial render)", () => {
365
- it("should show behavior when a subtree is mounted after initial render completes", () => {
366
- const events: string[] = [];
367
-
368
- function Parent() {
369
- const [showChild, setShowChild] = useState(false);
370
- events.push(`Parent render showChild=${showChild}`);
371
-
372
- useEffect(() => {
373
- events.push(`Parent effect mount showChild=${showChild}`);
374
- if (!showChild) {
375
- setShowChild(true);
376
- }
377
- return () => {
378
- events.push(`Parent effect cleanup showChild=${showChild}`);
379
- };
380
- }, [showChild]);
381
-
382
- return (
383
- <div>
384
- Parent
385
- {showChild && <DelayedChild />}
386
- </div>
387
- );
388
- }
389
-
390
- function DelayedChild() {
391
- events.push("DelayedChild render");
392
-
393
- useEffect(() => {
394
- events.push("DelayedChild effect mount");
395
- return () => {
396
- events.push("DelayedChild effect cleanup");
397
- };
398
- }, []);
399
-
400
- return <div>Child</div>;
401
- }
402
-
403
- render(
404
- <StrictMode>
405
- <Parent />
406
- </StrictMode>,
407
- );
408
-
409
- // ACTUAL React behavior: Components added after initial render
410
- // still get double-render and strict mode double-mount
411
-
412
- expect(events).toEqual([
413
- "Parent render showChild=false",
414
- "Parent render showChild=false",
415
- "Parent effect mount showChild=false",
416
- "Parent effect cleanup showChild=false",
417
- "Parent effect mount showChild=false",
418
- "Parent render showChild=true",
419
- "Parent render showChild=true",
420
- "DelayedChild render",
421
- "DelayedChild render",
422
- "Parent effect cleanup showChild=false",
423
- "DelayedChild effect mount",
424
- "Parent effect mount showChild=true",
425
- "DelayedChild effect cleanup",
426
- "DelayedChild effect mount",
427
- ]);
428
- });
429
-
430
- it("should verify that subtree mounted later still gets strict mode treatment", () => {
431
- const events: string[] = [];
432
-
433
- function Root() {
434
- const [mounted, setMounted] = useState(false);
435
- events.push(`Root render mounted=${mounted}`);
436
-
437
- useEffect(() => {
438
- events.push(`Root effect mount mounted=${mounted}`);
439
- if (!mounted) {
440
- // Mount the subtree after the first effect runs
441
- setMounted(true);
442
- }
443
- return () => {
444
- events.push(`Root effect cleanup mounted=${mounted}`);
445
- };
446
- }, [mounted]);
447
-
448
- return <div>{mounted && <LateComponent />}</div>;
449
- }
450
-
451
- function LateComponent() {
452
- const [state, setState] = useState("initial");
453
- events.push(`LateComponent render state=${state}`);
454
-
455
- useEffect(() => {
456
- events.push(`LateComponent effect mount state=${state}`);
457
- if (state === "initial") {
458
- setState("updated");
459
- }
460
- return () => {
461
- events.push(`LateComponent effect cleanup state=${state}`);
462
- };
463
- }, [state]);
464
-
465
- return <div>{state}</div>;
466
- }
467
-
468
- render(
469
- <StrictMode>
470
- <Root />
471
- </StrictMode>,
472
- );
473
-
474
- // ACTUAL React behavior: Components added after initial strict mode cycle
475
- // still get the double-render and double-mount treatment,
476
- // but setState only causes double-render (no remount)
477
-
478
- expect(events).toEqual([
479
- "Root render mounted=false",
480
- "Root render mounted=false",
481
- "Root effect mount mounted=false",
482
- "Root effect cleanup mounted=false",
483
- "Root effect mount mounted=false",
484
- "Root render mounted=true",
485
- "Root render mounted=true",
486
- "LateComponent render state=initial",
487
- "LateComponent render state=initial",
488
- "Root effect cleanup mounted=false",
489
- "LateComponent effect mount state=initial",
490
- "Root effect mount mounted=true",
491
- "LateComponent effect cleanup state=initial",
492
- "LateComponent effect mount state=initial",
493
- "LateComponent render state=updated",
494
- "LateComponent render state=updated",
495
- "LateComponent effect cleanup state=initial",
496
- "LateComponent effect mount state=updated",
497
- ]);
498
- });
499
- });
500
-
501
- describe("Test 5: useMemo strict mode behavior", () => {
502
- it("should double-invoke useMemo factory and use the first result", () => {
503
- const events: string[] = [];
504
- let memoCallCount = 0;
505
-
506
- function TestComponent() {
507
- const memoValue = useMemo(() => {
508
- memoCallCount++;
509
- events.push(`memo-${memoCallCount}`);
510
- return memoCallCount;
511
- }, []);
512
-
513
- events.push(`render memoValue=${memoValue}`);
514
-
515
- return <div>{memoValue}</div>;
516
- }
517
-
518
- render(
519
- <StrictMode>
520
- <TestComponent />
521
- </StrictMode>,
522
- );
523
-
524
- expect(events).toEqual([
525
- "memo-1",
526
- "memo-2",
527
- "render memoValue=1",
528
- "render memoValue=1",
529
- ]);
530
- });
531
- });
532
-
533
- describe("Test 6: useReducer strict mode behavior", () => {
534
- it("should double-invoke useReducer initializer and use the first result", () => {
535
- const events: string[] = [];
536
- let initCallCount = 0;
537
-
538
- function TestComponent() {
539
- const [state] = useReducer(
540
- (s: number, a: number) => s + a,
541
- 0,
542
- (arg) => {
543
- initCallCount++;
544
- events.push(`init-${initCallCount}`);
545
- return arg + initCallCount * 10;
546
- },
547
- );
548
-
549
- events.push(`render state=${state}`);
550
-
551
- return <div>{state}</div>;
552
- }
553
-
554
- render(
555
- <StrictMode>
556
- <TestComponent />
557
- </StrictMode>,
558
- );
559
-
560
- expect(events).toEqual([
561
- "init-1",
562
- "init-2",
563
- "render state=10",
564
- "render state=10",
565
- ]);
566
- });
567
-
568
- it("should double-invoke useReducer reducer on dispatch and use the first result", () => {
569
- const events: string[] = [];
570
- let reducerCallCount = 0;
571
-
572
- function TestComponent() {
573
- const [state, dispatch] = useReducer((s: number, _a: number) => {
574
- reducerCallCount++;
575
- const result = reducerCallCount * 100;
576
- events.push(`reducer-${reducerCallCount} state=${s} -> ${result}`);
577
- return result;
578
- }, 0);
579
-
580
- events.push(`render state=${state}`);
581
-
582
- useEffect(() => {
583
- if (state === 0) {
584
- events.push("dispatch");
585
- dispatch(1);
586
- }
587
- }, [state]);
588
-
589
- return <div>{state}</div>;
590
- }
591
-
592
- render(
593
- <StrictMode>
594
- <TestComponent />
595
- </StrictMode>,
596
- );
597
-
598
- // React behavior: reducer is called 4 times (2 dispatches × 2 strict mode double-calls)
599
- // Dispatch #1 (effect mount): reducer called twice, SECOND result (200) kept
600
- // Dispatch #2 (effect remount): reducer called twice, SECOND result (400) kept
601
- // Note: this is opposite to useMemo/useState which keep the FIRST result!
602
- expect(reducerCallCount).toBe(4);
603
- expect(events).toEqual([
604
- "render state=0",
605
- "render state=0",
606
- "dispatch",
607
- "dispatch",
608
- "reducer-1 state=0 -> 100",
609
- "reducer-2 state=0 -> 200",
610
- "reducer-3 state=200 -> 300",
611
- "reducer-4 state=200 -> 400",
612
- "render state=400",
613
- "render state=400",
614
- ]);
615
- });
616
- });
617
-
618
- describe("Test 7: useState/useReducer dispatch double-invoke (isolated from effects)", () => {
619
- it("should double-invoke useState updater and use the first result", () => {
620
- const events: string[] = [];
621
- let updaterCallCount = 0;
622
- let setCountRef: ((fn: (prev: number) => number) => void) | null = null;
623
-
624
- function TestComponent() {
625
- const [count, setCount] = useState(0);
626
- setCountRef = setCount;
627
-
628
- events.push(`render count=${count}`);
629
-
630
- return <div>{count}</div>;
631
- }
632
-
633
- render(
634
- <StrictMode>
635
- <TestComponent />
636
- </StrictMode>,
637
- );
638
-
639
- events.length = 0;
640
- updaterCallCount = 0;
641
-
642
- act(() => {
643
- setCountRef!((prev) => {
644
- updaterCallCount++;
645
- const result = updaterCallCount * 100;
646
- events.push(`updater-${updaterCallCount} prev=${prev} -> ${result}`);
647
- return result;
648
- });
649
- });
650
-
651
- // useState updater is double-invoked, FIRST result kept
652
- // (same as useMemo/useState init — NOT like useReducer dispatch!)
653
- expect(updaterCallCount).toBe(2);
654
- expect(events).toEqual([
655
- "updater-1 prev=0 -> 100",
656
- "updater-2 prev=0 -> 200",
657
- "render count=100",
658
- "render count=100",
659
- ]);
660
- });
661
-
662
- it("should double-invoke useReducer reducer and use the first result", () => {
663
- const events: string[] = [];
664
- let reducerCallCount = 0;
665
- let dispatchRef: ((a: number) => void) | null = null;
666
-
667
- function TestComponent() {
668
- const [state, dispatch] = useReducer((s: number, _a: number) => {
669
- reducerCallCount++;
670
- const result = reducerCallCount * 100;
671
- events.push(`reducer-${reducerCallCount} state=${s} -> ${result}`);
672
- return result;
673
- }, 0);
674
- dispatchRef = dispatch;
675
-
676
- events.push(`render state=${state}`);
677
-
678
- return <div>{state}</div>;
679
- }
680
-
681
- render(
682
- <StrictMode>
683
- <TestComponent />
684
- </StrictMode>,
685
- );
686
-
687
- events.length = 0;
688
- reducerCallCount = 0;
689
-
690
- act(() => {
691
- dispatchRef!(1);
692
- });
693
-
694
- // useReducer reducer is double-invoked, SECOND result kept!
695
- // This differs from useState updater which keeps the FIRST result.
696
- expect(reducerCallCount).toBe(2);
697
- expect(events).toEqual([
698
- "reducer-1 state=0 -> 100",
699
- "reducer-2 state=0 -> 200",
700
- "render state=200",
701
- "render state=200",
702
- ]);
703
- });
704
- });
705
-
706
- describe("Test 8: setState in effect - strict mode edge cases", () => {
707
- it("should verify which setState is applied when effect calls setState only on first mount", () => {
708
- const events: string[] = [];
709
- let effectRunCount = 0;
710
-
711
- function TestComponent() {
712
- const [count, setCount] = useState(0);
713
- events.push(`render count=${count}`);
714
-
715
- useEffect(() => {
716
- effectRunCount++;
717
- events.push(`effect mount #${effectRunCount} count=${count}`);
718
-
719
- // Only call setState on the FIRST mount, not the remount
720
- if (effectRunCount === 1) {
721
- events.push(`setState(1) called in effect #${effectRunCount}`);
722
- setCount(1);
723
- } else {
724
- events.push(`no setState in effect #${effectRunCount}`);
725
- }
726
-
727
- return () => {
728
- events.push(`effect cleanup #${effectRunCount} count=${count}`);
729
- };
730
- }, []);
731
-
732
- return <div>{count}</div>;
733
- }
734
-
735
- render(
736
- <StrictMode>
737
- <TestComponent />
738
- </StrictMode>,
739
- );
740
-
741
- // KEY FINDING: React DOES apply the setState(1) from effect #1,
742
- // even though it was called in an effect that was cleaned up!
743
- // The state update is queued and processed after the strict mode cycle.
744
- expect(events).toEqual([
745
- "render count=0",
746
- "render count=0",
747
- "effect mount #1 count=0",
748
- "setState(1) called in effect #1",
749
- "effect cleanup #1 count=0",
750
- "effect mount #2 count=0",
751
- "no setState in effect #2",
752
- "render count=1", // setState(1) was applied!
753
- "render count=1",
754
- ]);
755
- });
756
-
757
- it("should verify which setState is applied when both effect mounts call setState with different values", () => {
758
- const events: string[] = [];
759
- let effectRunCount = 0;
760
-
761
- function TestComponent() {
762
- const [count, setCount] = useState(0);
763
- events.push(`render count=${count}`);
764
-
765
- useEffect(() => {
766
- effectRunCount++;
767
- events.push(`effect mount #${effectRunCount} count=${count}`);
768
-
769
- if (effectRunCount === 1) {
770
- events.push(`setState(1) called in effect #${effectRunCount}`);
771
- setCount(1);
772
- } else if (effectRunCount === 2) {
773
- events.push(`setState(2) called in effect #${effectRunCount}`);
774
- setCount(2);
775
- }
776
-
777
- return () => {
778
- events.push(`effect cleanup #${effectRunCount} count=${count}`);
779
- };
780
- }, []);
781
-
782
- return <div>{count}</div>;
783
- }
784
-
785
- render(
786
- <StrictMode>
787
- <TestComponent />
788
- </StrictMode>,
789
- );
790
-
791
- // KEY FINDING: React applies the LAST setState (setState(2)),
792
- // not the first one or both. The state updates are batched and
793
- // the later one overwrites the earlier one.
794
- expect(events).toEqual([
795
- "render count=0",
796
- "render count=0",
797
- "effect mount #1 count=0",
798
- "setState(1) called in effect #1",
799
- "effect cleanup #1 count=0",
800
- "effect mount #2 count=0",
801
- "setState(2) called in effect #2",
802
- "render count=2", // Only setState(2) was applied!
803
- "render count=2",
804
- ]);
805
- });
806
-
807
- it("should verify setState callback execution during strict mode", () => {
808
- const events: string[] = [];
809
- let effectRunCount = 0;
810
-
811
- function TestComponent() {
812
- const [count, setCount] = useState(0);
813
- events.push(`render count=${count}`);
814
-
815
- useEffect(() => {
816
- effectRunCount++;
817
- events.push(`effect mount #${effectRunCount} count=${count}`);
818
-
819
- // Use updater function to see if it's called once or twice
820
- setCount((prev) => {
821
- events.push(
822
- `setState updater called with prev=${prev} in effect #${effectRunCount}`,
823
- );
824
- return prev + effectRunCount;
825
- });
826
-
827
- return () => {
828
- events.push(`effect cleanup #${effectRunCount} count=${count}`);
829
- };
830
- }, []);
831
-
832
- return <div>{count}</div>;
833
- }
834
-
835
- render(
836
- <StrictMode>
837
- <TestComponent />
838
- </StrictMode>,
839
- );
840
-
841
- // KEY FINDING: Both updater functions are queued and executed!
842
- // Effect #1: updater(0) => 0 + 1 = 1
843
- // Effect #2: updater(0) => 0 + 2 = 2
844
- // But then the updater from effect #2 runs TWICE MORE with prev=1
845
- // due to strict mode doubling the updater call itself!
846
- // Final calculation: 0 -> 1 (from effect #1) -> 3 (from effect #2: 1+2)
847
- expect(events).toEqual([
848
- "render count=0",
849
- "render count=0",
850
- "effect mount #1 count=0",
851
- "setState updater called with prev=0 in effect #1",
852
- "effect cleanup #1 count=0",
853
- "effect mount #2 count=0",
854
- "setState updater called with prev=0 in effect #2",
855
- "setState updater called with prev=1 in effect #2", // Updater doubled!
856
- "setState updater called with prev=1 in effect #2", // Updater doubled again!
857
- "render count=3", // Final: 0 -> 1 -> 3
858
- "render count=3",
859
- ]);
860
- });
861
-
862
- it("should use the SECOND return value when updater is called twice in strict mode", () => {
863
- const events: string[] = [];
864
- let updaterCallCount = 0;
865
-
866
- function TestComponent() {
867
- const [count, setCount] = useState(0);
868
- events.push(`render count=${count}`);
869
-
870
- useEffect(() => {
871
- events.push("effect mount");
872
- setCount((prev) => {
873
- updaterCallCount++;
874
- events.push(`updater call #${updaterCallCount} with prev=${prev}`);
875
- // Return different values on each call
876
- if (updaterCallCount === 1) {
877
- return 100; // First call returns 100
878
- }
879
- return 200; // Second call returns 200
880
- });
881
-
882
- return () => {
883
- events.push("effect cleanup");
884
- };
885
- }, []);
886
-
887
- return <div>{count}</div>;
888
- }
889
-
890
- render(
891
- <StrictMode>
892
- <TestComponent />
893
- </StrictMode>,
894
- );
895
-
896
- // ANSWER: React calls updater 4 times and uses the LAST return value!
897
- // Sequence:
898
- // 1. Effect #1 mounts: updater(0) → 100
899
- // 2. Effect #1 cleanup (strict mode)
900
- // 3. Effect #2 mounts: updater(0) → 200
901
- // 4. Strict mode doubles the updater: updater(100) → 200
902
- // 5. Strict mode doubles again: updater(100) → 200
903
- // Final value: 200 (from the last call)
904
- expect(updaterCallCount).toBe(4);
905
- expect(events).toEqual([
906
- "render count=0",
907
- "render count=0",
908
- "effect mount",
909
- "updater call #1 with prev=0", // Effect #1: returns 100
910
- "effect cleanup",
911
- "effect mount",
912
- "updater call #2 with prev=0", // Effect #2: returns 200
913
- "updater call #3 with prev=100", // Strict mode double: returns 200
914
- "updater call #4 with prev=100", // Strict mode double again: returns 200
915
- "render count=200", // Uses LAST return value
916
- "render count=200",
917
- ]);
918
- });
919
- });
920
- });