@assistant-ui/tap 0.3.6 → 0.4.2

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 (124) hide show
  1. package/README.md +24 -23
  2. package/dist/core/ResourceFiber.d.ts +1 -1
  3. package/dist/core/ResourceFiber.d.ts.map +1 -1
  4. package/dist/core/ResourceFiber.js +15 -8
  5. package/dist/core/ResourceFiber.js.map +1 -1
  6. package/dist/core/commit.d.ts +1 -1
  7. package/dist/core/commit.d.ts.map +1 -1
  8. package/dist/core/commit.js +40 -50
  9. package/dist/core/commit.js.map +1 -1
  10. package/dist/core/context.d.ts +2 -2
  11. package/dist/core/context.d.ts.map +1 -1
  12. package/dist/core/context.js +2 -2
  13. package/dist/core/context.js.map +1 -1
  14. package/dist/core/createResource.d.ts +3 -2
  15. package/dist/core/createResource.d.ts.map +1 -1
  16. package/dist/core/createResource.js +48 -22
  17. package/dist/core/createResource.js.map +1 -1
  18. package/dist/core/env.d.ts +2 -0
  19. package/dist/core/env.d.ts.map +1 -0
  20. package/dist/core/env.js +3 -0
  21. package/dist/core/env.js.map +1 -0
  22. package/dist/core/execution-context.d.ts +1 -0
  23. package/dist/core/execution-context.d.ts.map +1 -1
  24. package/dist/core/execution-context.js +8 -0
  25. package/dist/core/execution-context.js.map +1 -1
  26. package/dist/core/resource.d.ts +4 -3
  27. package/dist/core/resource.d.ts.map +1 -1
  28. package/dist/core/resource.js.map +1 -1
  29. package/dist/core/scheduler.d.ts +1 -1
  30. package/dist/core/scheduler.d.ts.map +1 -1
  31. package/dist/core/scheduler.js +4 -1
  32. package/dist/core/scheduler.js.map +1 -1
  33. package/dist/core/types.d.ts +22 -21
  34. package/dist/core/types.d.ts.map +1 -1
  35. package/dist/core/types.js +1 -1
  36. package/dist/core/types.js.map +1 -1
  37. package/dist/core/withKey.d.ts +3 -0
  38. package/dist/core/withKey.d.ts.map +1 -0
  39. package/dist/core/withKey.js +4 -0
  40. package/dist/core/withKey.js.map +1 -0
  41. package/dist/hooks/tap-callback.d.ts.map +1 -1
  42. package/dist/hooks/tap-callback.js +1 -0
  43. package/dist/hooks/tap-callback.js.map +1 -1
  44. package/dist/hooks/tap-const.d.ts +2 -0
  45. package/dist/hooks/tap-const.d.ts.map +1 -0
  46. package/dist/hooks/tap-const.js +6 -0
  47. package/dist/hooks/tap-const.js.map +1 -0
  48. package/dist/hooks/tap-effect-event.d.ts.map +1 -1
  49. package/dist/hooks/tap-effect-event.js +11 -0
  50. package/dist/hooks/tap-effect-event.js.map +1 -1
  51. package/dist/hooks/tap-effect.d.ts.map +1 -1
  52. package/dist/hooks/tap-effect.js +46 -31
  53. package/dist/hooks/tap-effect.js.map +1 -1
  54. package/dist/hooks/tap-inline-resource.d.ts +2 -2
  55. package/dist/hooks/tap-inline-resource.d.ts.map +1 -1
  56. package/dist/hooks/tap-memo.d.ts.map +1 -1
  57. package/dist/hooks/tap-memo.js +9 -1
  58. package/dist/hooks/tap-memo.js.map +1 -1
  59. package/dist/hooks/tap-resource.d.ts +3 -3
  60. package/dist/hooks/tap-resource.d.ts.map +1 -1
  61. package/dist/hooks/tap-resource.js +17 -9
  62. package/dist/hooks/tap-resource.js.map +1 -1
  63. package/dist/hooks/tap-resources.d.ts +2 -10
  64. package/dist/hooks/tap-resources.d.ts.map +1 -1
  65. package/dist/hooks/tap-resources.js +74 -43
  66. package/dist/hooks/tap-resources.js.map +1 -1
  67. package/dist/hooks/tap-state.d.ts.map +1 -1
  68. package/dist/hooks/tap-state.js +37 -24
  69. package/dist/hooks/tap-state.js.map +1 -1
  70. package/dist/hooks/utils/depsShallowEqual.d.ts.map +1 -0
  71. package/dist/hooks/utils/depsShallowEqual.js.map +1 -0
  72. package/dist/hooks/utils/tapHook.d.ts +6 -0
  73. package/dist/hooks/utils/tapHook.d.ts.map +1 -0
  74. package/dist/hooks/utils/tapHook.js +24 -0
  75. package/dist/hooks/utils/tapHook.js.map +1 -0
  76. package/dist/index.d.ts +5 -3
  77. package/dist/index.d.ts.map +1 -1
  78. package/dist/index.js +4 -2
  79. package/dist/index.js.map +1 -1
  80. package/dist/react/use-resource.d.ts +2 -2
  81. package/dist/react/use-resource.d.ts.map +1 -1
  82. package/dist/react/use-resource.js +24 -10
  83. package/dist/react/use-resource.js.map +1 -1
  84. package/package.json +10 -3
  85. package/src/__tests__/basic/resourceHandle.test.ts +4 -4
  86. package/src/__tests__/basic/tapEffect.basic.test.ts +3 -2
  87. package/src/__tests__/basic/tapResources.basic.test.ts +84 -64
  88. package/src/__tests__/basic/tapState.basic.test.ts +8 -8
  89. package/src/__tests__/errors/errors.effect-errors.test.ts +8 -3
  90. package/src/__tests__/lifecycle/lifecycle.dependencies.test.ts +3 -2
  91. package/src/__tests__/lifecycle/lifecycle.mount-unmount.test.ts +2 -2
  92. package/src/__tests__/react/concurrent-mode.test.tsx +243 -0
  93. package/src/__tests__/strictmode/react-strictmode-behavior.test.tsx +709 -0
  94. package/src/__tests__/strictmode/react-strictmode-rerender-sources.test.tsx +392 -0
  95. package/src/__tests__/strictmode/strictmode.test.ts +274 -0
  96. package/src/__tests__/strictmode/tap-strictmode-rerender-sources.test.ts +723 -0
  97. package/src/__tests__/test-utils.ts +8 -6
  98. package/src/core/ResourceFiber.ts +21 -11
  99. package/src/core/commit.ts +37 -57
  100. package/src/core/context.ts +2 -2
  101. package/src/core/createResource.ts +64 -25
  102. package/src/core/env.ts +3 -0
  103. package/src/core/execution-context.ts +9 -0
  104. package/src/core/resource.ts +9 -3
  105. package/src/core/scheduler.ts +4 -1
  106. package/src/core/types.ts +25 -26
  107. package/src/core/withKey.ts +8 -0
  108. package/src/hooks/tap-callback.ts +1 -0
  109. package/src/hooks/tap-const.ts +6 -0
  110. package/src/hooks/tap-effect-event.ts +15 -0
  111. package/src/hooks/tap-effect.ts +51 -38
  112. package/src/hooks/tap-inline-resource.ts +2 -2
  113. package/src/hooks/tap-memo.ts +10 -1
  114. package/src/hooks/tap-resource.ts +24 -20
  115. package/src/hooks/tap-resources.ts +86 -63
  116. package/src/hooks/tap-state.ts +49 -26
  117. package/src/hooks/utils/tapHook.ts +35 -0
  118. package/src/index.ts +8 -3
  119. package/src/react/use-resource.ts +27 -16
  120. package/dist/hooks/depsShallowEqual.d.ts.map +0 -1
  121. package/dist/hooks/depsShallowEqual.js.map +0 -1
  122. /package/dist/hooks/{depsShallowEqual.d.ts → utils/depsShallowEqual.d.ts} +0 -0
  123. /package/dist/hooks/{depsShallowEqual.js → utils/depsShallowEqual.js} +0 -0
  124. /package/src/hooks/{depsShallowEqual.ts → utils/depsShallowEqual.ts} +0 -0
@@ -0,0 +1,709 @@
1
+ /**
2
+ * Tests to verify React's strict mode behavior
3
+ * These tests verify React's own behavior, not tap's implementation
4
+ */
5
+
6
+ import { describe, it, expect } from "vitest";
7
+ import { render } from "@testing-library/react";
8
+ import { StrictMode, useState, useEffect, useMemo, useRef } from "react";
9
+
10
+ describe("React Strict Mode Behavior Verification", () => {
11
+ describe("Test 1: Effect + setState behavior in strict mode", () => {
12
+ it("should mount, setState in effect, unmount, remount with OLD state, then rerender with NEW state", () => {
13
+ const events: string[] = [];
14
+
15
+ function TestComponent() {
16
+ const [count, setCount] = useState(() => {
17
+ events.push("useState init");
18
+ return 0;
19
+ });
20
+
21
+ events.push(`render count=${count}`);
22
+
23
+ useEffect(() => {
24
+ events.push(`effect mount count=${count}`);
25
+ if (count === 0) {
26
+ setCount(1);
27
+ }
28
+
29
+ return () => {
30
+ events.push(`effect cleanup count=${count}`);
31
+ };
32
+ }, [count]);
33
+
34
+ return <div>Count: {count}</div>;
35
+ }
36
+
37
+ render(
38
+ <StrictMode>
39
+ <TestComponent />
40
+ </StrictMode>,
41
+ );
42
+
43
+ // ACTUAL React behavior observed:
44
+ // 1. Render twice (double-render): useState init called twice
45
+ // 2. Effect mounts with count=0 and calls setState(1)
46
+ // 3. Effect unmounts (strict mode)
47
+ // 4. Effect remounts with count=0 and calls setState(1)
48
+ // 5. setState causes rerender with count=1 (double-render)
49
+ // 6. Effect with [count] deps reruns, cleanup old effect, mount new
50
+
51
+ expect(events).toEqual([
52
+ "useState init",
53
+ "useState init",
54
+ "render count=0",
55
+ "render count=0",
56
+ "effect mount count=0",
57
+ "effect cleanup count=0",
58
+ "effect mount count=0",
59
+ "render count=1",
60
+ "render count=1",
61
+ "effect cleanup count=0",
62
+ "effect mount count=1",
63
+ ]);
64
+ });
65
+
66
+ it("should show that setState in effect during mount is applied after strict mode cycle", () => {
67
+ const events: string[] = [];
68
+
69
+ function TestComponent() {
70
+ const [value, setValue] = useState("initial");
71
+
72
+ events.push(`render value=${value}`);
73
+
74
+ useEffect(() => {
75
+ events.push(`effect mount value=${value}`);
76
+ if (value === "initial") {
77
+ setValue("updated");
78
+ }
79
+
80
+ return () => {
81
+ events.push(`effect cleanup value=${value}`);
82
+ };
83
+ }, [value]);
84
+
85
+ return <div>{value}</div>;
86
+ }
87
+
88
+ render(
89
+ <StrictMode>
90
+ <TestComponent />
91
+ </StrictMode>,
92
+ );
93
+
94
+ // ACTUAL React behavior observed:
95
+ // 1. Double-render with value=initial (no useState init log because it's a constant)
96
+ // 2. Effect mounts and calls setValue
97
+ // 3. Effect unmounts (strict mode)
98
+ // 4. Effect remounts with value=initial and calls setValue
99
+ // 5. setState causes rerender with value=updated (double-render)
100
+ // 6. Effect with [value] deps reruns, cleanup old effect, mount new
101
+
102
+ expect(events).toEqual([
103
+ "render value=initial",
104
+ "render value=initial",
105
+ "effect mount value=initial",
106
+ "effect cleanup value=initial",
107
+ "effect mount value=initial",
108
+ "render value=updated",
109
+ "render value=updated",
110
+ "effect cleanup value=initial",
111
+ "effect mount value=updated",
112
+ ]);
113
+ });
114
+ });
115
+
116
+ describe("Test 2: Render/commit sequence with useState and useMemo", () => {
117
+ it("should show the sequence: render → useState init (dropped) → useMemo (dropped) → render → commit → commit(stale?) → render → commit", () => {
118
+ const events: string[] = [];
119
+
120
+ function TestComponent() {
121
+ const renderCount = useRef(0);
122
+ renderCount.current++;
123
+
124
+ events.push(`render #${renderCount.current}`);
125
+
126
+ const [state] = useState(() => {
127
+ events.push(`useState init #${renderCount.current}`);
128
+ return "state";
129
+ });
130
+
131
+ const memoValue = useMemo(() => {
132
+ events.push(`useMemo #${renderCount.current}`);
133
+ return `memo-${renderCount.current}`;
134
+ }, []);
135
+
136
+ useEffect(() => {
137
+ events.push(`effect commit #${renderCount.current} state=${state}`);
138
+ return () => {
139
+ events.push(`effect cleanup #${renderCount.current}`);
140
+ };
141
+ }, [state]);
142
+
143
+ return <div>{memoValue}</div>;
144
+ }
145
+
146
+ render(
147
+ <StrictMode>
148
+ <TestComponent />
149
+ </StrictMode>,
150
+ );
151
+
152
+ // ACTUAL React behavior observed:
153
+ // 1. Renders twice (double-render): both useState and useMemo called twice
154
+ // 2. The state/memo results are NOT dropped - both are kept
155
+ // 3. Commits the effects once
156
+ // 4. Unmounts and remounts effects (strict mode)
157
+
158
+ expect(events).toEqual([
159
+ "render #1",
160
+ "useState init #1",
161
+ "useState init #1",
162
+ "useMemo #1",
163
+ "useMemo #1",
164
+ "render #2",
165
+ "effect commit #2 state=state",
166
+ "effect cleanup #2",
167
+ "effect commit #2 state=state",
168
+ ]);
169
+ });
170
+
171
+ it("should verify that useState initializer is called twice but second value is used", () => {
172
+ const events: string[] = [];
173
+ let initCallCount = 0;
174
+
175
+ function TestComponent() {
176
+ const [value] = useState(() => {
177
+ initCallCount++;
178
+ events.push(`useState init call #${initCallCount}`);
179
+ return initCallCount;
180
+ });
181
+
182
+ events.push(`render value=${value}`);
183
+
184
+ return <div>{value}</div>;
185
+ }
186
+
187
+ render(
188
+ <StrictMode>
189
+ <TestComponent />
190
+ </StrictMode>,
191
+ );
192
+
193
+ // ACTUAL React behavior: useState initializer is called twice,
194
+ // but the FIRST value is kept (not the second)!
195
+ expect(events).toEqual([
196
+ "useState init call #1",
197
+ "useState init call #2",
198
+ "render value=1",
199
+ "render value=1",
200
+ ]);
201
+ });
202
+ });
203
+
204
+ describe("Test 3: Component tree vs per-component remounting", () => {
205
+ it("should show whether React remounts entire tree or per-component", () => {
206
+ const events: string[] = [];
207
+
208
+ function Parent() {
209
+ events.push("Parent render");
210
+
211
+ useEffect(() => {
212
+ events.push("Parent effect mount");
213
+ return () => {
214
+ events.push("Parent effect cleanup");
215
+ };
216
+ }, []);
217
+
218
+ return (
219
+ <div>
220
+ <Child1 />
221
+ <Child2 />
222
+ </div>
223
+ );
224
+ }
225
+
226
+ function Child1() {
227
+ events.push("Child1 render");
228
+
229
+ useEffect(() => {
230
+ events.push("Child1 effect mount");
231
+ return () => {
232
+ events.push("Child1 effect cleanup");
233
+ };
234
+ }, []);
235
+
236
+ return <div>Child1</div>;
237
+ }
238
+
239
+ function Child2() {
240
+ events.push("Child2 render");
241
+
242
+ useEffect(() => {
243
+ events.push("Child2 effect mount");
244
+ return () => {
245
+ events.push("Child2 effect cleanup");
246
+ };
247
+ }, []);
248
+
249
+ return <div>Child2</div>;
250
+ }
251
+
252
+ render(
253
+ <StrictMode>
254
+ <Parent />
255
+ </StrictMode>,
256
+ );
257
+
258
+ // ACTUAL React behavior:
259
+ // 1. Parent renders twice, then each child renders twice
260
+ // 2. Effects mount in child-to-parent order (children first, then parent)
261
+ // 3. Then unmounts all effects and remounts all (strict mode)
262
+
263
+ expect(events).toEqual([
264
+ "Parent render",
265
+ "Parent render",
266
+ "Child1 render",
267
+ "Child1 render",
268
+ "Child2 render",
269
+ "Child2 render",
270
+ "Child1 effect mount",
271
+ "Child2 effect mount",
272
+ "Parent effect mount",
273
+ "Parent effect cleanup",
274
+ "Child1 effect cleanup",
275
+ "Child2 effect cleanup",
276
+ "Child1 effect mount",
277
+ "Child2 effect mount",
278
+ "Parent effect mount",
279
+ ]);
280
+ });
281
+
282
+ it("should verify that nested components follow the same pattern with state updates", () => {
283
+ const events: string[] = [];
284
+
285
+ function Parent() {
286
+ const [parentState, setParentState] = useState(0);
287
+ events.push(`Parent render state=${parentState}`);
288
+
289
+ useEffect(() => {
290
+ events.push(`Parent effect mount state=${parentState}`);
291
+ if (parentState === 0) {
292
+ setParentState(1);
293
+ }
294
+ return () => {
295
+ events.push(`Parent effect cleanup state=${parentState}`);
296
+ };
297
+ }, [parentState]);
298
+
299
+ return (
300
+ <div>
301
+ <Child parentState={parentState} />
302
+ </div>
303
+ );
304
+ }
305
+
306
+ function Child({ parentState }: { parentState: number }) {
307
+ events.push(`Child render parentState=${parentState}`);
308
+
309
+ useEffect(() => {
310
+ events.push(`Child effect mount parentState=${parentState}`);
311
+ return () => {
312
+ events.push(`Child effect cleanup parentState=${parentState}`);
313
+ };
314
+ }, [parentState]);
315
+
316
+ return <div>Child</div>;
317
+ }
318
+
319
+ render(
320
+ <StrictMode>
321
+ <Parent />
322
+ </StrictMode>,
323
+ );
324
+
325
+ // ACTUAL React behavior:
326
+ // 1. Parent double-render, child double-render
327
+ // 2. Effects mount in child-to-parent order
328
+ // 3. Unmount/remount all (strict mode)
329
+ // 4. State update causes parent double-render, child double-render
330
+ // 5. Effects update (no remount), child-to-parent order
331
+
332
+ expect(events).toEqual([
333
+ "Parent render state=0",
334
+ "Parent render state=0",
335
+ "Child render parentState=0",
336
+ "Child render parentState=0",
337
+ "Child effect mount parentState=0",
338
+ "Parent effect mount state=0",
339
+ "Parent effect cleanup state=0",
340
+ "Child effect cleanup parentState=0",
341
+ "Child effect mount parentState=0",
342
+ "Parent effect mount state=0",
343
+ "Parent render state=1",
344
+ "Parent render state=1",
345
+ "Child render parentState=1",
346
+ "Child render parentState=1",
347
+ "Child effect cleanup parentState=0",
348
+ "Parent effect cleanup state=0",
349
+ "Child effect mount parentState=1",
350
+ "Parent effect mount state=1",
351
+ ]);
352
+ });
353
+ });
354
+
355
+ describe("Test 4: Delayed mount behavior (subtree mounted after initial render)", () => {
356
+ it("should show behavior when a subtree is mounted after initial render completes", () => {
357
+ const events: string[] = [];
358
+
359
+ function Parent() {
360
+ const [showChild, setShowChild] = useState(false);
361
+ events.push(`Parent render showChild=${showChild}`);
362
+
363
+ useEffect(() => {
364
+ events.push(`Parent effect mount showChild=${showChild}`);
365
+ if (!showChild) {
366
+ setShowChild(true);
367
+ }
368
+ return () => {
369
+ events.push(`Parent effect cleanup showChild=${showChild}`);
370
+ };
371
+ }, [showChild]);
372
+
373
+ return (
374
+ <div>
375
+ Parent
376
+ {showChild && <DelayedChild />}
377
+ </div>
378
+ );
379
+ }
380
+
381
+ function DelayedChild() {
382
+ events.push("DelayedChild render");
383
+
384
+ useEffect(() => {
385
+ events.push("DelayedChild effect mount");
386
+ return () => {
387
+ events.push("DelayedChild effect cleanup");
388
+ };
389
+ }, []);
390
+
391
+ return <div>Child</div>;
392
+ }
393
+
394
+ render(
395
+ <StrictMode>
396
+ <Parent />
397
+ </StrictMode>,
398
+ );
399
+
400
+ // ACTUAL React behavior: Components added after initial render
401
+ // still get double-render and strict mode double-mount
402
+
403
+ expect(events).toEqual([
404
+ "Parent render showChild=false",
405
+ "Parent render showChild=false",
406
+ "Parent effect mount showChild=false",
407
+ "Parent effect cleanup showChild=false",
408
+ "Parent effect mount showChild=false",
409
+ "Parent render showChild=true",
410
+ "Parent render showChild=true",
411
+ "DelayedChild render",
412
+ "DelayedChild render",
413
+ "Parent effect cleanup showChild=false",
414
+ "DelayedChild effect mount",
415
+ "Parent effect mount showChild=true",
416
+ "DelayedChild effect cleanup",
417
+ "DelayedChild effect mount",
418
+ ]);
419
+ });
420
+
421
+ it("should verify that subtree mounted later still gets strict mode treatment", () => {
422
+ const events: string[] = [];
423
+
424
+ function Root() {
425
+ const [mounted, setMounted] = useState(false);
426
+ events.push(`Root render mounted=${mounted}`);
427
+
428
+ useEffect(() => {
429
+ events.push(`Root effect mount mounted=${mounted}`);
430
+ if (!mounted) {
431
+ // Mount the subtree after the first effect runs
432
+ setMounted(true);
433
+ }
434
+ return () => {
435
+ events.push(`Root effect cleanup mounted=${mounted}`);
436
+ };
437
+ }, [mounted]);
438
+
439
+ return <div>{mounted && <LateComponent />}</div>;
440
+ }
441
+
442
+ function LateComponent() {
443
+ const [state, setState] = useState("initial");
444
+ events.push(`LateComponent render state=${state}`);
445
+
446
+ useEffect(() => {
447
+ events.push(`LateComponent effect mount state=${state}`);
448
+ if (state === "initial") {
449
+ setState("updated");
450
+ }
451
+ return () => {
452
+ events.push(`LateComponent effect cleanup state=${state}`);
453
+ };
454
+ }, [state]);
455
+
456
+ return <div>{state}</div>;
457
+ }
458
+
459
+ render(
460
+ <StrictMode>
461
+ <Root />
462
+ </StrictMode>,
463
+ );
464
+
465
+ // ACTUAL React behavior: Components added after initial strict mode cycle
466
+ // still get the double-render and double-mount treatment,
467
+ // but setState only causes double-render (no remount)
468
+
469
+ expect(events).toEqual([
470
+ "Root render mounted=false",
471
+ "Root render mounted=false",
472
+ "Root effect mount mounted=false",
473
+ "Root effect cleanup mounted=false",
474
+ "Root effect mount mounted=false",
475
+ "Root render mounted=true",
476
+ "Root render mounted=true",
477
+ "LateComponent render state=initial",
478
+ "LateComponent render state=initial",
479
+ "Root effect cleanup mounted=false",
480
+ "LateComponent effect mount state=initial",
481
+ "Root effect mount mounted=true",
482
+ "LateComponent effect cleanup state=initial",
483
+ "LateComponent effect mount state=initial",
484
+ "LateComponent render state=updated",
485
+ "LateComponent render state=updated",
486
+ "LateComponent effect cleanup state=initial",
487
+ "LateComponent effect mount state=updated",
488
+ ]);
489
+ });
490
+ });
491
+
492
+ describe("Test 5: setState in effect - strict mode edge cases", () => {
493
+ it("should verify which setState is applied when effect calls setState only on first mount", () => {
494
+ const events: string[] = [];
495
+ let effectRunCount = 0;
496
+
497
+ function TestComponent() {
498
+ const [count, setCount] = useState(0);
499
+ events.push(`render count=${count}`);
500
+
501
+ // biome-ignore lint/correctness/useExhaustiveDependencies: testing strict mode behavior with intentionally incomplete deps
502
+ useEffect(() => {
503
+ effectRunCount++;
504
+ events.push(`effect mount #${effectRunCount} count=${count}`);
505
+
506
+ // Only call setState on the FIRST mount, not the remount
507
+ if (effectRunCount === 1) {
508
+ events.push(`setState(1) called in effect #${effectRunCount}`);
509
+ setCount(1);
510
+ } else {
511
+ events.push(`no setState in effect #${effectRunCount}`);
512
+ }
513
+
514
+ return () => {
515
+ events.push(`effect cleanup #${effectRunCount} count=${count}`);
516
+ };
517
+ }, []);
518
+
519
+ return <div>{count}</div>;
520
+ }
521
+
522
+ render(
523
+ <StrictMode>
524
+ <TestComponent />
525
+ </StrictMode>,
526
+ );
527
+
528
+ // KEY FINDING: React DOES apply the setState(1) from effect #1,
529
+ // even though it was called in an effect that was cleaned up!
530
+ // The state update is queued and processed after the strict mode cycle.
531
+ expect(events).toEqual([
532
+ "render count=0",
533
+ "render count=0",
534
+ "effect mount #1 count=0",
535
+ "setState(1) called in effect #1",
536
+ "effect cleanup #1 count=0",
537
+ "effect mount #2 count=0",
538
+ "no setState in effect #2",
539
+ "render count=1", // setState(1) was applied!
540
+ "render count=1",
541
+ ]);
542
+ });
543
+
544
+ it("should verify which setState is applied when both effect mounts call setState with different values", () => {
545
+ const events: string[] = [];
546
+ let effectRunCount = 0;
547
+
548
+ function TestComponent() {
549
+ const [count, setCount] = useState(0);
550
+ events.push(`render count=${count}`);
551
+
552
+ // biome-ignore lint/correctness/useExhaustiveDependencies: testing strict mode behavior with intentionally incomplete deps
553
+ useEffect(() => {
554
+ effectRunCount++;
555
+ events.push(`effect mount #${effectRunCount} count=${count}`);
556
+
557
+ if (effectRunCount === 1) {
558
+ events.push(`setState(1) called in effect #${effectRunCount}`);
559
+ setCount(1);
560
+ } else if (effectRunCount === 2) {
561
+ events.push(`setState(2) called in effect #${effectRunCount}`);
562
+ setCount(2);
563
+ }
564
+
565
+ return () => {
566
+ events.push(`effect cleanup #${effectRunCount} count=${count}`);
567
+ };
568
+ }, []);
569
+
570
+ return <div>{count}</div>;
571
+ }
572
+
573
+ render(
574
+ <StrictMode>
575
+ <TestComponent />
576
+ </StrictMode>,
577
+ );
578
+
579
+ // KEY FINDING: React applies the LAST setState (setState(2)),
580
+ // not the first one or both. The state updates are batched and
581
+ // the later one overwrites the earlier one.
582
+ expect(events).toEqual([
583
+ "render count=0",
584
+ "render count=0",
585
+ "effect mount #1 count=0",
586
+ "setState(1) called in effect #1",
587
+ "effect cleanup #1 count=0",
588
+ "effect mount #2 count=0",
589
+ "setState(2) called in effect #2",
590
+ "render count=2", // Only setState(2) was applied!
591
+ "render count=2",
592
+ ]);
593
+ });
594
+
595
+ it("should verify setState callback execution during strict mode", () => {
596
+ const events: string[] = [];
597
+ let effectRunCount = 0;
598
+
599
+ function TestComponent() {
600
+ const [count, setCount] = useState(0);
601
+ events.push(`render count=${count}`);
602
+
603
+ // biome-ignore lint/correctness/useExhaustiveDependencies: testing strict mode behavior with intentionally incomplete deps
604
+ useEffect(() => {
605
+ effectRunCount++;
606
+ events.push(`effect mount #${effectRunCount} count=${count}`);
607
+
608
+ // Use updater function to see if it's called once or twice
609
+ setCount((prev) => {
610
+ events.push(
611
+ `setState updater called with prev=${prev} in effect #${effectRunCount}`,
612
+ );
613
+ return prev + effectRunCount;
614
+ });
615
+
616
+ return () => {
617
+ events.push(`effect cleanup #${effectRunCount} count=${count}`);
618
+ };
619
+ }, []);
620
+
621
+ return <div>{count}</div>;
622
+ }
623
+
624
+ render(
625
+ <StrictMode>
626
+ <TestComponent />
627
+ </StrictMode>,
628
+ );
629
+
630
+ // KEY FINDING: Both updater functions are queued and executed!
631
+ // Effect #1: updater(0) => 0 + 1 = 1
632
+ // Effect #2: updater(0) => 0 + 2 = 2
633
+ // But then the updater from effect #2 runs TWICE MORE with prev=1
634
+ // due to strict mode doubling the updater call itself!
635
+ // Final calculation: 0 -> 1 (from effect #1) -> 3 (from effect #2: 1+2)
636
+ expect(events).toEqual([
637
+ "render count=0",
638
+ "render count=0",
639
+ "effect mount #1 count=0",
640
+ "setState updater called with prev=0 in effect #1",
641
+ "effect cleanup #1 count=0",
642
+ "effect mount #2 count=0",
643
+ "setState updater called with prev=0 in effect #2",
644
+ "setState updater called with prev=1 in effect #2", // Updater doubled!
645
+ "setState updater called with prev=1 in effect #2", // Updater doubled again!
646
+ "render count=3", // Final: 0 -> 1 -> 3
647
+ "render count=3",
648
+ ]);
649
+ });
650
+
651
+ it("should use the SECOND return value when updater is called twice in strict mode", () => {
652
+ const events: string[] = [];
653
+ let updaterCallCount = 0;
654
+
655
+ function TestComponent() {
656
+ const [count, setCount] = useState(0);
657
+ events.push(`render count=${count}`);
658
+
659
+ useEffect(() => {
660
+ events.push("effect mount");
661
+ setCount((prev) => {
662
+ updaterCallCount++;
663
+ events.push(`updater call #${updaterCallCount} with prev=${prev}`);
664
+ // Return different values on each call
665
+ if (updaterCallCount === 1) {
666
+ return 100; // First call returns 100
667
+ }
668
+ return 200; // Second call returns 200
669
+ });
670
+
671
+ return () => {
672
+ events.push("effect cleanup");
673
+ };
674
+ }, []);
675
+
676
+ return <div>{count}</div>;
677
+ }
678
+
679
+ render(
680
+ <StrictMode>
681
+ <TestComponent />
682
+ </StrictMode>,
683
+ );
684
+
685
+ // ANSWER: React calls updater 4 times and uses the LAST return value!
686
+ // Sequence:
687
+ // 1. Effect #1 mounts: updater(0) → 100
688
+ // 2. Effect #1 cleanup (strict mode)
689
+ // 3. Effect #2 mounts: updater(0) → 200
690
+ // 4. Strict mode doubles the updater: updater(100) → 200
691
+ // 5. Strict mode doubles again: updater(100) → 200
692
+ // Final value: 200 (from the last call)
693
+ expect(updaterCallCount).toBe(4);
694
+ expect(events).toEqual([
695
+ "render count=0",
696
+ "render count=0",
697
+ "effect mount",
698
+ "updater call #1 with prev=0", // Effect #1: returns 100
699
+ "effect cleanup",
700
+ "effect mount",
701
+ "updater call #2 with prev=0", // Effect #2: returns 200
702
+ "updater call #3 with prev=100", // Strict mode double: returns 200
703
+ "updater call #4 with prev=100", // Strict mode double again: returns 200
704
+ "render count=200", // Uses LAST return value
705
+ "render count=200",
706
+ ]);
707
+ });
708
+ });
709
+ });