@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,723 @@
1
+ /**
2
+ * Tests to verify when tap strict mode causes double-rendering
3
+ * These tests should mirror the React strict mode behavior
4
+ */
5
+
6
+ import { describe, it, expect } from "vitest";
7
+ import { resource } from "../../core/resource";
8
+ import { tapState } from "../../hooks/tap-state";
9
+ import { tapEffect } from "../../hooks/tap-effect";
10
+ import { createResource } from "../../core/createResource";
11
+ import { flushResourcesSync } from "../../core/scheduler";
12
+
13
+ describe("Tap Strict Mode - Rerender Sources", () => {
14
+ describe("DEBUG: Callback invocation count", () => {
15
+ it("should show how many times the dispatchUpdate callback is invoked", () => {
16
+ let updaterInvocations = 0;
17
+ const events: string[] = [];
18
+
19
+ const TestResource = resource(() => {
20
+ const [count, setCount] = tapState(0);
21
+ events.push(`render count=${count}`);
22
+
23
+ return {
24
+ count,
25
+ increment: () => {
26
+ events.push("setState called");
27
+ setCount((prevCount) => {
28
+ updaterInvocations++;
29
+ events.push(
30
+ `updater invocation #${updaterInvocations} with prevCount=${prevCount}`,
31
+ );
32
+ return prevCount + 1;
33
+ });
34
+ },
35
+ };
36
+ });
37
+
38
+ const handle = createResource(TestResource(), { devStrictMode: true });
39
+
40
+ events.length = 0;
41
+ updaterInvocations = 0;
42
+
43
+ flushResourcesSync(() => {
44
+ handle.getValue().increment();
45
+ });
46
+
47
+ console.log("Updater invocations:", updaterInvocations);
48
+ console.log("Events:", events);
49
+ console.log(
50
+ "Expected: updater called twice (React behavior), actual:",
51
+ updaterInvocations,
52
+ );
53
+ });
54
+
55
+ it.skip("should use the same return value logic as React when updater returns different values", () => {
56
+ const events: string[] = [];
57
+ let updaterCallCount = 0;
58
+
59
+ const TestResource = resource(() => {
60
+ const [count, setCount] = tapState(0);
61
+ events.push(`render count=${count}`);
62
+
63
+ tapEffect(() => {
64
+ events.push("effect mount");
65
+ setCount((prev) => {
66
+ updaterCallCount++;
67
+ events.push(`updater call #${updaterCallCount} with prev=${prev}`);
68
+ // Return different values on each call
69
+ if (updaterCallCount === 1) {
70
+ return 100; // First call returns 100
71
+ }
72
+ return 200; // Second call returns 200
73
+ });
74
+
75
+ return () => {
76
+ events.push("effect cleanup");
77
+ };
78
+ }, []);
79
+
80
+ return { count };
81
+ });
82
+
83
+ createResource(TestResource(), {
84
+ devStrictMode: true,
85
+ mount: true,
86
+ });
87
+
88
+ console.log("Tap updater call count:", updaterCallCount);
89
+ console.log("Tap events:", events);
90
+
91
+ // React behavior: updater called 4 times, uses LAST return value (200)
92
+ // Expected tap behavior: Same as React
93
+ expect(updaterCallCount).toBe(4);
94
+ expect(events).toEqual([
95
+ "render count=0",
96
+ "render count=0",
97
+ "effect mount",
98
+ "updater call #1 with prev=0", // Effect #1: returns 100
99
+ "effect cleanup",
100
+ "effect mount",
101
+ "updater call #2 with prev=0", // Effect #2: returns 200
102
+ "updater call #3 with prev=100", // Strict mode double: returns 200
103
+ "updater call #4 with prev=100", // Strict mode double again: returns 200
104
+ "render count=200", // Uses LAST return value
105
+ "render count=200",
106
+ ]);
107
+ });
108
+ });
109
+
110
+ describe("Source 1: Initial render", () => {
111
+ it("should double-render on initial mount", () => {
112
+ const events: string[] = [];
113
+
114
+ const TestResource = resource(() => {
115
+ const [count] = tapState(0);
116
+ events.push(`render count=${count}`);
117
+ return { count };
118
+ });
119
+
120
+ createResource(TestResource(), { devStrictMode: true });
121
+
122
+ expect(events).toEqual(["render count=0", "render count=0"]);
123
+ });
124
+ });
125
+
126
+ describe("Source 2: setState in tapEffect", () => {
127
+ it("should double-render after setState in tapEffect", () => {
128
+ const events: string[] = [];
129
+
130
+ const TestResource = resource(() => {
131
+ const [count, setCount] = tapState(0);
132
+ events.push(`render count=${count}`);
133
+
134
+ tapEffect(() => {
135
+ events.push(`effect count=${count}`);
136
+ if (count === 0) {
137
+ setCount(1);
138
+ }
139
+ return () => {
140
+ events.push(`cleanup count=${count}`);
141
+ };
142
+ }, [count]);
143
+
144
+ return { count };
145
+ });
146
+
147
+ createResource(TestResource(), { devStrictMode: true, mount: true });
148
+
149
+ expect(events).toEqual([
150
+ "render count=0",
151
+ "render count=0",
152
+ "effect count=0",
153
+ "cleanup count=0",
154
+ "effect count=0",
155
+ "render count=1",
156
+ "render count=1",
157
+ "cleanup count=0",
158
+ "effect count=1",
159
+ ]);
160
+ });
161
+ });
162
+
163
+ describe("Source 3: setState in flushResourcesSync (event handler analogue)", () => {
164
+ it("should ALSO double-render after setState in flushResourcesSync", () => {
165
+ const events: string[] = [];
166
+
167
+ const TestResource = resource(() => {
168
+ const [count, setCount] = tapState(0);
169
+ events.push(`render count=${count}`);
170
+
171
+ return {
172
+ count,
173
+ increment: () => {
174
+ events.push("increment");
175
+ setCount(count + 1);
176
+ },
177
+ };
178
+ });
179
+
180
+ const handle = createResource(TestResource(), { devStrictMode: true });
181
+
182
+ // Initial render is double
183
+ expect(events).toEqual(["render count=0", "render count=0"]);
184
+
185
+ events.length = 0; // Clear events
186
+
187
+ // Call the method inside flushResourcesSync (like clicking a button)
188
+ flushResourcesSync(() => {
189
+ handle.getValue().increment();
190
+ });
191
+
192
+ // flushResourcesSync setState should ALSO double-render (matching React 19)
193
+ expect(events).toEqual(["increment", "render count=1", "render count=1"]);
194
+ });
195
+
196
+ it("should double-render on ALL flushResourcesSync calls", () => {
197
+ const events: string[] = [];
198
+
199
+ const TestResource = resource(() => {
200
+ const [count, setCount] = tapState(0);
201
+ events.push(`render count=${count}`);
202
+
203
+ return {
204
+ count,
205
+ increment: () => {
206
+ events.push("increment");
207
+ setCount((c) => c + 1);
208
+ },
209
+ };
210
+ });
211
+
212
+ const handle = createResource(TestResource(), { devStrictMode: true });
213
+
214
+ events.length = 0; // Clear initial renders
215
+
216
+ // Multiple flushResourcesSync calls (like multiple button clicks)
217
+ flushResourcesSync(() => {
218
+ handle.getValue().increment();
219
+ });
220
+ flushResourcesSync(() => {
221
+ handle.getValue().increment();
222
+ });
223
+ flushResourcesSync(() => {
224
+ handle.getValue().increment();
225
+ });
226
+
227
+ // Each call should cause double render
228
+ expect(events).toEqual([
229
+ "increment",
230
+ "render count=1",
231
+ "render count=1",
232
+ "increment",
233
+ "render count=2",
234
+ "render count=2",
235
+ "increment",
236
+ "render count=3",
237
+ "render count=3",
238
+ ]);
239
+ });
240
+ });
241
+
242
+ describe("Source 4: setState in setTimeout", () => {
243
+ it.skip("should double-render AND double-call setTimeout callback", async () => {
244
+ const events: string[] = [];
245
+
246
+ const TestResource = resource(() => {
247
+ const [count, setCount] = tapState(0);
248
+ events.push(`render count=${count}`);
249
+
250
+ tapEffect(() => {
251
+ if (count === 0) {
252
+ setTimeout(() => {
253
+ events.push("setTimeout");
254
+ setCount(1);
255
+ }, 10);
256
+ }
257
+ }, [count]);
258
+
259
+ return { count };
260
+ });
261
+
262
+ createResource(TestResource(), { devStrictMode: true, mount: true });
263
+
264
+ // Wait for setTimeout
265
+ await new Promise((resolve) => setTimeout(resolve, 50));
266
+
267
+ // React behavior: setTimeout callbacks run TWICE, then renders double
268
+ expect(events).toEqual([
269
+ "render count=0",
270
+ "render count=0",
271
+ "setTimeout",
272
+ "setTimeout",
273
+ "render count=1",
274
+ "render count=1",
275
+ ]);
276
+ });
277
+ });
278
+
279
+ describe("Source 5: setState in Promise/async", () => {
280
+ it("should double-render AND double-call Promise callback", async () => {
281
+ const events: string[] = [];
282
+
283
+ const TestResource = resource(() => {
284
+ const [count, setCount] = tapState(0);
285
+ events.push(`render count=${count}`);
286
+
287
+ tapEffect(() => {
288
+ if (count === 0) {
289
+ Promise.resolve().then(() => {
290
+ events.push("promise");
291
+ setCount(1);
292
+ });
293
+ }
294
+ }, [count]);
295
+
296
+ return { count };
297
+ });
298
+
299
+ createResource(TestResource(), { devStrictMode: true, mount: true });
300
+
301
+ // Wait for promise
302
+ await new Promise((resolve) => setTimeout(resolve, 10));
303
+
304
+ // Promise callback should run TWICE and renders should be DOUBLED
305
+ expect(events).toEqual([
306
+ "render count=0",
307
+ "render count=0",
308
+ "promise",
309
+ "promise",
310
+ "render count=1",
311
+ "render count=1",
312
+ ]);
313
+ });
314
+ });
315
+
316
+ describe("Source 6: Multiple setState calls", () => {
317
+ it("should batch multiple setState calls in flushResourcesSync (single double-render)", () => {
318
+ const events: string[] = [];
319
+
320
+ const TestResource = resource(() => {
321
+ const [count1, setCount1] = tapState(0);
322
+ const [count2, setCount2] = tapState(0);
323
+ events.push(`render count1=${count1} count2=${count2}`);
324
+
325
+ return {
326
+ updateBoth: () => {
327
+ events.push("updateBoth");
328
+ setCount1(1);
329
+ setCount2(2);
330
+ },
331
+ };
332
+ });
333
+
334
+ const handle = createResource(TestResource(), { devStrictMode: true });
335
+
336
+ events.length = 0; // Clear initial renders
337
+
338
+ flushResourcesSync(() => {
339
+ handle.getValue().updateBoth();
340
+ });
341
+
342
+ // Both setState calls batched, but render is DOUBLED
343
+ expect(events).toEqual([
344
+ "updateBoth",
345
+ "render count1=1 count2=2",
346
+ "render count1=1 count2=2",
347
+ ]);
348
+ });
349
+
350
+ it("should batch multiple setState calls in tapEffect (single double-render)", () => {
351
+ const events: string[] = [];
352
+
353
+ const TestResource = resource(() => {
354
+ const [count1, setCount1] = tapState(0);
355
+ const [count2, setCount2] = tapState(0);
356
+ events.push(`render count1=${count1} count2=${count2}`);
357
+
358
+ tapEffect(() => {
359
+ if (count1 === 0 && count2 === 0) {
360
+ setCount1(1);
361
+ setCount2(2);
362
+ }
363
+ }, [count1, count2]);
364
+
365
+ return {};
366
+ });
367
+
368
+ createResource(TestResource(), { devStrictMode: true, mount: true });
369
+
370
+ // Initial double-render, then batched setState causes another double-render
371
+ expect(events).toEqual([
372
+ "render count1=0 count2=0",
373
+ "render count1=0 count2=0",
374
+ "render count1=1 count2=2",
375
+ "render count1=1 count2=2",
376
+ ]);
377
+ });
378
+ });
379
+
380
+ describe("Source 7: Simple resource double-render", () => {
381
+ it("should double-render simple resources", () => {
382
+ const events: string[] = [];
383
+
384
+ const TestResource = resource(() => {
385
+ const [count, setCount] = tapState(0);
386
+ events.push(`render count=${count}`);
387
+
388
+ return {
389
+ count,
390
+ increment: () => setCount((c) => c + 1),
391
+ };
392
+ });
393
+
394
+ createResource(TestResource(), { devStrictMode: true });
395
+
396
+ // Resource renders should be doubled
397
+ expect(events).toEqual(["render count=0", "render count=0"]);
398
+ });
399
+ });
400
+
401
+ describe("Source 8: setState with function updater", () => {
402
+ it.skip("should double-render with function updater in flushResourcesSync", () => {
403
+ const events: string[] = [];
404
+
405
+ const TestResource = resource(() => {
406
+ const [count, setCount] = tapState(0);
407
+ events.push(`render count=${count}`);
408
+
409
+ return {
410
+ count,
411
+ increment: () => {
412
+ events.push("increment");
413
+ setCount((prevCount) => {
414
+ events.push(`updater prevCount=${prevCount}`);
415
+ return prevCount + 1;
416
+ });
417
+ },
418
+ };
419
+ });
420
+
421
+ const handle = createResource(TestResource(), { devStrictMode: true });
422
+
423
+ events.length = 0; // Clear initial renders
424
+
425
+ flushResourcesSync(() => {
426
+ handle.getValue().increment();
427
+ });
428
+
429
+ // React behavior: Updater function is called TWICE in strict mode
430
+ expect(events).toEqual([
431
+ "increment",
432
+ "updater prevCount=0",
433
+ "updater prevCount=0",
434
+ "render count=1",
435
+ "render count=1",
436
+ ]);
437
+ });
438
+ });
439
+
440
+ describe("Source 9: Complex effect patterns", () => {
441
+ it.skip("should handle effect with dependencies and setState", () => {
442
+ const events: string[] = [];
443
+
444
+ const TestResource = resource(() => {
445
+ const [count, setCount] = tapState(0);
446
+ const [doubled, setDoubled] = tapState(0);
447
+ events.push(`render count=${count} doubled=${doubled}`);
448
+
449
+ tapEffect(() => {
450
+ events.push(`effect count=${count}`);
451
+ setDoubled(count * 2);
452
+ return () => {
453
+ events.push(`cleanup count=${count}`);
454
+ };
455
+ }, [count]);
456
+
457
+ return {
458
+ count,
459
+ increment: () => setCount((c) => c + 1),
460
+ };
461
+ });
462
+
463
+ const handle = createResource(TestResource(), {
464
+ devStrictMode: true,
465
+ mount: true,
466
+ });
467
+
468
+ // React behavior: When effect calls setState during strict mode,
469
+ // it triggers additional render cycles
470
+ expect(events).toEqual([
471
+ "render count=0 doubled=0",
472
+ "render count=0 doubled=0",
473
+ "effect count=0",
474
+ "cleanup count=0",
475
+ "effect count=0",
476
+ "render count=0 doubled=0",
477
+ "render count=0 doubled=0",
478
+ "cleanup count=0",
479
+ "effect count=0",
480
+ ]);
481
+
482
+ events.length = 0;
483
+
484
+ // Trigger increment via flushResourcesSync
485
+ flushResourcesSync(() => {
486
+ handle.getValue().increment();
487
+ });
488
+
489
+ // Should double-render with new count, effect updates doubled
490
+ expect(events).toEqual([
491
+ "render count=1 doubled=0",
492
+ "render count=1 doubled=0",
493
+ "cleanup count=0",
494
+ "effect count=1",
495
+ "render count=1 doubled=2",
496
+ "render count=1 doubled=2",
497
+ "cleanup count=1",
498
+ "effect count=1",
499
+ ]);
500
+ });
501
+ });
502
+
503
+ describe("Source 10: tapState initializer function", () => {
504
+ it("should call tapState initializer twice", () => {
505
+ const events: string[] = [];
506
+ let initCount = 0;
507
+
508
+ const TestResource = resource(() => {
509
+ const [value] = tapState(() => {
510
+ initCount++;
511
+ events.push(`init call #${initCount}`);
512
+ return initCount;
513
+ });
514
+
515
+ events.push(`render value=${value}`);
516
+
517
+ return { value };
518
+ });
519
+
520
+ createResource(TestResource(), { devStrictMode: true });
521
+
522
+ // tapState initializer should be called twice, first value kept
523
+ expect(events).toEqual([
524
+ "init call #1",
525
+ "init call #2",
526
+ "render value=1",
527
+ "render value=1",
528
+ ]);
529
+ });
530
+ });
531
+
532
+ describe("Source 11: Resource disposal and recreation", () => {
533
+ it("should maintain double-render behavior after disposal and recreation", () => {
534
+ const events: string[] = [];
535
+
536
+ const TestResource = resource(() => {
537
+ const [count, setCount] = tapState(0);
538
+ events.push(`render count=${count}`);
539
+
540
+ return {
541
+ count,
542
+ increment: () => setCount((c) => c + 1),
543
+ };
544
+ });
545
+
546
+ // Create first instance
547
+ const handle1 = createResource(TestResource(), { devStrictMode: true });
548
+
549
+ expect(events).toEqual(["render count=0", "render count=0"]);
550
+
551
+ events.length = 0;
552
+
553
+ // Unmount
554
+ handle1.unmount();
555
+
556
+ // Create second instance
557
+ const handle2 = createResource(TestResource(), { devStrictMode: true });
558
+
559
+ // Should still double-render
560
+ expect(events).toEqual(["render count=0", "render count=0"]);
561
+
562
+ events.length = 0;
563
+
564
+ // Method calls via flushResourcesSync should still double-render
565
+ flushResourcesSync(() => {
566
+ handle2.getValue().increment();
567
+ });
568
+
569
+ expect(events).toEqual(["render count=1", "render count=1"]);
570
+ });
571
+ });
572
+
573
+ describe("Source 12: setState in effect edge cases", () => {
574
+ it("should apply setState from first effect mount even when second mount doesn't call setState", () => {
575
+ const events: string[] = [];
576
+ let effectRunCount = 0;
577
+
578
+ const TestResource = resource(() => {
579
+ const [count, setCount] = tapState(0);
580
+ events.push(`render count=${count}`);
581
+
582
+ // biome-ignore lint/correctness/useExhaustiveDependencies: testing strict mode behavior with intentionally incomplete deps
583
+ tapEffect(() => {
584
+ effectRunCount++;
585
+ events.push(`effect mount #${effectRunCount} count=${count}`);
586
+
587
+ // Only call setState on first mount
588
+ if (effectRunCount === 1) {
589
+ events.push(`setState(1) called in effect #${effectRunCount}`);
590
+ setCount(1);
591
+ } else {
592
+ events.push(`no setState in effect #${effectRunCount}`);
593
+ }
594
+
595
+ return () => {
596
+ events.push(`effect cleanup #${effectRunCount} count=${count}`);
597
+ };
598
+ }, []);
599
+
600
+ return { count };
601
+ });
602
+
603
+ createResource(TestResource(), {
604
+ devStrictMode: true,
605
+ mount: true,
606
+ });
607
+
608
+ // Expected: setState(1) from effect #1 should be applied
609
+ // even though effect #1 was cleaned up
610
+ expect(events).toEqual([
611
+ "render count=0",
612
+ "render count=0",
613
+ "effect mount #1 count=0",
614
+ "setState(1) called in effect #1",
615
+ "effect cleanup #1 count=0",
616
+ "effect mount #2 count=0",
617
+ "no setState in effect #2",
618
+ "render count=1", // setState(1) applied!
619
+ "render count=1",
620
+ ]);
621
+ });
622
+
623
+ it("should apply last setState when both effect mounts call setState with different values", () => {
624
+ const events: string[] = [];
625
+ let effectRunCount = 0;
626
+
627
+ const TestResource = resource(() => {
628
+ const [count, setCount] = tapState(0);
629
+ events.push(`render count=${count}`);
630
+
631
+ // biome-ignore lint/correctness/useExhaustiveDependencies: testing strict mode behavior with intentionally incomplete deps
632
+ tapEffect(() => {
633
+ effectRunCount++;
634
+ events.push(`effect mount #${effectRunCount} count=${count}`);
635
+
636
+ if (effectRunCount === 1) {
637
+ events.push(`setState(1) called in effect #${effectRunCount}`);
638
+ setCount(1);
639
+ } else if (effectRunCount === 2) {
640
+ events.push(`setState(2) called in effect #${effectRunCount}`);
641
+ setCount(2);
642
+ }
643
+
644
+ return () => {
645
+ events.push(`effect cleanup #${effectRunCount} count=${count}`);
646
+ };
647
+ }, []);
648
+
649
+ return { count };
650
+ });
651
+
652
+ createResource(TestResource(), {
653
+ devStrictMode: true,
654
+ mount: true,
655
+ });
656
+
657
+ // Expected: Only setState(2) should be applied (last one wins)
658
+ expect(events).toEqual([
659
+ "render count=0",
660
+ "render count=0",
661
+ "effect mount #1 count=0",
662
+ "setState(1) called in effect #1",
663
+ "effect cleanup #1 count=0",
664
+ "effect mount #2 count=0",
665
+ "setState(2) called in effect #2",
666
+ "render count=2", // Only setState(2) applied!
667
+ "render count=2",
668
+ ]);
669
+ });
670
+
671
+ it.skip("should handle updater functions from both effect mounts", () => {
672
+ const events: string[] = [];
673
+ let effectRunCount = 0;
674
+
675
+ const TestResource = resource(() => {
676
+ const [count, setCount] = tapState(0);
677
+ events.push(`render count=${count}`);
678
+
679
+ // biome-ignore lint/correctness/useExhaustiveDependencies: testing strict mode behavior with intentionally incomplete deps
680
+ tapEffect(() => {
681
+ effectRunCount++;
682
+ events.push(`effect mount #${effectRunCount} count=${count}`);
683
+
684
+ setCount((prev) => {
685
+ events.push(
686
+ `setState updater called with prev=${prev} in effect #${effectRunCount}`,
687
+ );
688
+ return prev + effectRunCount;
689
+ });
690
+
691
+ return () => {
692
+ events.push(`effect cleanup #${effectRunCount} count=${count}`);
693
+ };
694
+ }, []);
695
+
696
+ return { count };
697
+ });
698
+
699
+ createResource(TestResource(), {
700
+ devStrictMode: true,
701
+ mount: true,
702
+ });
703
+
704
+ // React behavior: Both updaters are queued and executed
705
+ // Effect #1: updater(0) => 0 + 1 = 1
706
+ // Effect #2: updater(0) => 0 + 2 = 2, but then runs TWICE more with prev=1
707
+ // Final: 3
708
+ expect(events).toEqual([
709
+ "render count=0",
710
+ "render count=0",
711
+ "effect mount #1 count=0",
712
+ "setState updater called with prev=0 in effect #1",
713
+ "effect cleanup #1 count=0",
714
+ "effect mount #2 count=0",
715
+ "setState updater called with prev=0 in effect #2",
716
+ "setState updater called with prev=1 in effect #2",
717
+ "setState updater called with prev=1 in effect #2",
718
+ "render count=3",
719
+ "render count=3",
720
+ ]);
721
+ });
722
+ });
723
+ });