@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,351 @@
1
+ /**
2
+ * A/B concurrency tests for pending updates in tap-scheduled sub-roots.
3
+ *
4
+ * Since the pending-update queue removal, dispatches apply directly into
5
+ * reducer cells, and a React-driven render of a useTapRoot sub-root consumes
6
+ * pending entries — including render attempts React later discards. These
7
+ * tests run the same scenario in two worlds:
8
+ *
9
+ * react — the hooks run directly in the component (React's own update
10
+ * queue semantics are the oracle)
11
+ * tap — the hooks run in a useTapRoot sub-root read via
12
+ * useSyncExternalStore
13
+ *
14
+ * and assert the committed, observable output matches at every checkpoint.
15
+ * Render-attempt interleavings are deliberately NOT compared (scheduling
16
+ * differs legitimately); committed state must not.
17
+ */
18
+ /* oxlint-disable react/rules-of-hooks -- the world branch is fixed per test run */
19
+
20
+ import { describe, it, expect, afterEach } from "vitest";
21
+ import {
22
+ StrictMode,
23
+ Suspense,
24
+ startTransition,
25
+ use,
26
+ useMemo,
27
+ useReducer,
28
+ useState,
29
+ useSyncExternalStore,
30
+ } from "react";
31
+ import { render, screen, act, cleanup } from "@testing-library/react";
32
+ import { useTapRoot } from "../../index";
33
+ import { cleanupAllResources } from "../test-utils";
34
+
35
+ afterEach(() => {
36
+ cleanupAllResources();
37
+ cleanup();
38
+ });
39
+
40
+ type WorldName = "react" | "tap";
41
+
42
+ const useInWorld = <T,>(world: WorldName, useBody: () => T): T => {
43
+ if (world === "react") return useBody();
44
+ const root = useTapRoot(function Sub() {
45
+ return useBody();
46
+ });
47
+ return useSyncExternalStore(root.subscribe, root.getValue, root.getValue);
48
+ };
49
+
50
+ /**
51
+ * Two chained reducer cells; `add` dispatches to both in one event. The
52
+ * return value is identity-stable per state (the useSyncExternalStore
53
+ * snapshot contract for tap roots).
54
+ */
55
+ const useCounters = () => {
56
+ const [a, addA] = useReducer((s: number, n: number) => s + n, 0);
57
+ const [b, addB] = useReducer((s: number, n: number) => s + n, 100);
58
+ return useMemo(
59
+ () => ({
60
+ a,
61
+ b,
62
+ add: (n: number) => {
63
+ addA(n);
64
+ addB(n * 2);
65
+ },
66
+ }),
67
+ [a, b],
68
+ );
69
+ };
70
+
71
+ const ShouldNeverFallback = () => {
72
+ throw new Error("should never fallback");
73
+ };
74
+
75
+ describe("pending updates under concurrent rendering (react vs tap)", () => {
76
+ it("a forced re-render between dispatch and flush applies each update exactly once", async () => {
77
+ const run = async (world: WorldName): Promise<string[]> => {
78
+ const checkpoints: string[] = [];
79
+ let api!: ReturnType<typeof useCounters>;
80
+
81
+ function App() {
82
+ const counters = useInWorld(world, useCounters);
83
+ api = counters;
84
+ const [, setTick] = useState(0);
85
+ return (
86
+ <>
87
+ <button
88
+ type="button"
89
+ data-testid="rerender"
90
+ onClick={() => setTick((t) => t + 1)}
91
+ />
92
+ <div data-testid="out">
93
+ a={counters.a} b={counters.b}
94
+ </div>
95
+ </>
96
+ );
97
+ }
98
+
99
+ render(
100
+ <StrictMode>
101
+ <App />
102
+ </StrictMode>,
103
+ );
104
+ checkpoints.push(screen.getByTestId("out").textContent!);
105
+
106
+ // Dispatch, then force a synchronous host re-render in the same act
107
+ // before the tap scheduler's macrotask flush can run. In the tap world
108
+ // this makes the React-driven render consume the pending entries; the
109
+ // later flush must not re-apply them.
110
+ await act(async () => {
111
+ api.add(1);
112
+ screen.getByTestId("rerender").click();
113
+ });
114
+ checkpoints.push(screen.getByTestId("out").textContent!);
115
+
116
+ // Let any remaining scheduled flush settle.
117
+ await act(async () => {
118
+ await new Promise((r) => setTimeout(r, 30));
119
+ });
120
+ checkpoints.push(screen.getByTestId("out").textContent!);
121
+
122
+ return checkpoints;
123
+ };
124
+
125
+ const reactLog = await run("react");
126
+ cleanup();
127
+ const tapLog = await run("tap");
128
+
129
+ expect(reactLog.at(-1)).toBe("a=1 b=102");
130
+ expect(tapLog).toEqual(reactLog);
131
+ });
132
+
133
+ it("pending dispatches survive a render attempt discarded by suspense", async () => {
134
+ const run = async (world: WorldName): Promise<string[]> => {
135
+ const checkpoints: string[] = [];
136
+ let api!: ReturnType<typeof useCounters>;
137
+ let resolve!: (v: number) => void;
138
+ const gate = new Promise<number>((r) => {
139
+ resolve = r;
140
+ });
141
+
142
+ function Suspender() {
143
+ return use(gate);
144
+ }
145
+
146
+ function App() {
147
+ const counters = useInWorld(world, useCounters);
148
+ api = counters;
149
+ const [load, setLoad] = useState(false);
150
+ return (
151
+ <>
152
+ <button
153
+ type="button"
154
+ data-testid="suspend"
155
+ onClick={() => startTransition(() => setLoad(true))}
156
+ />
157
+ <div data-testid="out">
158
+ a={counters.a} b={counters.b}
159
+ </div>
160
+ <Suspense fallback={<ShouldNeverFallback />}>
161
+ <div data-testid="gated">{load ? <Suspender /> : "none"}</div>
162
+ </Suspense>
163
+ </>
164
+ );
165
+ }
166
+
167
+ render(
168
+ <StrictMode>
169
+ <App />
170
+ </StrictMode>,
171
+ );
172
+
173
+ // Start a transition whose render attempt suspends (and is repeatedly
174
+ // discarded/retried while the gate is pending).
175
+ await act(async () => {
176
+ screen.getByTestId("suspend").click();
177
+ });
178
+
179
+ // Dispatch while the suspended transition is in flight; the urgent
180
+ // re-render and discarded transition attempts race the tap flush.
181
+ await act(async () => {
182
+ api.add(1);
183
+ });
184
+ checkpoints.push(screen.getByTestId("out").textContent!);
185
+
186
+ await act(async () => {
187
+ resolve(7);
188
+ });
189
+ checkpoints.push(screen.getByTestId("out").textContent!);
190
+ checkpoints.push(screen.getByTestId("gated").textContent!);
191
+
192
+ return checkpoints;
193
+ };
194
+
195
+ const reactLog = await run("react");
196
+ cleanup();
197
+ const tapLog = await run("tap");
198
+
199
+ expect(reactLog).toEqual(["a=1 b=102", "a=1 b=102", "7"]);
200
+ expect(tapLog).toEqual(reactLog);
201
+ });
202
+
203
+ it("suspension between chained reducer cells leaves no partial or double application", async () => {
204
+ const run = async (world: WorldName): Promise<string[]> => {
205
+ const checkpoints: string[] = [];
206
+ let resolve!: (v: number) => void;
207
+ const gate = new Promise<number>((r) => {
208
+ resolve = r;
209
+ });
210
+ let gateOpen = false;
211
+ let api!: { add: (n: number) => void };
212
+
213
+ // The suspension happens INSIDE the body, between the two reducer
214
+ // cells, so a discarded attempt has consumed cell A's entries but not
215
+ // cell B's. Suspense uses the throw protocol: tap's `use()` accepts
216
+ // only resource contexts, but a thrown promise propagates out of the
217
+ // resource render to React in both worlds.
218
+ const useChained = () => {
219
+ const [a, addA] = useReducer((s: number, n: number) => s + n, 0);
220
+ if (!gateOpen) throw gate;
221
+ const [b, addB] = useReducer((s: number, n: number) => s + n, 100);
222
+ return useMemo(
223
+ () => ({
224
+ a,
225
+ b,
226
+ add: (n: number) => {
227
+ addA(n);
228
+ addB(n * 2);
229
+ },
230
+ }),
231
+ [a, b],
232
+ );
233
+ };
234
+
235
+ function Gated() {
236
+ const counters = useInWorld(world, useChained);
237
+ api = counters;
238
+ return (
239
+ <div data-testid="out">
240
+ a={counters.a} b={counters.b}
241
+ </div>
242
+ );
243
+ }
244
+
245
+ function App() {
246
+ const [show, setShow] = useState(false);
247
+ return (
248
+ <>
249
+ <button
250
+ type="button"
251
+ data-testid="show"
252
+ onClick={() => startTransition(() => setShow(true))}
253
+ />
254
+ <Suspense fallback={<div data-testid="fallback">loading</div>}>
255
+ {show ? <Gated /> : <div data-testid="out">hidden</div>}
256
+ </Suspense>
257
+ </>
258
+ );
259
+ }
260
+
261
+ render(
262
+ <StrictMode>
263
+ <App />
264
+ </StrictMode>,
265
+ );
266
+
267
+ // Mount the gated subtree: the body suspends between cell A and cell B.
268
+ await act(async () => {
269
+ screen.getByTestId("show").click();
270
+ });
271
+ checkpoints.push(screen.getByTestId("out").textContent!);
272
+
273
+ // Open the gate so retries complete.
274
+ await act(async () => {
275
+ gateOpen = true;
276
+ resolve(1);
277
+ });
278
+ checkpoints.push(screen.getByTestId("out").textContent!);
279
+
280
+ // Updates after the rocky mount must still apply exactly once.
281
+ await act(async () => {
282
+ api.add(2);
283
+ });
284
+ await act(async () => {
285
+ await new Promise((r) => setTimeout(r, 30));
286
+ });
287
+ checkpoints.push(screen.getByTestId("out").textContent!);
288
+
289
+ return checkpoints;
290
+ };
291
+
292
+ const reactLog = await run("react");
293
+ cleanup();
294
+ const tapLog = await run("tap");
295
+
296
+ expect(reactLog).toEqual(["hidden", "a=0 b=100", "a=2 b=104"]);
297
+ expect(tapLog).toEqual(reactLog);
298
+ });
299
+
300
+ it("dispatches racing an interrupted transition apply exactly once", async () => {
301
+ const run = async (world: WorldName): Promise<string[]> => {
302
+ const checkpoints: string[] = [];
303
+ let api!: ReturnType<typeof useCounters>;
304
+
305
+ function App() {
306
+ const counters = useInWorld(world, useCounters);
307
+ api = counters;
308
+ const [mode, setMode] = useState("idle");
309
+ return (
310
+ <>
311
+ <button
312
+ type="button"
313
+ data-testid="transition"
314
+ onClick={() => startTransition(() => setMode("busy"))}
315
+ />
316
+ <div data-testid="out">
317
+ {mode} a={counters.a} b={counters.b}
318
+ </div>
319
+ </>
320
+ );
321
+ }
322
+
323
+ render(
324
+ <StrictMode>
325
+ <App />
326
+ </StrictMode>,
327
+ );
328
+
329
+ // Start a transition and interrupt it with urgent dispatches before it
330
+ // commits; React restarts the transition render around them.
331
+ await act(async () => {
332
+ screen.getByTestId("transition").click();
333
+ api.add(1);
334
+ api.add(10);
335
+ });
336
+ await act(async () => {
337
+ await new Promise((r) => setTimeout(r, 30));
338
+ });
339
+ checkpoints.push(screen.getByTestId("out").textContent!);
340
+
341
+ return checkpoints;
342
+ };
343
+
344
+ const reactLog = await run("react");
345
+ cleanup();
346
+ const tapLog = await run("tap");
347
+
348
+ expect(reactLog).toEqual(["busy a=11 b=122"]);
349
+ expect(tapLog).toEqual(reactLog);
350
+ });
351
+ });
@@ -0,0 +1,350 @@
1
+ /**
2
+ * A/B concurrency tests for render-phase updates (setState during render)
3
+ * in renders React discards and replays.
4
+ *
5
+ * A render-phase dispatch lives only inside its render attempt: tap drains it
6
+ * within renderResourceFiber, and a discarded attempt is rolled back
7
+ * (setRootVersion resets workInProgress and clears cell queues). The dispatch
8
+ * therefore survives a discard only if the retry re-derives it from the
9
+ * render's inputs. These tests run the same scenario in two worlds:
10
+ *
11
+ * react — the hooks run directly in the component (React's render-phase
12
+ * queue semantics are the oracle)
13
+ * tap — the hooks run in a useTapRoot sub-root read via
14
+ * useSyncExternalStore
15
+ *
16
+ * and assert the committed, observable output matches. Render-attempt
17
+ * interleavings are deliberately NOT compared; committed state must not
18
+ * differ.
19
+ */
20
+ /* oxlint-disable react/rules-of-hooks -- the world branch is fixed per test run */
21
+
22
+ import { describe, it, expect, afterEach } from "vitest";
23
+ import {
24
+ StrictMode,
25
+ startTransition,
26
+ Suspense,
27
+ useMemo,
28
+ useReducer,
29
+ useRef,
30
+ useState,
31
+ useSyncExternalStore,
32
+ } from "react";
33
+ import { render, screen, act, cleanup } from "@testing-library/react";
34
+ import { useTapRoot } from "../../index";
35
+ import { cleanupAllResources } from "../test-utils";
36
+
37
+ afterEach(() => {
38
+ cleanupAllResources();
39
+ cleanup();
40
+ });
41
+
42
+ type WorldName = "react" | "tap";
43
+
44
+ const useInWorld = <T,>(world: WorldName, useBody: () => T): T => {
45
+ if (world === "react") return useBody();
46
+ const root = useTapRoot(function Sub() {
47
+ return useBody();
48
+ });
49
+ return useSyncExternalStore(root.subscribe, root.getValue, root.getValue);
50
+ };
51
+
52
+ describe("render-phase updates under concurrent rendering (react vs tap)", () => {
53
+ it("pure derivations re-derive across an interrupted transition", async () => {
54
+ const run = async (world: WorldName): Promise<string[]> => {
55
+ let api!: { add: (x: number) => void };
56
+
57
+ const useDerived = () => {
58
+ const [n, addN] = useReducer((s: number, x: number) => s + x, 0);
59
+ const [doubled, setDoubled] = useState(0);
60
+ // The classic adjust-during-render pattern: a pure function of `n`,
61
+ // so any discarded attempt's dispatch is re-derived on retry.
62
+ if (doubled !== n * 2) setDoubled(n * 2);
63
+ return useMemo(
64
+ () => ({ n, doubled, add: (x: number) => addN(x) }),
65
+ [n, doubled],
66
+ );
67
+ };
68
+
69
+ function App() {
70
+ const value = useInWorld(world, useDerived);
71
+ api = value;
72
+ const [mode, setMode] = useState("idle");
73
+ return (
74
+ <>
75
+ <button
76
+ type="button"
77
+ data-testid="transition"
78
+ onClick={() => startTransition(() => setMode("busy"))}
79
+ />
80
+ <div data-testid="out">
81
+ {mode} n={value.n} doubled={value.doubled}
82
+ </div>
83
+ </>
84
+ );
85
+ }
86
+
87
+ render(
88
+ <StrictMode>
89
+ <App />
90
+ </StrictMode>,
91
+ );
92
+
93
+ // Start a transition and interrupt it with urgent dispatches before it
94
+ // commits; React restarts the transition render around them.
95
+ await act(async () => {
96
+ screen.getByTestId("transition").click();
97
+ api.add(1);
98
+ api.add(10);
99
+ });
100
+ await act(async () => {
101
+ await new Promise((r) => setTimeout(r, 30));
102
+ });
103
+ return [screen.getByTestId("out").textContent!];
104
+ };
105
+
106
+ const reactLog = await run("react");
107
+ cleanup();
108
+ const tapLog = await run("tap");
109
+
110
+ expect(reactLog).toEqual(["busy n=11 doubled=22"]);
111
+ expect(tapLog).toEqual(reactLog);
112
+ });
113
+
114
+ it("a non-re-derivable render-phase dispatch from a discarded attempt", async () => {
115
+ const run = async (world: WorldName): Promise<string[]> => {
116
+ let resolve!: (v: number) => void;
117
+ const gate = new Promise<number>((r) => {
118
+ resolve = r;
119
+ });
120
+ let gateOpen = false;
121
+
122
+ function App() {
123
+ const [mode, setMode] = useState("idle");
124
+ // The one-shot guard lives in a ref, which neither React nor tap
125
+ // restores when an attempt is discarded, so the retry does NOT
126
+ // re-derive the dispatch. Whatever React commits is the oracle for
127
+ // whether the dispatch survives the discard.
128
+ const fired = useRef(false);
129
+ const value = useInWorld(world, () => {
130
+ const [count, bump] = useReducer((s: number, n: number) => s + n, 0);
131
+ if (mode === "busy" && !fired.current) {
132
+ fired.current = true;
133
+ bump(100);
134
+ }
135
+ // Discard this attempt deterministically, after the dispatch.
136
+ if (mode === "busy" && !gateOpen) throw gate;
137
+ return useMemo(() => ({ count }), [count]);
138
+ });
139
+ return (
140
+ <>
141
+ <button
142
+ type="button"
143
+ data-testid="transition"
144
+ onClick={() => startTransition(() => setMode("busy"))}
145
+ />
146
+ <div data-testid="out">
147
+ {mode} count={value.count}
148
+ </div>
149
+ </>
150
+ );
151
+ }
152
+
153
+ render(
154
+ <StrictMode>
155
+ <Suspense fallback={<div>loading</div>}>
156
+ <App />
157
+ </Suspense>
158
+ </StrictMode>,
159
+ );
160
+
161
+ // The transition attempt renders mode=busy: the one-shot fires, the
162
+ // render-phase dispatch is enqueued, then the attempt suspends and is
163
+ // discarded.
164
+ await act(async () => {
165
+ screen.getByTestId("transition").click();
166
+ });
167
+ const midpoint = screen.getByTestId("out").textContent!;
168
+
169
+ // Open the gate; the retry renders with the ref already consumed.
170
+ await act(async () => {
171
+ gateOpen = true;
172
+ resolve(1);
173
+ });
174
+ await act(async () => {
175
+ await new Promise((r) => setTimeout(r, 30));
176
+ });
177
+ return [midpoint, screen.getByTestId("out").textContent!];
178
+ };
179
+
180
+ const reactLog = await run("react");
181
+ cleanup();
182
+ const tapLog = await run("tap");
183
+
184
+ // React drops the render-phase dispatch together with the discarded
185
+ // attempt: the queued update lived on the attempt's work-in-progress
186
+ // hooks. Discard-on-rollback is therefore the React-correct semantics;
187
+ // only re-derivable dispatches survive (see the previous test).
188
+ expect(reactLog).toEqual(["idle count=0", "busy count=0"]);
189
+ expect(tapLog).toEqual(reactLog);
190
+ });
191
+
192
+ it("a render-phase dispatch in an attempt aborted around a higher-priority commit", async () => {
193
+ const run = async (
194
+ world: WorldName,
195
+ ): Promise<{ checkpoints: string[]; abortedAttemptRan: boolean }> => {
196
+ const checkpoints: string[] = [];
197
+ let resolve!: (v: number) => void;
198
+ const gate = new Promise<number>((r) => {
199
+ resolve = r;
200
+ });
201
+ let gateOpen = false;
202
+ let api!: { add: (n: number) => void };
203
+ let abortedAttemptRan = false;
204
+
205
+ function App() {
206
+ const [mode, setMode] = useState("idle");
207
+ const fired = useRef(false);
208
+ const value = useInWorld(world, () => {
209
+ const [count, bump] = useReducer((s: number, n: number) => s + n, 0);
210
+ // One-shot render-phase dispatch, made only by the low-priority
211
+ // attempt; the ref guard means retries do not re-derive it.
212
+ if (mode === "busy" && !fired.current) {
213
+ fired.current = true;
214
+ bump(100);
215
+ }
216
+ // Keep the low-priority attempt aborting until the gate opens, so
217
+ // the higher-priority commit below lands while it is in flight.
218
+ if (mode === "busy" && !gateOpen) {
219
+ abortedAttemptRan = true;
220
+ throw gate;
221
+ }
222
+ return useMemo(
223
+ () => ({ count, add: (n: number) => bump(n) }),
224
+ [count],
225
+ );
226
+ });
227
+ api = value;
228
+ return (
229
+ <>
230
+ <button
231
+ type="button"
232
+ data-testid="transition"
233
+ onClick={() => startTransition(() => setMode("busy"))}
234
+ />
235
+ <div data-testid="out">
236
+ {mode} count={value.count}
237
+ </div>
238
+ </>
239
+ );
240
+ }
241
+
242
+ render(
243
+ <StrictMode>
244
+ <Suspense fallback={<div>loading</div>}>
245
+ <App />
246
+ </Suspense>
247
+ </StrictMode>,
248
+ );
249
+
250
+ // 1. Begin the low-priority attempt; it runs (fires the render-phase
251
+ // dispatch) and aborts at the gate. The committed UI stays idle.
252
+ await act(async () => {
253
+ screen.getByTestId("transition").click();
254
+ });
255
+ checkpoints.push(screen.getByTestId("out").textContent!);
256
+
257
+ // 2. A higher-priority dispatch commits while the low-priority attempt
258
+ // is in flight.
259
+ await act(async () => {
260
+ api.add(1);
261
+ });
262
+ checkpoints.push(screen.getByTestId("out").textContent!);
263
+
264
+ // 3. Open the gate; the low-priority render retries and commits.
265
+ await act(async () => {
266
+ gateOpen = true;
267
+ resolve(1);
268
+ });
269
+ await act(async () => {
270
+ await new Promise((r) => setTimeout(r, 30));
271
+ });
272
+ checkpoints.push(screen.getByTestId("out").textContent!);
273
+
274
+ return { checkpoints, abortedAttemptRan };
275
+ };
276
+
277
+ const react = await run("react");
278
+ cleanup();
279
+ const tap = await run("tap");
280
+
281
+ // The aborted attempt provably ran in both worlds.
282
+ expect(react.abortedAttemptRan).toBe(true);
283
+ expect(tap.abortedAttemptRan).toBe(true);
284
+
285
+ // The higher-priority dispatch commits on the old UI mid-flight; the
286
+ // aborted attempt's render-phase dispatch dies with the attempt.
287
+ expect(react.checkpoints).toEqual([
288
+ "idle count=0",
289
+ "idle count=1",
290
+ "busy count=1",
291
+ ]);
292
+ expect(tap.checkpoints).toEqual(react.checkpoints);
293
+ });
294
+
295
+ it("a committed render-phase update survives a later dispatch's rollback", async () => {
296
+ const run = async (world: WorldName): Promise<string[]> => {
297
+ let api!: { addTrail: (s: string) => void };
298
+
299
+ const useTrail = () => {
300
+ const [mounted, setMounted] = useState(false);
301
+ // Non-eager useReducer: pending actions reduce over workInProgress at
302
+ // render time, so a rollback that restores a stale base is visible.
303
+ const [trail, addTrail] = useReducer(
304
+ (s: string, x: string) => s + x,
305
+ "",
306
+ );
307
+ // Non-re-derivable accumulation via a render-phase dispatch.
308
+ if (!mounted) {
309
+ setMounted(true);
310
+ addTrail("m;");
311
+ }
312
+ return useMemo(() => ({ trail, addTrail }), [trail]);
313
+ };
314
+
315
+ function App() {
316
+ const state = useInWorld(world, useTrail);
317
+ api = state;
318
+ return <div data-testid="out">{state.trail}</div>;
319
+ }
320
+
321
+ render(
322
+ <StrictMode>
323
+ <App />
324
+ </StrictMode>,
325
+ );
326
+ await act(async () => {
327
+ await new Promise((r) => setTimeout(r, 30));
328
+ });
329
+ const afterMount = screen.getByTestId("out").textContent!;
330
+
331
+ // A regular dispatch to the same cell after the render-phase update
332
+ // committed. The flush's rollback restores the cell's committed state;
333
+ // the render-phase update must be part of it.
334
+ await act(async () => {
335
+ api.addTrail("X;");
336
+ });
337
+ await act(async () => {
338
+ await new Promise((r) => setTimeout(r, 30));
339
+ });
340
+ return [afterMount, screen.getByTestId("out").textContent!];
341
+ };
342
+
343
+ const reactLog = await run("react");
344
+ cleanup();
345
+ const tapLog = await run("tap");
346
+
347
+ expect(reactLog).toEqual(["m;", "m;X;"]);
348
+ expect(tapLog).toEqual(reactLog);
349
+ });
350
+ });
@@ -29,7 +29,7 @@ describe("@assistant-ui/tap/react-shim", () => {
29
29
  return count;
30
30
  });
31
31
 
32
- renderTest(testFiber, undefined);
32
+ renderTest(testFiber);
33
33
  expect(getCommittedOutput(testFiber)).toBe(0);
34
34
  expect(effectLog).toEqual([0]);
35
35