@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
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Baseline parity scenarios: mount/render counts, strict-mode ghost
3
+ * invocations, memo caching, effect lifecycles, setState batching, async
4
+ * updates, unmount. Runs in dev and prod via the vitest projects.
5
+ */
6
+ /* oxlint-disable react/exhaustive-deps -- intentional missing-dep patterns are part of the scenarios */
7
+ import { useEffect, useMemo, useReducer, useRef, useState } from "react";
8
+ import { describeParity, type Scenario } from "./describeParity";
9
+
10
+ const scenarios: Scenario[] = [
11
+ {
12
+ name: "mount: render count, useState initializer ghost-invoked, first result kept",
13
+ use: (log) => {
14
+ const [a] = useState(() => {
15
+ log("init-a");
16
+ return 1;
17
+ });
18
+ const [b] = useState(() => {
19
+ log("init-b");
20
+ return 2;
21
+ });
22
+ log(`render a=${a} b=${b}`);
23
+ },
24
+ },
25
+ {
26
+ name: "useMemo: ghost-invoked when computing, cached across passes and re-renders",
27
+ use: (log) => {
28
+ const [n, setN] = useState(0);
29
+ useMemo(() => {
30
+ log(`memo n=${n}`);
31
+ return n;
32
+ }, [n]);
33
+ log(`render n=${n}`);
34
+ useEffect(() => {
35
+ if (n === 0) setN(1);
36
+ }, [n]);
37
+ },
38
+ },
39
+ {
40
+ name: "useMemo: first returned instance is the one kept",
41
+ use: (log) => {
42
+ let instance = 0;
43
+ const obj = useMemo(() => ({ instance: ++instance }), []);
44
+ const first = useRef(obj);
45
+ log(`instance=${obj.instance} identity-stable=${first.current === obj}`);
46
+ },
47
+ },
48
+ {
49
+ name: "useReducer: initializer ghost-invoked, first result kept",
50
+ use: (log) => {
51
+ let initCount = 0;
52
+ const [state] = useReducer(
53
+ (s: number, a: number) => s + a,
54
+ 0,
55
+ (arg: number) => {
56
+ initCount++;
57
+ log(`init-${initCount}`);
58
+ return arg + initCount * 10;
59
+ },
60
+ );
61
+ log(`render state=${state}`);
62
+ },
63
+ },
64
+ {
65
+ name: "useReducer: dispatch reducer ghost-invoked",
66
+ use: (log) => {
67
+ const countRef = useRef(0);
68
+ const [state, dispatch] = useReducer((s: number, _a: number) => {
69
+ countRef.current++;
70
+ const result = countRef.current * 100;
71
+ log(`reducer-${countRef.current} state=${s} -> ${result}`);
72
+ return result;
73
+ }, 0);
74
+ log(`render state=${state}`);
75
+ return { dispatch };
76
+ },
77
+ drive: async ({ api, act }) => {
78
+ await act(() => api().dispatch(1));
79
+ },
80
+ },
81
+ {
82
+ name: "effects cycle mount, strict remount, deps",
83
+ use: (log) => {
84
+ const [n] = useState(0);
85
+ useEffect(() => {
86
+ log("e1-mount");
87
+ return () => log("e1-unmount");
88
+ });
89
+ useEffect(() => {
90
+ log("e2-mount");
91
+ return () => log("e2-unmount");
92
+ }, []);
93
+ useEffect(() => {
94
+ log(`e3-mount n=${n}`);
95
+ return () => log(`e3-unmount n=${n}`);
96
+ }, [n]);
97
+ },
98
+ },
99
+ {
100
+ name: "setState in effect",
101
+ use: (log) => {
102
+ const [count, setCount] = useState(0);
103
+ log(`render ${count}`);
104
+ useEffect(() => {
105
+ log(`effect ${count}`);
106
+ if (count === 0) setCount(1);
107
+ return () => log(`cleanup ${count}`);
108
+ }, [count]);
109
+ },
110
+ },
111
+ {
112
+ name: "event-handler setState: single re-render, updater ghost-invoked",
113
+ use: (log) => {
114
+ const [count, setCount] = useState(0);
115
+ log(`render ${count}`);
116
+ return {
117
+ increment: () =>
118
+ setCount((prev) => {
119
+ log(`updater prev=${prev}`);
120
+ return prev + 1;
121
+ }),
122
+ };
123
+ },
124
+ drive: async ({ api, act }) => {
125
+ await act(() => api().increment());
126
+ },
127
+ },
128
+ {
129
+ name: "event-handler setState: multiple setStates batch into one render",
130
+ use: (log) => {
131
+ const [a, setA] = useState(0);
132
+ const [b, setB] = useState(0);
133
+ log(`render a=${a} b=${b}`);
134
+ return {
135
+ both: () => {
136
+ setA(1);
137
+ setB(2);
138
+ },
139
+ };
140
+ },
141
+ drive: async ({ api, act }) => {
142
+ await act(() => api().both());
143
+ },
144
+ },
145
+ {
146
+ name: "async setState from a promise scheduled in an effect",
147
+ use: (log) => {
148
+ const [count, setCount] = useState(0);
149
+ log(`render ${count}`);
150
+ useEffect(() => {
151
+ if (count === 0) {
152
+ void Promise.resolve().then(() => {
153
+ log("promise");
154
+ setCount(1);
155
+ });
156
+ }
157
+ }, [count]);
158
+ },
159
+ drive: ({ settle }) => settle(),
160
+ },
161
+ {
162
+ name: "async setState from a setTimeout scheduled in an effect",
163
+ use: (log) => {
164
+ const [count, setCount] = useState(0);
165
+ log(`render ${count}`);
166
+ useEffect(() => {
167
+ if (count === 0) {
168
+ // The cleanup matters: without it the strict double effect-mount
169
+ // schedules two timers, and whether their dispatches batch into one
170
+ // render is a race between the timer phase and the schedulers.
171
+ const timer = setTimeout(() => {
172
+ log("timeout");
173
+ setCount(1);
174
+ }, 5);
175
+ return () => clearTimeout(timer);
176
+ }
177
+ return undefined;
178
+ }, [count]);
179
+ },
180
+ drive: ({ settle }) => settle(),
181
+ },
182
+ {
183
+ name: "setState from the first strict effect mount survives its cleanup",
184
+ use: (log) => {
185
+ const [count, setCount] = useState(0);
186
+ const runs = useRef(0);
187
+ log(`render ${count}`);
188
+ useEffect(() => {
189
+ runs.current++;
190
+ const n = runs.current;
191
+ log(`mount#${n} count=${count}`);
192
+ if (n === 1) setCount(1);
193
+ return () => log(`cleanup#${n}`);
194
+ }, []);
195
+ },
196
+ },
197
+ {
198
+ name: "setState from both strict effect mounts: last value wins",
199
+ use: (log) => {
200
+ const [count, setCount] = useState(0);
201
+ const runs = useRef(0);
202
+ log(`render ${count}`);
203
+ useEffect(() => {
204
+ runs.current++;
205
+ const n = runs.current;
206
+ log(`mount#${n} count=${count}`);
207
+ setCount(n === 1 ? 1 : 2);
208
+ return () => log(`cleanup#${n}`);
209
+ }, []);
210
+ },
211
+ },
212
+ {
213
+ name: "updater setState from both strict effect mounts chains",
214
+ use: (log) => {
215
+ const [count, setCount] = useState(0);
216
+ const runs = useRef(0);
217
+ log(`render ${count}`);
218
+ useEffect(() => {
219
+ runs.current++;
220
+ const n = runs.current;
221
+ setCount((prev) => {
222
+ log(`updater#${n} prev=${prev}`);
223
+ return prev + n;
224
+ });
225
+ }, []);
226
+ },
227
+ },
228
+ {
229
+ name: "useReducer: dispatching the same state",
230
+ use: (log) => {
231
+ const [state, dispatch] = useReducer((s: number) => s, 42);
232
+ log(`render ${state}`);
233
+ return { dispatch };
234
+ },
235
+ drive: async ({ api, act }) => {
236
+ await act(() => api().dispatch(0));
237
+ },
238
+ },
239
+ {
240
+ name: "updater returning a different value per invocation",
241
+ divergence: { bridge: "multiset" },
242
+ use: (log) => {
243
+ const [count, setCount] = useState(0);
244
+ const calls = useRef(0);
245
+ log(`render ${count}`);
246
+ useEffect(() => {
247
+ log("effect mount");
248
+ setCount((prev) => {
249
+ calls.current++;
250
+ log(`updater call #${calls.current} with prev=${prev}`);
251
+ return calls.current === 1 ? 100 : 200;
252
+ });
253
+ return () => log("effect cleanup");
254
+ }, []);
255
+ },
256
+ },
257
+ {
258
+ name: "render-phase update: setState during render re-renders before committing",
259
+ use: (log) => {
260
+ const [count, setCount] = useState(0);
261
+ log(`render ${count}`);
262
+ if (count === 0) setCount(1);
263
+ },
264
+ },
265
+ {
266
+ name: "unmount runs cleanups",
267
+ use: (log) => {
268
+ useEffect(() => {
269
+ log("mount-1");
270
+ return () => log("unmount-1");
271
+ }, []);
272
+ useEffect(() => {
273
+ log("mount-2");
274
+ return () => log("unmount-2");
275
+ }, []);
276
+ },
277
+ unmountAtEnd: true,
278
+ },
279
+ ];
280
+
281
+ describeParity(scenarios);
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The remaining (deliberate or structural) divergences between React and
3
+ * tap; see DIVERGENCES.md for the full rationale. Each test pins the CURRENT
4
+ * behavior of BOTH sides so a change in either direction is caught:
5
+ *
6
+ * - tap's eager dispatch bailout is more aggressive than React's: no stale
7
+ * lanes, so a same-value dispatch right after an update skips the render
8
+ * React still does, and no-change renders commit no-deps effects.
9
+ * - Bridge dispatches ride the host's React reducer: a fully bailable
10
+ * dispatch renders the host once where React and tap roots render nothing.
11
+ * - useLayoutEffect collapses onto useEffect (no layout phase).
12
+ * - Dispatch after unmount applies, like an Activity hide: tap cannot
13
+ * distinguish a hide from a deletion. Do NOT add an isMounted guard.
14
+ */
15
+ /* oxlint-disable react/exhaustive-deps -- intentional missing-dep patterns are part of the scenarios */
16
+ import { describe, it, expect } from "vitest";
17
+ import { useEffect, useLayoutEffect, useReducer, useState } from "react";
18
+ import {
19
+ isDevMode,
20
+ runScenario,
21
+ TAP_ENVS,
22
+ type Scenario,
23
+ } from "./describeParity";
24
+
25
+ const countOf = (events: string[], entry: string) =>
26
+ events.filter((e) => e === entry).length;
27
+
28
+ /** Body invocations per committed render pass (dev double-invokes). */
29
+ const perRender = isDevMode ? 2 : 1;
30
+
31
+ describe("divergence: eager dispatch bailout is more aggressive than React's", () => {
32
+ it("same-value dispatch right after an update: React renders once more; tap roots bail", async () => {
33
+ // React's eager bailout needs idle lanes on the fiber AND its alternate;
34
+ // a committed update leaves the alternate's lanes stale, so the next
35
+ // same-value dispatch still renders (a bailout render that clears them).
36
+ // tap bails on Object.is equality whenever its batch is empty: strictly
37
+ // fewer renders, identical state. Matching React exactly takes a lanes
38
+ // emulation (dirty bit + bailout-render detection + effect suppression +
39
+ // args guard); it was implemented and reverted as not worth the hot-path
40
+ // complexity, since only impure updaters and render bodies can observe
41
+ // the difference.
42
+ const scenario: Scenario = {
43
+ name: "",
44
+ use: (log) => {
45
+ const [count, setCount] = useState(0);
46
+ log(`render ${count}`);
47
+ return { set: (n: number) => setCount(n) };
48
+ },
49
+ drive: async ({ api, act }) => {
50
+ await act(() => api().set(1));
51
+ await act(() => api().set(1));
52
+ },
53
+ };
54
+
55
+ const react = await runScenario("react", scenario);
56
+ expect(countOf(react, "render 1")).toBe(2 * perRender);
57
+
58
+ for (const env of ["tapRoot", "createTapRoot"] as const) {
59
+ const tap = await runScenario(env, scenario);
60
+ expect(countOf(tap, "render 1")).toBe(perRender);
61
+ }
62
+
63
+ const bridge = await runScenario("bridge", scenario);
64
+ expect(bridge).toEqual(react);
65
+ });
66
+
67
+ it("no-change render: React strips no-deps effects (bailoutHooks); tap commits them", async () => {
68
+ // Consistent with tap's whole-tree re-renders, which refire no-deps
69
+ // effects on every update anyway.
70
+ const scenario: Scenario = {
71
+ name: "",
72
+ use: (log) => {
73
+ const [state, dispatch] = useReducer((s: number) => s, 42);
74
+ log(`render ${state}`);
75
+ useEffect(() => {
76
+ log("effect");
77
+ return () => log("cleanup");
78
+ });
79
+ return { dispatch };
80
+ },
81
+ drive: async ({ api, act }) => {
82
+ await act(() => api().dispatch(0));
83
+ },
84
+ };
85
+
86
+ const react = await runScenario("react", scenario);
87
+
88
+ for (const env of TAP_ENVS) {
89
+ const tap = await runScenario(env, scenario);
90
+ expect(countOf(tap, "render 42")).toBe(countOf(react, "render 42"));
91
+ expect(countOf(tap, "effect")).toBe(countOf(react, "effect") + 1);
92
+ expect(countOf(tap, "cleanup")).toBe(countOf(react, "cleanup") + 1);
93
+ }
94
+ });
95
+ });
96
+
97
+ describe("divergence: fully-bailable dispatch renders the bridge host once", () => {
98
+ const scenario: Scenario = {
99
+ name: "",
100
+ use: (log) => {
101
+ const [count, setCount] = useState(0);
102
+ log(`render ${count}`);
103
+ return { set: (n: number) => setCount(n) };
104
+ },
105
+ drive: async ({ api, act }) => {
106
+ await act(() => api().set(0));
107
+ },
108
+ };
109
+
110
+ it("React and tap roots bail without rendering; the bridge renders once", async () => {
111
+ const react = await runScenario("react", scenario);
112
+ expect(countOf(react, "render 0")).toBe(perRender);
113
+
114
+ for (const env of ["tapRoot", "createTapRoot"] as const) {
115
+ const tap = await runScenario(env, scenario);
116
+ expect(tap).toEqual(react);
117
+ }
118
+
119
+ const bridge = await runScenario("bridge", scenario);
120
+ expect(countOf(bridge, "render 0")).toBe(2 * perRender);
121
+ });
122
+ });
123
+
124
+ describe("divergence: dispatch from an unmount cleanup applies (Activity semantics)", () => {
125
+ const scenario: Scenario = {
126
+ name: "",
127
+ use: (log) => {
128
+ const [count, setCount] = useState(0);
129
+ log(`render ${count}`);
130
+ useEffect(() => {
131
+ log(`mount ${count}`);
132
+ return () => {
133
+ log(`cleanup ${count}`);
134
+ setCount(99);
135
+ };
136
+ }, []);
137
+ },
138
+ unmountAtEnd: true,
139
+ };
140
+
141
+ it("React drops it (deletion); tap roots apply it like an Activity hide", async () => {
142
+ const react = await runScenario("react", scenario);
143
+ const bridge = await runScenario("bridge", scenario);
144
+ expect(bridge).toEqual(react);
145
+
146
+ if (isDevMode) {
147
+ // In dev the strict remount cycle runs the cleanup while still mounted,
148
+ // so even React renders 99 during mount.
149
+ expect(react).toContain("render 99");
150
+ } else {
151
+ expect(react).not.toContain("render 99");
152
+ }
153
+
154
+ for (const env of ["tapRoot", "createTapRoot"] as const) {
155
+ const tap = await runScenario(env, scenario);
156
+ if (isDevMode) {
157
+ // The strict remount cycle already set 99 while mounted; the unmount
158
+ // dispatch then bails eagerly on equality, masking the divergence.
159
+ expect(tap).toEqual(react);
160
+ } else {
161
+ // tap cannot distinguish this deletion from an Activity-style hide,
162
+ // so the update applies and the (pure) render runs; effects stay off.
163
+ expect(tap).toEqual([...react, "render 99"]);
164
+ }
165
+ }
166
+ });
167
+ });
168
+
169
+ describe("divergence: useLayoutEffect is an alias for useEffect", () => {
170
+ const scenario: Scenario = {
171
+ name: "",
172
+ use: (log) => {
173
+ const [n, setN] = useState(0);
174
+ useEffect(() => {
175
+ log(`passive n=${n}`);
176
+ return () => log(`passive-cleanup n=${n}`);
177
+ }, [n]);
178
+ useLayoutEffect(() => {
179
+ log(`layout n=${n}`);
180
+ return () => log(`layout-cleanup n=${n}`);
181
+ }, [n]);
182
+ return { bump: () => setN((c) => c + 1) };
183
+ },
184
+ drive: async ({ api, act }) => {
185
+ await act(() => api().bump());
186
+ },
187
+ };
188
+
189
+ it("React runs the layout phase first; tap runs call order", async () => {
190
+ const react = await runScenario("react", scenario);
191
+ expect(react.slice(-4)).toEqual([
192
+ "layout-cleanup n=0",
193
+ "layout n=1",
194
+ "passive-cleanup n=0",
195
+ "passive n=1",
196
+ ]);
197
+
198
+ for (const env of TAP_ENVS) {
199
+ const tap = await runScenario(env, scenario);
200
+ expect(tap.slice(-4)).toEqual([
201
+ "passive-cleanup n=0",
202
+ "layout-cleanup n=0",
203
+ "passive n=1",
204
+ "layout n=1",
205
+ ]);
206
+ }
207
+ });
208
+ });
@@ -0,0 +1,43 @@
1
+ /* oxlint-disable react/exhaustive-deps -- intentional patterns are part of the scenarios */
2
+ import { describe, it, expect } from "vitest";
3
+ import { useEffect, useState } from "react";
4
+ import {
5
+ describeParity,
6
+ isDevMode,
7
+ runScenario,
8
+ type Scenario,
9
+ } from "./describeParity";
10
+
11
+ describe("harness smoke", () => {
12
+ it("runs against the React build matching the project mode", () => {
13
+ expect(process.env.NODE_ENV === "production").toBe(!isDevMode);
14
+ });
15
+
16
+ it("react env observes StrictMode double render only in dev", async () => {
17
+ const scenario: Scenario = {
18
+ name: "",
19
+ use: (log) => log("render"),
20
+ };
21
+ const events = await runScenario("react", scenario);
22
+ expect(events).toEqual(isDevMode ? ["render", "render"] : ["render"]);
23
+ });
24
+ });
25
+
26
+ describeParity([
27
+ {
28
+ name: "smoke: mount, effect, event-handler setState",
29
+ use: (log) => {
30
+ const [count, setCount] = useState(0);
31
+ log(`render ${count}`);
32
+ useEffect(() => {
33
+ log(`effect ${count}`);
34
+ return () => log(`cleanup ${count}`);
35
+ }, [count]);
36
+ return { increment: () => setCount((c) => c + 1) };
37
+ },
38
+ drive: async ({ api, act }) => {
39
+ await act(() => api().increment());
40
+ },
41
+ unmountAtEnd: true,
42
+ },
43
+ ]);
@@ -2,8 +2,8 @@ import { describe, it, expect } from "vitest";
2
2
  import { render, screen, act } from "@testing-library/react";
3
3
  import { Suspense, startTransition, use, useState } from "react";
4
4
  import { resource } from "../../core/resource";
5
- import { useResource } from "../../react/hooks";
6
- import { useState as useResourceState } from "../../hooks/useState";
5
+ import { useResource } from "../../index";
6
+ import { useState as useResourceState } from "../../react-hooks/useState";
7
7
 
8
8
  const ShouldNeverFallback = () => {
9
9
  throw new Error("should never fallback");
@@ -11,9 +11,11 @@ const ShouldNeverFallback = () => {
11
11
 
12
12
  describe("Concurrent Mode with useResource", () => {
13
13
  it("should not commit useResourceState updates when render is discarded", async () => {
14
- const TestResource = resource(function TestResource() {
14
+ const useTestResource = () => {
15
15
  return useResourceState(false);
16
- });
16
+ };
17
+
18
+ const TestResource = resource(useTestResource);
17
19
 
18
20
  let resolve: (value: number) => void;
19
21
 
@@ -142,14 +144,16 @@ describe("Concurrent Mode with useResource", () => {
142
144
  let resolve: () => void;
143
145
  let shouldSuspend = false;
144
146
 
145
- const TestResource = resource(function TestResource(props: { id: number }) {
147
+ const useTestResource = (props: { id: number }) => {
146
148
  if (shouldSuspend) {
147
149
  throw new Promise<void>((r) => {
148
150
  resolve = r;
149
151
  });
150
152
  }
151
153
  return { value: `content-${props.id}` };
152
- });
154
+ };
155
+
156
+ const TestResource = resource(useTestResource);
153
157
 
154
158
  function Inner({ id }: { id: number }) {
155
159
  const result = useResource(TestResource({ id }));