@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.
- 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 +14 -14
- 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.js +11 -11
- 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 +10 -9
- 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 +1 -1
- 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,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 "../../
|
|
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
|
|
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
|
|
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 }));
|