@assistant-ui/tap 0.6.0 → 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.
- package/README.md +9 -6
- package/dist/core/ResourceFiber.d.ts +5 -5
- package/dist/core/ResourceFiber.d.ts.map +1 -1
- package/dist/core/ResourceFiber.js +26 -18
- package/dist/core/ResourceFiber.js.map +1 -1
- package/dist/core/createTapRoot.d.ts +9 -0
- package/dist/core/createTapRoot.d.ts.map +1 -0
- package/dist/core/createTapRoot.js +27 -0
- package/dist/core/createTapRoot.js.map +1 -0
- package/dist/core/helpers/commit.d.ts +1 -1
- package/dist/core/helpers/commit.d.ts.map +1 -1
- package/dist/core/helpers/commit.js +6 -1
- package/dist/core/helpers/commit.js.map +1 -1
- package/dist/core/helpers/execution-context.d.ts +4 -5
- package/dist/core/helpers/execution-context.d.ts.map +1 -1
- package/dist/core/helpers/execution-context.js +1 -7
- package/dist/core/helpers/execution-context.js.map +1 -1
- package/dist/core/helpers/root.d.ts +3 -2
- package/dist/core/helpers/root.d.ts.map +1 -1
- package/dist/core/helpers/root.js +19 -15
- package/dist/core/helpers/root.js.map +1 -1
- package/dist/core/react-dispatcher.d.ts.map +1 -1
- package/dist/core/react-dispatcher.js +17 -16
- package/dist/core/react-dispatcher.js.map +1 -1
- package/dist/core/resource.d.ts +2 -4
- package/dist/core/resource.d.ts.map +1 -1
- package/dist/core/resource.js +5 -10
- package/dist/core/resource.js.map +1 -1
- package/dist/core/scheduler.d.ts +2 -2
- package/dist/core/scheduler.d.ts.map +1 -1
- package/dist/core/scheduler.js +2 -2
- package/dist/core/scheduler.js.map +1 -1
- package/dist/core/types.d.ts +27 -25
- package/dist/core/types.d.ts.map +1 -1
- package/dist/hooks/useResource.d.ts +2 -2
- package/dist/hooks/useResource.d.ts.map +1 -1
- package/dist/hooks/useResource.js +14 -20
- package/dist/hooks/useResource.js.map +1 -1
- package/dist/hooks/useResources.d.ts +1 -1
- package/dist/hooks/useResources.d.ts.map +1 -1
- package/dist/hooks/useResources.js +18 -27
- package/dist/hooks/useResources.js.map +1 -1
- package/dist/hooks/useTapHost.d.ts +21 -0
- package/dist/hooks/useTapHost.d.ts.map +1 -0
- package/dist/hooks/useTapHost.js +30 -0
- package/dist/hooks/useTapHost.js.map +1 -0
- package/dist/hooks/useTapRoot.d.ts +18 -0
- package/dist/hooks/useTapRoot.d.ts.map +1 -0
- package/dist/hooks/useTapRoot.js +77 -0
- package/dist/hooks/useTapRoot.js.map +1 -0
- package/dist/hooks/utils/depsShallowEqual.d.ts.map +1 -1
- package/dist/hooks/utils/depsShallowEqual.js +5 -2
- package/dist/hooks/utils/depsShallowEqual.js.map +1 -1
- package/dist/hooks/utils/useCell.d.ts +2 -2
- package/dist/hooks/utils/useCell.d.ts.map +1 -1
- package/dist/hooks/utils/useCell.js.map +1 -1
- package/dist/hooks/utils/useDevStrictMode.d.ts +5 -0
- package/dist/hooks/utils/useDevStrictMode.d.ts.map +1 -0
- package/dist/hooks/utils/useDevStrictMode.js +25 -0
- package/dist/hooks/utils/useDevStrictMode.js.map +1 -0
- package/dist/hooks/utils/useRenderMemo.d.ts +5 -0
- package/dist/hooks/utils/useRenderMemo.d.ts.map +1 -0
- package/dist/hooks/utils/useRenderMemo.js +25 -0
- package/dist/hooks/utils/useRenderMemo.js.map +1 -0
- package/dist/hooks/utils/useResourceFiberHostUtils.d.ts +10 -0
- package/dist/hooks/utils/useResourceFiberHostUtils.d.ts.map +1 -0
- package/dist/hooks/utils/useResourceFiberHostUtils.js +46 -0
- package/dist/hooks/utils/useResourceFiberHostUtils.js.map +1 -0
- package/dist/index.d.ts +7 -4
- package/dist/index.js +7 -4
- package/dist/{hooks → react-hooks}/index.d.ts +6 -6
- package/dist/{hooks → react-hooks}/index.js +5 -5
- package/dist/{hooks → react-hooks}/use.d.ts +1 -1
- package/dist/{hooks → react-hooks}/use.d.ts.map +1 -1
- package/dist/{hooks → react-hooks}/use.js +1 -1
- package/dist/react-hooks/use.js.map +1 -0
- package/dist/{hooks → react-hooks}/useCallback.d.ts +1 -1
- package/dist/react-hooks/useCallback.d.ts.map +1 -0
- package/dist/{hooks → react-hooks}/useCallback.js +1 -1
- package/dist/react-hooks/useCallback.js.map +1 -0
- package/dist/{hooks → react-hooks}/useEffect.d.ts +1 -1
- package/dist/react-hooks/useEffect.d.ts.map +1 -0
- package/dist/react-hooks/useEffect.js +35 -0
- package/dist/react-hooks/useEffect.js.map +1 -0
- package/dist/{hooks → react-hooks}/useEffectEvent.d.ts +1 -1
- package/dist/react-hooks/useEffectEvent.d.ts.map +1 -0
- package/dist/{hooks → react-hooks}/useEffectEvent.js +2 -2
- package/dist/react-hooks/useEffectEvent.js.map +1 -0
- package/dist/{hooks → react-hooks}/useMemo.d.ts +1 -1
- package/dist/react-hooks/useMemo.d.ts.map +1 -0
- package/dist/{hooks → react-hooks}/useMemo.js +3 -3
- package/dist/react-hooks/useMemo.js.map +1 -0
- package/dist/{hooks → react-hooks}/useMemoCache.d.ts +1 -1
- package/dist/react-hooks/useMemoCache.d.ts.map +1 -0
- package/dist/{hooks → react-hooks}/useMemoCache.js +1 -1
- package/dist/react-hooks/useMemoCache.js.map +1 -0
- package/dist/react-hooks/useReducer.d.ts +9 -0
- package/dist/react-hooks/useReducer.d.ts.map +1 -0
- package/dist/react-hooks/useReducer.js +120 -0
- package/dist/react-hooks/useReducer.js.map +1 -0
- package/dist/{hooks → react-hooks}/useRef.d.ts +1 -1
- package/dist/react-hooks/useRef.d.ts.map +1 -0
- package/dist/{hooks → react-hooks}/useRef.js +1 -1
- package/dist/react-hooks/useRef.js.map +1 -0
- package/dist/{hooks → react-hooks}/useState.d.ts +1 -1
- package/dist/react-hooks/useState.d.ts.map +1 -0
- package/dist/{hooks → react-hooks}/useState.js +3 -3
- package/dist/react-hooks/useState.js.map +1 -0
- package/dist/react-shim/index.d.ts +8 -10
- package/dist/react-shim/index.d.ts.map +1 -1
- package/dist/react-shim/index.js +19 -19
- package/dist/react-shim/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/basic/resourceHandle.test.ts +32 -22
- package/src/__tests__/basic/tapEffect.basic.test.ts +8 -8
- package/src/__tests__/basic/tapReducer.basic.test.ts +16 -14
- package/src/__tests__/basic/tapResources.basic.test.ts +19 -16
- package/src/__tests__/basic/tapState.basic.test.ts +11 -11
- package/src/__tests__/bench/hosts.bench.tsx +124 -0
- package/src/__tests__/bench/tree.bench.tsx +166 -0
- package/src/__tests__/errors/errors.effect-errors.test.ts +12 -13
- package/src/__tests__/errors/errors.render-errors.test.ts +65 -22
- package/src/__tests__/lifecycle/lifecycle.dependencies.test.ts +19 -19
- package/src/__tests__/lifecycle/lifecycle.mount-unmount.test.ts +14 -14
- package/src/__tests__/parity/describeParity.tsx +217 -0
- package/src/__tests__/parity/parity.adversarial.test.tsx +375 -0
- package/src/__tests__/parity/parity.basics.test.tsx +281 -0
- package/src/__tests__/parity/parity.divergences.test.tsx +208 -0
- package/src/__tests__/parity/parity.smoke.test.tsx +43 -0
- package/src/__tests__/react/concurrent-mode.test.tsx +10 -6
- package/src/__tests__/react/concurrent-pending-updates.test.tsx +351 -0
- package/src/__tests__/react/concurrent-render-phase.test.tsx +350 -0
- package/src/__tests__/react/react-shim.test.tsx +1 -1
- package/src/__tests__/react/useResource.test.tsx +41 -26
- package/src/__tests__/react/useTapHost.test.tsx +233 -0
- package/src/__tests__/react-dispatcher.test.ts +4 -4
- package/src/__tests__/rules/rules.hook-count.test.ts +21 -21
- package/src/__tests__/rules/rules.hook-order.test.ts +17 -17
- package/src/__tests__/strictmode/strictmode-parity.test.tsx +420 -0
- package/src/__tests__/strictmode/strictmode.test.ts +39 -209
- package/src/__tests__/test-utils.ts +33 -23
- package/src/core/ResourceFiber.ts +43 -35
- package/src/core/createTapRoot.ts +45 -0
- package/src/core/helpers/commit.ts +12 -2
- package/src/core/helpers/execution-context.ts +4 -13
- package/src/core/helpers/root.ts +24 -12
- package/src/core/react-dispatcher.ts +14 -13
- package/src/core/resource.ts +5 -20
- package/src/core/scheduler.ts +1 -1
- package/src/core/types.ts +27 -21
- package/src/hooks/useResource.ts +18 -27
- package/src/hooks/useResources.ts +18 -42
- package/src/hooks/useTapHost.ts +60 -0
- package/src/hooks/useTapRoot.ts +135 -0
- package/src/hooks/utils/depsShallowEqual.ts +12 -2
- package/src/hooks/utils/useCell.ts +2 -2
- package/src/hooks/utils/useDevStrictMode.ts +34 -0
- package/src/hooks/utils/useRenderMemo.ts +27 -0
- package/src/hooks/utils/useResourceFiberHostUtils.ts +61 -0
- package/src/index.ts +6 -3
- package/src/{hooks → react-hooks}/index.ts +4 -4
- package/src/react-hooks/useEffect.ts +58 -0
- package/src/{hooks → react-hooks}/useMemo.ts +1 -1
- package/src/react-hooks/useReducer.ts +254 -0
- package/src/{hooks → react-hooks}/useState.ts +2 -2
- package/src/react-shim/index.ts +24 -13
- package/dist/core/createResourceRoot.d.ts +0 -11
- package/dist/core/createResourceRoot.d.ts.map +0 -1
- package/dist/core/createResourceRoot.js +0 -31
- package/dist/core/createResourceRoot.js.map +0 -1
- package/dist/core/helpers/callResourceFn.d.ts +0 -1
- package/dist/core/helpers/callResourceFn.js +0 -19
- package/dist/core/helpers/callResourceFn.js.map +0 -1
- package/dist/hooks/use.js.map +0 -1
- package/dist/hooks/useCallback.d.ts.map +0 -1
- package/dist/hooks/useCallback.js.map +0 -1
- package/dist/hooks/useEffect.d.ts.map +0 -1
- package/dist/hooks/useEffect.js +0 -40
- package/dist/hooks/useEffect.js.map +0 -1
- package/dist/hooks/useEffectEvent.d.ts.map +0 -1
- package/dist/hooks/useEffectEvent.js.map +0 -1
- package/dist/hooks/useMemo.d.ts.map +0 -1
- package/dist/hooks/useMemo.js.map +0 -1
- package/dist/hooks/useMemoCache.d.ts.map +0 -1
- package/dist/hooks/useMemoCache.js.map +0 -1
- package/dist/hooks/useReducer.d.ts +0 -21
- package/dist/hooks/useReducer.d.ts.map +0 -1
- package/dist/hooks/useReducer.js +0 -81
- package/dist/hooks/useReducer.js.map +0 -1
- package/dist/hooks/useRef.d.ts.map +0 -1
- package/dist/hooks/useRef.js.map +0 -1
- package/dist/hooks/useResourceRoot.d.ts +0 -20
- package/dist/hooks/useResourceRoot.d.ts.map +0 -1
- package/dist/hooks/useResourceRoot.js +0 -77
- package/dist/hooks/useResourceRoot.js.map +0 -1
- package/dist/hooks/useState.d.ts.map +0 -1
- package/dist/hooks/useState.js.map +0 -1
- package/dist/react/hooks.d.ts +0 -25
- package/dist/react/hooks.d.ts.map +0 -1
- package/dist/react/hooks.js +0 -69
- package/dist/react/hooks.js.map +0 -1
- package/src/__tests__/strictmode/react-strictmode-behavior.test.tsx +0 -920
- package/src/__tests__/strictmode/react-strictmode-rerender-sources.test.tsx +0 -488
- package/src/__tests__/strictmode/tap-strictmode-rerender-sources.test.ts +0 -687
- package/src/core/createResourceRoot.ts +0 -53
- package/src/core/helpers/callResourceFn.ts +0 -21
- package/src/hooks/useEffect.ts +0 -72
- package/src/hooks/useReducer.ts +0 -160
- package/src/hooks/useResourceRoot.ts +0 -130
- package/src/react/hooks.ts +0 -112
- /package/src/{hooks → react-hooks}/use.ts +0 -0
- /package/src/{hooks → react-hooks}/useCallback.ts +0 -0
- /package/src/{hooks → react-hooks}/useEffectEvent.ts +0 -0
- /package/src/{hooks → react-hooks}/useMemoCache.ts +0 -0
- /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
|
+
});
|