@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,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Differential parity harness.
|
|
3
|
+
*
|
|
4
|
+
* tap's hooks are React's hooks (imported from "react" and resolved through
|
|
5
|
+
* the dispatcher inside resource renders), so the same scenario body can run
|
|
6
|
+
* in four environments:
|
|
7
|
+
*
|
|
8
|
+
* 1. react - as a component under <StrictMode>
|
|
9
|
+
* 2. bridge - as a resource hosted via useResource inside <StrictMode>
|
|
10
|
+
* 3. tapRoot - inside useTapRoot(function Root() {...}) in a component
|
|
11
|
+
* 4. createTapRoot - as a resource under createTapRoot (no React host)
|
|
12
|
+
*
|
|
13
|
+
* Each test runs the scenario in the react environment to capture the
|
|
14
|
+
* expected event log, then asserts the tap environments produce the identical
|
|
15
|
+
* log. React is the source of truth; there are no hand-maintained expected
|
|
16
|
+
* sequences to drift.
|
|
17
|
+
*
|
|
18
|
+
* The whole suite runs twice via vitest projects: once with NODE_ENV=test
|
|
19
|
+
* (dev React build, StrictMode double-invocation, tap devStrictMode) and once
|
|
20
|
+
* with NODE_ENV=production (prod React build, StrictMode inert, tap strict
|
|
21
|
+
* emulation off). React's prod build throws on act(), so this harness avoids
|
|
22
|
+
* act entirely: renders and event-handler updates are forced with flushSync /
|
|
23
|
+
* flushTapSync, and async work (passive effects, schedulers, promises) is
|
|
24
|
+
* absorbed by settling on a timer.
|
|
25
|
+
*/
|
|
26
|
+
import { describe, it, expect } from "vitest";
|
|
27
|
+
import { createElement, StrictMode } from "react";
|
|
28
|
+
import { createRoot } from "react-dom/client";
|
|
29
|
+
import { flushSync } from "react-dom";
|
|
30
|
+
import { resource } from "../../core/resource";
|
|
31
|
+
import { createTapRoot } from "../../core/createTapRoot";
|
|
32
|
+
import { flushTapSync } from "../../core/scheduler";
|
|
33
|
+
import { useResource } from "../../hooks/useResource";
|
|
34
|
+
import { useTapRoot } from "../../hooks/useTapRoot";
|
|
35
|
+
|
|
36
|
+
/** Mirrors tap's core/helpers/env so scenarios can branch on the mode. */
|
|
37
|
+
export const isDevMode = process.env.NODE_ENV !== "production";
|
|
38
|
+
|
|
39
|
+
// We deliberately do not use act(): it does not exist in prod React builds.
|
|
40
|
+
// Silence the dev-build "not wrapped in act" warning machinery.
|
|
41
|
+
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = false;
|
|
42
|
+
|
|
43
|
+
export type Log = (event: string) => void;
|
|
44
|
+
|
|
45
|
+
export type DriveContext = {
|
|
46
|
+
/** Latest value returned by the scenario body. */
|
|
47
|
+
api: () => any;
|
|
48
|
+
/** Dispatch an update like an event handler would, then settle. */
|
|
49
|
+
act: (fn: () => void) => Promise<void>;
|
|
50
|
+
/** Wait for async work (promises, scheduler flushes) to settle. */
|
|
51
|
+
settle: () => Promise<void>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type TapEnv = "bridge" | "tapRoot" | "createTapRoot";
|
|
55
|
+
export const TAP_ENVS = ["bridge", "tapRoot", "createTapRoot"] as const;
|
|
56
|
+
|
|
57
|
+
export type Scenario = {
|
|
58
|
+
name: string;
|
|
59
|
+
use: (log: Log) => any;
|
|
60
|
+
drive?: (ctx: DriveContext) => void | Promise<void>;
|
|
61
|
+
unmountAtEnd?: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* Documented divergences, keyed by tap environment:
|
|
64
|
+
* - "multiset": same events must occur, order may differ. Used for the
|
|
65
|
+
* bridge, where a dispatch rides through the host's React reducer so the
|
|
66
|
+
* eager invocation of an impure updater is deferred to the host's next
|
|
67
|
+
* render. Invocation multiset and final state must still match React.
|
|
68
|
+
* - "skip": environment intentionally not compared (assert the divergent
|
|
69
|
+
* behavior in a dedicated test instead).
|
|
70
|
+
*/
|
|
71
|
+
divergence?: Partial<Record<TapEnv, "multiset" | "skip">>;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Settle until two consecutive windows pass without new events, so delayed
|
|
75
|
+
// timers under suite load (parallel test files compete for CPU) cannot
|
|
76
|
+
// truncate a log.
|
|
77
|
+
const makeSettle = (events: string[]) => async () => {
|
|
78
|
+
let quiet = 0;
|
|
79
|
+
while (quiet < 2) {
|
|
80
|
+
const prev = events.length;
|
|
81
|
+
await new Promise<void>((r) => setTimeout(r, 20));
|
|
82
|
+
quiet = events.length === prev ? quiet + 1 : 0;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
type Host = {
|
|
87
|
+
api: () => any;
|
|
88
|
+
/** Synchronously flush an event-handler-style update. */
|
|
89
|
+
flush: (fn: () => void) => void;
|
|
90
|
+
unmount: () => void;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** Shared React host for the react, bridge and tapRoot environments. */
|
|
94
|
+
const mountReactHost = (
|
|
95
|
+
useProbeBody: (log: Log) => () => any,
|
|
96
|
+
log: Log,
|
|
97
|
+
): Host => {
|
|
98
|
+
let api: () => any = () => undefined;
|
|
99
|
+
function Probe() {
|
|
100
|
+
api = useProbeBody(log);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
const root = createRoot(document.createElement("div"));
|
|
104
|
+
// createElement instead of JSX so the harness is independent of the
|
|
105
|
+
// dev/prod jsx runtime split (jsxDEV does not exist in prod builds).
|
|
106
|
+
flushSync(() =>
|
|
107
|
+
root.render(createElement(StrictMode, null, createElement(Probe))),
|
|
108
|
+
);
|
|
109
|
+
return {
|
|
110
|
+
api: () => api(),
|
|
111
|
+
flush: (fn) => flushSync(fn),
|
|
112
|
+
unmount: () => flushSync(() => root.unmount()),
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const mountEnv = (
|
|
117
|
+
env: "react" | TapEnv,
|
|
118
|
+
scenario: Scenario,
|
|
119
|
+
log: Log,
|
|
120
|
+
): Host => {
|
|
121
|
+
switch (env) {
|
|
122
|
+
case "react":
|
|
123
|
+
return mountReactHost((log) => {
|
|
124
|
+
const value = scenario.use(log);
|
|
125
|
+
return () => value;
|
|
126
|
+
}, log);
|
|
127
|
+
case "bridge": {
|
|
128
|
+
const useScenario = (props: { log: Log }) => scenario.use(props.log);
|
|
129
|
+
const Scenario = resource(useScenario);
|
|
130
|
+
return mountReactHost((log) => {
|
|
131
|
+
const value = useResource(Scenario({ log }));
|
|
132
|
+
return () => value;
|
|
133
|
+
}, log);
|
|
134
|
+
}
|
|
135
|
+
case "tapRoot": {
|
|
136
|
+
const host = mountReactHost((log) => {
|
|
137
|
+
const root = useTapRoot(function Root() {
|
|
138
|
+
return scenario.use(log);
|
|
139
|
+
});
|
|
140
|
+
return () => root.getValue();
|
|
141
|
+
}, log);
|
|
142
|
+
// Updates inside the tap root are scheduled by tap, not React.
|
|
143
|
+
return { ...host, flush: (fn) => flushTapSync(fn) };
|
|
144
|
+
}
|
|
145
|
+
case "createTapRoot": {
|
|
146
|
+
const root = createTapRoot(function Root() {
|
|
147
|
+
return scenario.use(log);
|
|
148
|
+
});
|
|
149
|
+
return {
|
|
150
|
+
api: () => root.getValue(),
|
|
151
|
+
flush: (fn) => flushTapSync(fn),
|
|
152
|
+
unmount: () => root.unmount(),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export const runScenario = async (
|
|
159
|
+
env: "react" | TapEnv,
|
|
160
|
+
scenario: Scenario,
|
|
161
|
+
): Promise<string[]> => {
|
|
162
|
+
const events: string[] = [];
|
|
163
|
+
// The run ends before the host is torn down; stop logging so the teardown's
|
|
164
|
+
// effect cleanups don't leak into the captured log.
|
|
165
|
+
let done = false;
|
|
166
|
+
const log: Log = (e) => void (done || events.push(e));
|
|
167
|
+
const settle = makeSettle(events);
|
|
168
|
+
|
|
169
|
+
const host = mountEnv(env, scenario, log);
|
|
170
|
+
await settle();
|
|
171
|
+
await scenario.drive?.({
|
|
172
|
+
api: host.api,
|
|
173
|
+
act: async (fn) => {
|
|
174
|
+
host.flush(fn);
|
|
175
|
+
await settle();
|
|
176
|
+
},
|
|
177
|
+
settle,
|
|
178
|
+
});
|
|
179
|
+
await settle();
|
|
180
|
+
if (scenario.unmountAtEnd) {
|
|
181
|
+
host.unmount();
|
|
182
|
+
await settle();
|
|
183
|
+
}
|
|
184
|
+
done = true;
|
|
185
|
+
if (!scenario.unmountAtEnd) host.unmount();
|
|
186
|
+
return events;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Generates a describe block per scenario asserting each tap environment
|
|
191
|
+
* produces the exact event log React produces. The react run is shared
|
|
192
|
+
* across the three comparisons.
|
|
193
|
+
*/
|
|
194
|
+
export const describeParity = (scenarios: Scenario[]) => {
|
|
195
|
+
for (const scenario of scenarios) {
|
|
196
|
+
describe(scenario.name, () => {
|
|
197
|
+
let reactLog: Promise<string[]> | undefined;
|
|
198
|
+
const getReactLog = () => (reactLog ??= runScenario("react", scenario));
|
|
199
|
+
|
|
200
|
+
for (const env of TAP_ENVS) {
|
|
201
|
+
const divergence = scenario.divergence?.[env];
|
|
202
|
+
if (divergence === "skip") continue;
|
|
203
|
+
|
|
204
|
+
it(`${env} matches react`, async () => {
|
|
205
|
+
const expected = await getReactLog();
|
|
206
|
+
expect(expected.length).toBeGreaterThan(0);
|
|
207
|
+
const actual = await runScenario(env, scenario);
|
|
208
|
+
if (divergence === "multiset") {
|
|
209
|
+
expect([...actual].sort()).toEqual([...expected].sort());
|
|
210
|
+
} else {
|
|
211
|
+
expect(actual).toEqual(expected);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
};
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adversarial parity scenarios: each one was designed to break the
|
|
3
|
+
* React/tap equivalence and survived. Anything that diverged instead is
|
|
4
|
+
* documented in parity.divergences.test.tsx.
|
|
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: "reducer closing over another reducer's state, batched cross-dispatch",
|
|
13
|
+
use: (log) => {
|
|
14
|
+
const [value, dispatch] = useReducer((s: number, a: number) => {
|
|
15
|
+
log(`r1 s=${s} a=${a}`);
|
|
16
|
+
return s + a;
|
|
17
|
+
}, 1);
|
|
18
|
+
const [value2, dispatch2] = useReducer((a: number, b: number) => {
|
|
19
|
+
log(`r2 a=${a} b=${b} value=${value}`);
|
|
20
|
+
return a + b * value;
|
|
21
|
+
}, 0);
|
|
22
|
+
log(`render v=${value} v2=${value2}`);
|
|
23
|
+
return { dispatch, dispatch2 };
|
|
24
|
+
},
|
|
25
|
+
drive: async ({ api, act }) => {
|
|
26
|
+
await act(() => api().dispatch2(10));
|
|
27
|
+
await act(() => {
|
|
28
|
+
api().dispatch(1);
|
|
29
|
+
api().dispatch2(10);
|
|
30
|
+
});
|
|
31
|
+
await act(() => {
|
|
32
|
+
api().dispatch2(1);
|
|
33
|
+
api().dispatch(5);
|
|
34
|
+
api().dispatch2(2);
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "reducer closing over useState value updated in the same batch",
|
|
40
|
+
use: (log) => {
|
|
41
|
+
const [mode, setMode] = useState(1);
|
|
42
|
+
const [total, add] = useReducer((s: number, n: number) => {
|
|
43
|
+
log(`reducer s=${s} n=${n} mode=${mode}`);
|
|
44
|
+
return s + n * mode;
|
|
45
|
+
}, 0);
|
|
46
|
+
log(`render mode=${mode} total=${total}`);
|
|
47
|
+
return { setMode, add };
|
|
48
|
+
},
|
|
49
|
+
drive: async ({ api, act }) => {
|
|
50
|
+
await act(() => {
|
|
51
|
+
api().add(10);
|
|
52
|
+
api().setMode(100);
|
|
53
|
+
api().add(10);
|
|
54
|
+
});
|
|
55
|
+
await act(() => api().add(1));
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "same reducer dispatched three times in one batch",
|
|
60
|
+
use: (log) => {
|
|
61
|
+
const [state, dispatch] = useReducer((s: number, a: number) => {
|
|
62
|
+
log(`reducer s=${s} a=${a}`);
|
|
63
|
+
return s * 2 + a;
|
|
64
|
+
}, 1);
|
|
65
|
+
log(`render ${state}`);
|
|
66
|
+
return { dispatch };
|
|
67
|
+
},
|
|
68
|
+
drive: async ({ api, act }) => {
|
|
69
|
+
await act(() => {
|
|
70
|
+
api().dispatch(1);
|
|
71
|
+
api().dispatch(2);
|
|
72
|
+
api().dispatch(3);
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "mixed eager setState and lazy useReducer dispatch ordering",
|
|
78
|
+
use: (log) => {
|
|
79
|
+
const [a, setA] = useState(0);
|
|
80
|
+
const [b, dispatchB] = useReducer((s: number, n: number) => {
|
|
81
|
+
log(`reducerB s=${s} n=${n}`);
|
|
82
|
+
return s + n;
|
|
83
|
+
}, 0);
|
|
84
|
+
log(`render a=${a} b=${b}`);
|
|
85
|
+
return {
|
|
86
|
+
run: () => {
|
|
87
|
+
setA((prev) => {
|
|
88
|
+
log(`updaterA prev=${prev}`);
|
|
89
|
+
return prev + 1;
|
|
90
|
+
});
|
|
91
|
+
dispatchB(10);
|
|
92
|
+
setA((prev) => {
|
|
93
|
+
log(`updaterA2 prev=${prev}`);
|
|
94
|
+
return prev + 1;
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
drive: async ({ api, act }) => {
|
|
100
|
+
await act(() => api().run());
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: "render-phase updates chain until they settle",
|
|
105
|
+
use: (log) => {
|
|
106
|
+
const [count, setCount] = useState(0);
|
|
107
|
+
log(`render ${count}`);
|
|
108
|
+
if (count < 3) setCount(count + 1);
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "render-phase updater chain with a guard converges",
|
|
113
|
+
use: (log) => {
|
|
114
|
+
const [count, setCount] = useState(0);
|
|
115
|
+
log(`render ${count}`);
|
|
116
|
+
if (count < 5) {
|
|
117
|
+
setCount((c) => c + 1);
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: "render-phase updater functions apply in order",
|
|
123
|
+
use: (log) => {
|
|
124
|
+
const [count, setCount] = useState(0);
|
|
125
|
+
log(`render ${count}`);
|
|
126
|
+
if (count === 0) {
|
|
127
|
+
setCount((c) => {
|
|
128
|
+
log(`updater1 c=${c}`);
|
|
129
|
+
return c + 1;
|
|
130
|
+
});
|
|
131
|
+
setCount((c) => {
|
|
132
|
+
log(`updater2 c=${c}`);
|
|
133
|
+
return c + 10;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: "render-phase setState to the same value runs one extra pass",
|
|
140
|
+
use: (log) => {
|
|
141
|
+
const [count, setCount] = useState(0);
|
|
142
|
+
const dispatched = useRef(false);
|
|
143
|
+
log(`render ${count}`);
|
|
144
|
+
if (!dispatched.current) {
|
|
145
|
+
dispatched.current = true;
|
|
146
|
+
setCount(0);
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: "render-phase adjustment derives one state from another across updates",
|
|
152
|
+
use: (log) => {
|
|
153
|
+
const [n, setN] = useState(0);
|
|
154
|
+
const [doubled, setDoubled] = useState(0);
|
|
155
|
+
log(`render n=${n} doubled=${doubled}`);
|
|
156
|
+
if (doubled !== n * 2) setDoubled(n * 2);
|
|
157
|
+
return { bump: () => setN((c) => c + 1) };
|
|
158
|
+
},
|
|
159
|
+
drive: async ({ api, act }) => {
|
|
160
|
+
await act(() => api().bump());
|
|
161
|
+
await act(() => api().bump());
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: "render-phase dispatch to a useReducer during render",
|
|
166
|
+
use: (log) => {
|
|
167
|
+
const [count, dispatch] = useReducer((s: number, a: number) => {
|
|
168
|
+
log(`reducer s=${s} a=${a}`);
|
|
169
|
+
return s + a;
|
|
170
|
+
}, 0);
|
|
171
|
+
log(`render ${count}`);
|
|
172
|
+
if (count === 0) dispatch(5);
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: "setState inside a useMemo body is a render-phase update",
|
|
177
|
+
use: (log) => {
|
|
178
|
+
const [count, setCount] = useState(0);
|
|
179
|
+
useMemo(() => {
|
|
180
|
+
if (count === 0) setCount(1);
|
|
181
|
+
return null;
|
|
182
|
+
}, [count]);
|
|
183
|
+
log(`render ${count}`);
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
name: "bail-then-revert in one batch forces a render with the same state",
|
|
188
|
+
use: (log) => {
|
|
189
|
+
const [count, setCount] = useState(0);
|
|
190
|
+
log(`render ${count}`);
|
|
191
|
+
return {
|
|
192
|
+
bounce: () => {
|
|
193
|
+
setCount(1);
|
|
194
|
+
setCount(0);
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
drive: async ({ api, act }) => {
|
|
199
|
+
await act(() => api().bounce());
|
|
200
|
+
await act(() => api().bounce());
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
name: "NaN and negative zero: Object.is semantics for bailout",
|
|
205
|
+
use: (log) => {
|
|
206
|
+
const [value, setValue] = useState<number>(0);
|
|
207
|
+
log(`render ${Number.isNaN(value) ? "NaN" : value}`);
|
|
208
|
+
return { set: (n: number) => setValue(n) };
|
|
209
|
+
},
|
|
210
|
+
drive: async ({ api, act }) => {
|
|
211
|
+
await act(() => api().set(NaN));
|
|
212
|
+
await act(() => api().set(-0));
|
|
213
|
+
await act(() => api().set(0));
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
name: "NaN in deps: memo and effect do not refire",
|
|
218
|
+
use: (log) => {
|
|
219
|
+
const [, force] = useReducer((c: number) => c + 1, 0);
|
|
220
|
+
const dep = NaN;
|
|
221
|
+
useMemo(() => {
|
|
222
|
+
log("memo");
|
|
223
|
+
return null;
|
|
224
|
+
}, [dep]);
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
log("effect");
|
|
227
|
+
}, [dep]);
|
|
228
|
+
log("render");
|
|
229
|
+
return { force };
|
|
230
|
+
},
|
|
231
|
+
drive: async ({ api, act }) => {
|
|
232
|
+
await act(() => api().force());
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
name: "setState/dispatch function identity is stable across renders",
|
|
237
|
+
use: (log) => {
|
|
238
|
+
const [count, setCount] = useState(0);
|
|
239
|
+
const [, dispatch] = useReducer((s: number) => s + 1, 0);
|
|
240
|
+
const firstSet = useRef(setCount);
|
|
241
|
+
const firstDispatch = useRef(dispatch);
|
|
242
|
+
log(
|
|
243
|
+
`render ${count} set-stable=${firstSet.current === setCount} dispatch-stable=${firstDispatch.current === dispatch}`,
|
|
244
|
+
);
|
|
245
|
+
return { inc: () => setCount((c) => c + 1) };
|
|
246
|
+
},
|
|
247
|
+
drive: async ({ api, act }) => {
|
|
248
|
+
await act(() => api().inc());
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
name: "ref mutation during render observes strict double-render",
|
|
253
|
+
use: (log) => {
|
|
254
|
+
const renders = useRef(0);
|
|
255
|
+
renders.current++;
|
|
256
|
+
const [count, setCount] = useState(0);
|
|
257
|
+
log(`render count=${count} renders=${renders.current}`);
|
|
258
|
+
return { inc: () => setCount((c) => c + 1) };
|
|
259
|
+
},
|
|
260
|
+
drive: async ({ api, act }) => {
|
|
261
|
+
await act(() => api().inc());
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
name: "two dispatches in separate microtasks batch into one render",
|
|
266
|
+
use: (log) => {
|
|
267
|
+
const [count, setCount] = useState(0);
|
|
268
|
+
log(`render ${count}`);
|
|
269
|
+
return { inc: () => setCount((c) => c + 1) };
|
|
270
|
+
},
|
|
271
|
+
drive: async ({ api, settle }) => {
|
|
272
|
+
api().inc();
|
|
273
|
+
await Promise.resolve();
|
|
274
|
+
api().inc();
|
|
275
|
+
await settle();
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
name: "two dispatches in separate macrotasks render separately",
|
|
280
|
+
use: (log) => {
|
|
281
|
+
const [count, setCount] = useState(0);
|
|
282
|
+
log(`render ${count}`);
|
|
283
|
+
return { inc: () => setCount((c) => c + 1) };
|
|
284
|
+
},
|
|
285
|
+
drive: async ({ api, settle }) => {
|
|
286
|
+
api().inc();
|
|
287
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
288
|
+
api().inc();
|
|
289
|
+
await settle();
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
name: "update render: cleanup/setup ordering across two effects",
|
|
294
|
+
use: (log) => {
|
|
295
|
+
const [n, setN] = useState(0);
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
log(`setup-1 n=${n}`);
|
|
298
|
+
return () => log(`cleanup-1 n=${n}`);
|
|
299
|
+
}, [n]);
|
|
300
|
+
useEffect(() => {
|
|
301
|
+
log(`setup-2 n=${n}`);
|
|
302
|
+
return () => log(`cleanup-2 n=${n}`);
|
|
303
|
+
}, [n]);
|
|
304
|
+
return { bump: () => setN((c) => c + 1) };
|
|
305
|
+
},
|
|
306
|
+
drive: async ({ api, act }) => {
|
|
307
|
+
await act(() => api().bump());
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
name: "effect deps array grows between renders",
|
|
312
|
+
use: (log) => {
|
|
313
|
+
const [n, setN] = useState(0);
|
|
314
|
+
const deps = n === 0 ? [1] : [1, 2];
|
|
315
|
+
useEffect(() => {
|
|
316
|
+
log(`effect n=${n}`);
|
|
317
|
+
return () => log(`cleanup n=${n}`);
|
|
318
|
+
}, deps);
|
|
319
|
+
log(`render ${n}`);
|
|
320
|
+
return { bump: () => setN((c) => c + 1) };
|
|
321
|
+
},
|
|
322
|
+
drive: async ({ api, act }) => {
|
|
323
|
+
await act(() => api().bump());
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
name: "dispatch from an effect cleanup on deps change",
|
|
328
|
+
use: (log) => {
|
|
329
|
+
const [n, setN] = useState(0);
|
|
330
|
+
const [extra, bumpExtra] = useReducer((c: number) => c + 1, 0);
|
|
331
|
+
log(`render n=${n} extra=${extra}`);
|
|
332
|
+
useEffect(() => {
|
|
333
|
+
log(`setup n=${n}`);
|
|
334
|
+
return () => {
|
|
335
|
+
log(`cleanup n=${n}`);
|
|
336
|
+
bumpExtra();
|
|
337
|
+
};
|
|
338
|
+
}, [n]);
|
|
339
|
+
return { bump: () => setN((c) => c + 1) };
|
|
340
|
+
},
|
|
341
|
+
drive: async ({ api, act }) => {
|
|
342
|
+
await act(() => api().bump());
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
name: "cascading updates: effect chain until fixpoint",
|
|
347
|
+
use: (log) => {
|
|
348
|
+
const [n, setN] = useState(0);
|
|
349
|
+
log(`render ${n}`);
|
|
350
|
+
useEffect(() => {
|
|
351
|
+
log(`effect ${n}`);
|
|
352
|
+
if (n < 3) setN(n + 1);
|
|
353
|
+
}, [n]);
|
|
354
|
+
},
|
|
355
|
+
drive: ({ settle }) => settle(),
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
name: "memo depending on reducer state recomputes exactly once per change",
|
|
359
|
+
use: (log) => {
|
|
360
|
+
const [n, dispatch] = useReducer((s: number, a: number) => s + a, 0);
|
|
361
|
+
const doubled = useMemo(() => {
|
|
362
|
+
log(`memo n=${n}`);
|
|
363
|
+
return n * 2;
|
|
364
|
+
}, [n]);
|
|
365
|
+
log(`render n=${n} doubled=${doubled}`);
|
|
366
|
+
return { dispatch };
|
|
367
|
+
},
|
|
368
|
+
drive: async ({ api, act }) => {
|
|
369
|
+
await act(() => api().dispatch(1));
|
|
370
|
+
await act(() => api().dispatch(0));
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
];
|
|
374
|
+
|
|
375
|
+
describeParity(scenarios);
|