@assistant-ui/tap 0.3.6 → 0.4.2
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 +24 -23
- package/dist/core/ResourceFiber.d.ts +1 -1
- package/dist/core/ResourceFiber.d.ts.map +1 -1
- package/dist/core/ResourceFiber.js +15 -8
- package/dist/core/ResourceFiber.js.map +1 -1
- package/dist/core/commit.d.ts +1 -1
- package/dist/core/commit.d.ts.map +1 -1
- package/dist/core/commit.js +40 -50
- package/dist/core/commit.js.map +1 -1
- package/dist/core/context.d.ts +2 -2
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/context.js +2 -2
- package/dist/core/context.js.map +1 -1
- package/dist/core/createResource.d.ts +3 -2
- package/dist/core/createResource.d.ts.map +1 -1
- package/dist/core/createResource.js +48 -22
- package/dist/core/createResource.js.map +1 -1
- package/dist/core/env.d.ts +2 -0
- package/dist/core/env.d.ts.map +1 -0
- package/dist/core/env.js +3 -0
- package/dist/core/env.js.map +1 -0
- package/dist/core/execution-context.d.ts +1 -0
- package/dist/core/execution-context.d.ts.map +1 -1
- package/dist/core/execution-context.js +8 -0
- package/dist/core/execution-context.js.map +1 -1
- package/dist/core/resource.d.ts +4 -3
- package/dist/core/resource.d.ts.map +1 -1
- package/dist/core/resource.js.map +1 -1
- package/dist/core/scheduler.d.ts +1 -1
- package/dist/core/scheduler.d.ts.map +1 -1
- package/dist/core/scheduler.js +4 -1
- package/dist/core/scheduler.js.map +1 -1
- package/dist/core/types.d.ts +22 -21
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/core/withKey.d.ts +3 -0
- package/dist/core/withKey.d.ts.map +1 -0
- package/dist/core/withKey.js +4 -0
- package/dist/core/withKey.js.map +1 -0
- package/dist/hooks/tap-callback.d.ts.map +1 -1
- package/dist/hooks/tap-callback.js +1 -0
- package/dist/hooks/tap-callback.js.map +1 -1
- package/dist/hooks/tap-const.d.ts +2 -0
- package/dist/hooks/tap-const.d.ts.map +1 -0
- package/dist/hooks/tap-const.js +6 -0
- package/dist/hooks/tap-const.js.map +1 -0
- package/dist/hooks/tap-effect-event.d.ts.map +1 -1
- package/dist/hooks/tap-effect-event.js +11 -0
- package/dist/hooks/tap-effect-event.js.map +1 -1
- package/dist/hooks/tap-effect.d.ts.map +1 -1
- package/dist/hooks/tap-effect.js +46 -31
- package/dist/hooks/tap-effect.js.map +1 -1
- package/dist/hooks/tap-inline-resource.d.ts +2 -2
- package/dist/hooks/tap-inline-resource.d.ts.map +1 -1
- package/dist/hooks/tap-memo.d.ts.map +1 -1
- package/dist/hooks/tap-memo.js +9 -1
- package/dist/hooks/tap-memo.js.map +1 -1
- package/dist/hooks/tap-resource.d.ts +3 -3
- package/dist/hooks/tap-resource.d.ts.map +1 -1
- package/dist/hooks/tap-resource.js +17 -9
- package/dist/hooks/tap-resource.js.map +1 -1
- package/dist/hooks/tap-resources.d.ts +2 -10
- package/dist/hooks/tap-resources.d.ts.map +1 -1
- package/dist/hooks/tap-resources.js +74 -43
- package/dist/hooks/tap-resources.js.map +1 -1
- package/dist/hooks/tap-state.d.ts.map +1 -1
- package/dist/hooks/tap-state.js +37 -24
- package/dist/hooks/tap-state.js.map +1 -1
- package/dist/hooks/utils/depsShallowEqual.d.ts.map +1 -0
- package/dist/hooks/utils/depsShallowEqual.js.map +1 -0
- package/dist/hooks/utils/tapHook.d.ts +6 -0
- package/dist/hooks/utils/tapHook.d.ts.map +1 -0
- package/dist/hooks/utils/tapHook.js +24 -0
- package/dist/hooks/utils/tapHook.js.map +1 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/react/use-resource.d.ts +2 -2
- package/dist/react/use-resource.d.ts.map +1 -1
- package/dist/react/use-resource.js +24 -10
- package/dist/react/use-resource.js.map +1 -1
- package/package.json +10 -3
- package/src/__tests__/basic/resourceHandle.test.ts +4 -4
- package/src/__tests__/basic/tapEffect.basic.test.ts +3 -2
- package/src/__tests__/basic/tapResources.basic.test.ts +84 -64
- package/src/__tests__/basic/tapState.basic.test.ts +8 -8
- package/src/__tests__/errors/errors.effect-errors.test.ts +8 -3
- package/src/__tests__/lifecycle/lifecycle.dependencies.test.ts +3 -2
- package/src/__tests__/lifecycle/lifecycle.mount-unmount.test.ts +2 -2
- package/src/__tests__/react/concurrent-mode.test.tsx +243 -0
- package/src/__tests__/strictmode/react-strictmode-behavior.test.tsx +709 -0
- package/src/__tests__/strictmode/react-strictmode-rerender-sources.test.tsx +392 -0
- package/src/__tests__/strictmode/strictmode.test.ts +274 -0
- package/src/__tests__/strictmode/tap-strictmode-rerender-sources.test.ts +723 -0
- package/src/__tests__/test-utils.ts +8 -6
- package/src/core/ResourceFiber.ts +21 -11
- package/src/core/commit.ts +37 -57
- package/src/core/context.ts +2 -2
- package/src/core/createResource.ts +64 -25
- package/src/core/env.ts +3 -0
- package/src/core/execution-context.ts +9 -0
- package/src/core/resource.ts +9 -3
- package/src/core/scheduler.ts +4 -1
- package/src/core/types.ts +25 -26
- package/src/core/withKey.ts +8 -0
- package/src/hooks/tap-callback.ts +1 -0
- package/src/hooks/tap-const.ts +6 -0
- package/src/hooks/tap-effect-event.ts +15 -0
- package/src/hooks/tap-effect.ts +51 -38
- package/src/hooks/tap-inline-resource.ts +2 -2
- package/src/hooks/tap-memo.ts +10 -1
- package/src/hooks/tap-resource.ts +24 -20
- package/src/hooks/tap-resources.ts +86 -63
- package/src/hooks/tap-state.ts +49 -26
- package/src/hooks/utils/tapHook.ts +35 -0
- package/src/index.ts +8 -3
- package/src/react/use-resource.ts +27 -16
- package/dist/hooks/depsShallowEqual.d.ts.map +0 -1
- package/dist/hooks/depsShallowEqual.js.map +0 -1
- /package/dist/hooks/{depsShallowEqual.d.ts → utils/depsShallowEqual.d.ts} +0 -0
- /package/dist/hooks/{depsShallowEqual.js → utils/depsShallowEqual.js} +0 -0
- /package/src/hooks/{depsShallowEqual.ts → utils/depsShallowEqual.ts} +0 -0
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests to verify React's strict mode behavior
|
|
3
|
+
* These tests verify React's own behavior, not tap's implementation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect } from "vitest";
|
|
7
|
+
import { render } from "@testing-library/react";
|
|
8
|
+
import { StrictMode, useState, useEffect, useMemo, useRef } from "react";
|
|
9
|
+
|
|
10
|
+
describe("React Strict Mode Behavior Verification", () => {
|
|
11
|
+
describe("Test 1: Effect + setState behavior in strict mode", () => {
|
|
12
|
+
it("should mount, setState in effect, unmount, remount with OLD state, then rerender with NEW state", () => {
|
|
13
|
+
const events: string[] = [];
|
|
14
|
+
|
|
15
|
+
function TestComponent() {
|
|
16
|
+
const [count, setCount] = useState(() => {
|
|
17
|
+
events.push("useState init");
|
|
18
|
+
return 0;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
events.push(`render count=${count}`);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
events.push(`effect mount count=${count}`);
|
|
25
|
+
if (count === 0) {
|
|
26
|
+
setCount(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return () => {
|
|
30
|
+
events.push(`effect cleanup count=${count}`);
|
|
31
|
+
};
|
|
32
|
+
}, [count]);
|
|
33
|
+
|
|
34
|
+
return <div>Count: {count}</div>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
render(
|
|
38
|
+
<StrictMode>
|
|
39
|
+
<TestComponent />
|
|
40
|
+
</StrictMode>,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// ACTUAL React behavior observed:
|
|
44
|
+
// 1. Render twice (double-render): useState init called twice
|
|
45
|
+
// 2. Effect mounts with count=0 and calls setState(1)
|
|
46
|
+
// 3. Effect unmounts (strict mode)
|
|
47
|
+
// 4. Effect remounts with count=0 and calls setState(1)
|
|
48
|
+
// 5. setState causes rerender with count=1 (double-render)
|
|
49
|
+
// 6. Effect with [count] deps reruns, cleanup old effect, mount new
|
|
50
|
+
|
|
51
|
+
expect(events).toEqual([
|
|
52
|
+
"useState init",
|
|
53
|
+
"useState init",
|
|
54
|
+
"render count=0",
|
|
55
|
+
"render count=0",
|
|
56
|
+
"effect mount count=0",
|
|
57
|
+
"effect cleanup count=0",
|
|
58
|
+
"effect mount count=0",
|
|
59
|
+
"render count=1",
|
|
60
|
+
"render count=1",
|
|
61
|
+
"effect cleanup count=0",
|
|
62
|
+
"effect mount count=1",
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("should show that setState in effect during mount is applied after strict mode cycle", () => {
|
|
67
|
+
const events: string[] = [];
|
|
68
|
+
|
|
69
|
+
function TestComponent() {
|
|
70
|
+
const [value, setValue] = useState("initial");
|
|
71
|
+
|
|
72
|
+
events.push(`render value=${value}`);
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
events.push(`effect mount value=${value}`);
|
|
76
|
+
if (value === "initial") {
|
|
77
|
+
setValue("updated");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return () => {
|
|
81
|
+
events.push(`effect cleanup value=${value}`);
|
|
82
|
+
};
|
|
83
|
+
}, [value]);
|
|
84
|
+
|
|
85
|
+
return <div>{value}</div>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
render(
|
|
89
|
+
<StrictMode>
|
|
90
|
+
<TestComponent />
|
|
91
|
+
</StrictMode>,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// ACTUAL React behavior observed:
|
|
95
|
+
// 1. Double-render with value=initial (no useState init log because it's a constant)
|
|
96
|
+
// 2. Effect mounts and calls setValue
|
|
97
|
+
// 3. Effect unmounts (strict mode)
|
|
98
|
+
// 4. Effect remounts with value=initial and calls setValue
|
|
99
|
+
// 5. setState causes rerender with value=updated (double-render)
|
|
100
|
+
// 6. Effect with [value] deps reruns, cleanup old effect, mount new
|
|
101
|
+
|
|
102
|
+
expect(events).toEqual([
|
|
103
|
+
"render value=initial",
|
|
104
|
+
"render value=initial",
|
|
105
|
+
"effect mount value=initial",
|
|
106
|
+
"effect cleanup value=initial",
|
|
107
|
+
"effect mount value=initial",
|
|
108
|
+
"render value=updated",
|
|
109
|
+
"render value=updated",
|
|
110
|
+
"effect cleanup value=initial",
|
|
111
|
+
"effect mount value=updated",
|
|
112
|
+
]);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("Test 2: Render/commit sequence with useState and useMemo", () => {
|
|
117
|
+
it("should show the sequence: render → useState init (dropped) → useMemo (dropped) → render → commit → commit(stale?) → render → commit", () => {
|
|
118
|
+
const events: string[] = [];
|
|
119
|
+
|
|
120
|
+
function TestComponent() {
|
|
121
|
+
const renderCount = useRef(0);
|
|
122
|
+
renderCount.current++;
|
|
123
|
+
|
|
124
|
+
events.push(`render #${renderCount.current}`);
|
|
125
|
+
|
|
126
|
+
const [state] = useState(() => {
|
|
127
|
+
events.push(`useState init #${renderCount.current}`);
|
|
128
|
+
return "state";
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const memoValue = useMemo(() => {
|
|
132
|
+
events.push(`useMemo #${renderCount.current}`);
|
|
133
|
+
return `memo-${renderCount.current}`;
|
|
134
|
+
}, []);
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
events.push(`effect commit #${renderCount.current} state=${state}`);
|
|
138
|
+
return () => {
|
|
139
|
+
events.push(`effect cleanup #${renderCount.current}`);
|
|
140
|
+
};
|
|
141
|
+
}, [state]);
|
|
142
|
+
|
|
143
|
+
return <div>{memoValue}</div>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
render(
|
|
147
|
+
<StrictMode>
|
|
148
|
+
<TestComponent />
|
|
149
|
+
</StrictMode>,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// ACTUAL React behavior observed:
|
|
153
|
+
// 1. Renders twice (double-render): both useState and useMemo called twice
|
|
154
|
+
// 2. The state/memo results are NOT dropped - both are kept
|
|
155
|
+
// 3. Commits the effects once
|
|
156
|
+
// 4. Unmounts and remounts effects (strict mode)
|
|
157
|
+
|
|
158
|
+
expect(events).toEqual([
|
|
159
|
+
"render #1",
|
|
160
|
+
"useState init #1",
|
|
161
|
+
"useState init #1",
|
|
162
|
+
"useMemo #1",
|
|
163
|
+
"useMemo #1",
|
|
164
|
+
"render #2",
|
|
165
|
+
"effect commit #2 state=state",
|
|
166
|
+
"effect cleanup #2",
|
|
167
|
+
"effect commit #2 state=state",
|
|
168
|
+
]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("should verify that useState initializer is called twice but second value is used", () => {
|
|
172
|
+
const events: string[] = [];
|
|
173
|
+
let initCallCount = 0;
|
|
174
|
+
|
|
175
|
+
function TestComponent() {
|
|
176
|
+
const [value] = useState(() => {
|
|
177
|
+
initCallCount++;
|
|
178
|
+
events.push(`useState init call #${initCallCount}`);
|
|
179
|
+
return initCallCount;
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
events.push(`render value=${value}`);
|
|
183
|
+
|
|
184
|
+
return <div>{value}</div>;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
render(
|
|
188
|
+
<StrictMode>
|
|
189
|
+
<TestComponent />
|
|
190
|
+
</StrictMode>,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// ACTUAL React behavior: useState initializer is called twice,
|
|
194
|
+
// but the FIRST value is kept (not the second)!
|
|
195
|
+
expect(events).toEqual([
|
|
196
|
+
"useState init call #1",
|
|
197
|
+
"useState init call #2",
|
|
198
|
+
"render value=1",
|
|
199
|
+
"render value=1",
|
|
200
|
+
]);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("Test 3: Component tree vs per-component remounting", () => {
|
|
205
|
+
it("should show whether React remounts entire tree or per-component", () => {
|
|
206
|
+
const events: string[] = [];
|
|
207
|
+
|
|
208
|
+
function Parent() {
|
|
209
|
+
events.push("Parent render");
|
|
210
|
+
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
events.push("Parent effect mount");
|
|
213
|
+
return () => {
|
|
214
|
+
events.push("Parent effect cleanup");
|
|
215
|
+
};
|
|
216
|
+
}, []);
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<div>
|
|
220
|
+
<Child1 />
|
|
221
|
+
<Child2 />
|
|
222
|
+
</div>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function Child1() {
|
|
227
|
+
events.push("Child1 render");
|
|
228
|
+
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
events.push("Child1 effect mount");
|
|
231
|
+
return () => {
|
|
232
|
+
events.push("Child1 effect cleanup");
|
|
233
|
+
};
|
|
234
|
+
}, []);
|
|
235
|
+
|
|
236
|
+
return <div>Child1</div>;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function Child2() {
|
|
240
|
+
events.push("Child2 render");
|
|
241
|
+
|
|
242
|
+
useEffect(() => {
|
|
243
|
+
events.push("Child2 effect mount");
|
|
244
|
+
return () => {
|
|
245
|
+
events.push("Child2 effect cleanup");
|
|
246
|
+
};
|
|
247
|
+
}, []);
|
|
248
|
+
|
|
249
|
+
return <div>Child2</div>;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
render(
|
|
253
|
+
<StrictMode>
|
|
254
|
+
<Parent />
|
|
255
|
+
</StrictMode>,
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
// ACTUAL React behavior:
|
|
259
|
+
// 1. Parent renders twice, then each child renders twice
|
|
260
|
+
// 2. Effects mount in child-to-parent order (children first, then parent)
|
|
261
|
+
// 3. Then unmounts all effects and remounts all (strict mode)
|
|
262
|
+
|
|
263
|
+
expect(events).toEqual([
|
|
264
|
+
"Parent render",
|
|
265
|
+
"Parent render",
|
|
266
|
+
"Child1 render",
|
|
267
|
+
"Child1 render",
|
|
268
|
+
"Child2 render",
|
|
269
|
+
"Child2 render",
|
|
270
|
+
"Child1 effect mount",
|
|
271
|
+
"Child2 effect mount",
|
|
272
|
+
"Parent effect mount",
|
|
273
|
+
"Parent effect cleanup",
|
|
274
|
+
"Child1 effect cleanup",
|
|
275
|
+
"Child2 effect cleanup",
|
|
276
|
+
"Child1 effect mount",
|
|
277
|
+
"Child2 effect mount",
|
|
278
|
+
"Parent effect mount",
|
|
279
|
+
]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("should verify that nested components follow the same pattern with state updates", () => {
|
|
283
|
+
const events: string[] = [];
|
|
284
|
+
|
|
285
|
+
function Parent() {
|
|
286
|
+
const [parentState, setParentState] = useState(0);
|
|
287
|
+
events.push(`Parent render state=${parentState}`);
|
|
288
|
+
|
|
289
|
+
useEffect(() => {
|
|
290
|
+
events.push(`Parent effect mount state=${parentState}`);
|
|
291
|
+
if (parentState === 0) {
|
|
292
|
+
setParentState(1);
|
|
293
|
+
}
|
|
294
|
+
return () => {
|
|
295
|
+
events.push(`Parent effect cleanup state=${parentState}`);
|
|
296
|
+
};
|
|
297
|
+
}, [parentState]);
|
|
298
|
+
|
|
299
|
+
return (
|
|
300
|
+
<div>
|
|
301
|
+
<Child parentState={parentState} />
|
|
302
|
+
</div>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function Child({ parentState }: { parentState: number }) {
|
|
307
|
+
events.push(`Child render parentState=${parentState}`);
|
|
308
|
+
|
|
309
|
+
useEffect(() => {
|
|
310
|
+
events.push(`Child effect mount parentState=${parentState}`);
|
|
311
|
+
return () => {
|
|
312
|
+
events.push(`Child effect cleanup parentState=${parentState}`);
|
|
313
|
+
};
|
|
314
|
+
}, [parentState]);
|
|
315
|
+
|
|
316
|
+
return <div>Child</div>;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
render(
|
|
320
|
+
<StrictMode>
|
|
321
|
+
<Parent />
|
|
322
|
+
</StrictMode>,
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
// ACTUAL React behavior:
|
|
326
|
+
// 1. Parent double-render, child double-render
|
|
327
|
+
// 2. Effects mount in child-to-parent order
|
|
328
|
+
// 3. Unmount/remount all (strict mode)
|
|
329
|
+
// 4. State update causes parent double-render, child double-render
|
|
330
|
+
// 5. Effects update (no remount), child-to-parent order
|
|
331
|
+
|
|
332
|
+
expect(events).toEqual([
|
|
333
|
+
"Parent render state=0",
|
|
334
|
+
"Parent render state=0",
|
|
335
|
+
"Child render parentState=0",
|
|
336
|
+
"Child render parentState=0",
|
|
337
|
+
"Child effect mount parentState=0",
|
|
338
|
+
"Parent effect mount state=0",
|
|
339
|
+
"Parent effect cleanup state=0",
|
|
340
|
+
"Child effect cleanup parentState=0",
|
|
341
|
+
"Child effect mount parentState=0",
|
|
342
|
+
"Parent effect mount state=0",
|
|
343
|
+
"Parent render state=1",
|
|
344
|
+
"Parent render state=1",
|
|
345
|
+
"Child render parentState=1",
|
|
346
|
+
"Child render parentState=1",
|
|
347
|
+
"Child effect cleanup parentState=0",
|
|
348
|
+
"Parent effect cleanup state=0",
|
|
349
|
+
"Child effect mount parentState=1",
|
|
350
|
+
"Parent effect mount state=1",
|
|
351
|
+
]);
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
describe("Test 4: Delayed mount behavior (subtree mounted after initial render)", () => {
|
|
356
|
+
it("should show behavior when a subtree is mounted after initial render completes", () => {
|
|
357
|
+
const events: string[] = [];
|
|
358
|
+
|
|
359
|
+
function Parent() {
|
|
360
|
+
const [showChild, setShowChild] = useState(false);
|
|
361
|
+
events.push(`Parent render showChild=${showChild}`);
|
|
362
|
+
|
|
363
|
+
useEffect(() => {
|
|
364
|
+
events.push(`Parent effect mount showChild=${showChild}`);
|
|
365
|
+
if (!showChild) {
|
|
366
|
+
setShowChild(true);
|
|
367
|
+
}
|
|
368
|
+
return () => {
|
|
369
|
+
events.push(`Parent effect cleanup showChild=${showChild}`);
|
|
370
|
+
};
|
|
371
|
+
}, [showChild]);
|
|
372
|
+
|
|
373
|
+
return (
|
|
374
|
+
<div>
|
|
375
|
+
Parent
|
|
376
|
+
{showChild && <DelayedChild />}
|
|
377
|
+
</div>
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function DelayedChild() {
|
|
382
|
+
events.push("DelayedChild render");
|
|
383
|
+
|
|
384
|
+
useEffect(() => {
|
|
385
|
+
events.push("DelayedChild effect mount");
|
|
386
|
+
return () => {
|
|
387
|
+
events.push("DelayedChild effect cleanup");
|
|
388
|
+
};
|
|
389
|
+
}, []);
|
|
390
|
+
|
|
391
|
+
return <div>Child</div>;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
render(
|
|
395
|
+
<StrictMode>
|
|
396
|
+
<Parent />
|
|
397
|
+
</StrictMode>,
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
// ACTUAL React behavior: Components added after initial render
|
|
401
|
+
// still get double-render and strict mode double-mount
|
|
402
|
+
|
|
403
|
+
expect(events).toEqual([
|
|
404
|
+
"Parent render showChild=false",
|
|
405
|
+
"Parent render showChild=false",
|
|
406
|
+
"Parent effect mount showChild=false",
|
|
407
|
+
"Parent effect cleanup showChild=false",
|
|
408
|
+
"Parent effect mount showChild=false",
|
|
409
|
+
"Parent render showChild=true",
|
|
410
|
+
"Parent render showChild=true",
|
|
411
|
+
"DelayedChild render",
|
|
412
|
+
"DelayedChild render",
|
|
413
|
+
"Parent effect cleanup showChild=false",
|
|
414
|
+
"DelayedChild effect mount",
|
|
415
|
+
"Parent effect mount showChild=true",
|
|
416
|
+
"DelayedChild effect cleanup",
|
|
417
|
+
"DelayedChild effect mount",
|
|
418
|
+
]);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("should verify that subtree mounted later still gets strict mode treatment", () => {
|
|
422
|
+
const events: string[] = [];
|
|
423
|
+
|
|
424
|
+
function Root() {
|
|
425
|
+
const [mounted, setMounted] = useState(false);
|
|
426
|
+
events.push(`Root render mounted=${mounted}`);
|
|
427
|
+
|
|
428
|
+
useEffect(() => {
|
|
429
|
+
events.push(`Root effect mount mounted=${mounted}`);
|
|
430
|
+
if (!mounted) {
|
|
431
|
+
// Mount the subtree after the first effect runs
|
|
432
|
+
setMounted(true);
|
|
433
|
+
}
|
|
434
|
+
return () => {
|
|
435
|
+
events.push(`Root effect cleanup mounted=${mounted}`);
|
|
436
|
+
};
|
|
437
|
+
}, [mounted]);
|
|
438
|
+
|
|
439
|
+
return <div>{mounted && <LateComponent />}</div>;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function LateComponent() {
|
|
443
|
+
const [state, setState] = useState("initial");
|
|
444
|
+
events.push(`LateComponent render state=${state}`);
|
|
445
|
+
|
|
446
|
+
useEffect(() => {
|
|
447
|
+
events.push(`LateComponent effect mount state=${state}`);
|
|
448
|
+
if (state === "initial") {
|
|
449
|
+
setState("updated");
|
|
450
|
+
}
|
|
451
|
+
return () => {
|
|
452
|
+
events.push(`LateComponent effect cleanup state=${state}`);
|
|
453
|
+
};
|
|
454
|
+
}, [state]);
|
|
455
|
+
|
|
456
|
+
return <div>{state}</div>;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
render(
|
|
460
|
+
<StrictMode>
|
|
461
|
+
<Root />
|
|
462
|
+
</StrictMode>,
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
// ACTUAL React behavior: Components added after initial strict mode cycle
|
|
466
|
+
// still get the double-render and double-mount treatment,
|
|
467
|
+
// but setState only causes double-render (no remount)
|
|
468
|
+
|
|
469
|
+
expect(events).toEqual([
|
|
470
|
+
"Root render mounted=false",
|
|
471
|
+
"Root render mounted=false",
|
|
472
|
+
"Root effect mount mounted=false",
|
|
473
|
+
"Root effect cleanup mounted=false",
|
|
474
|
+
"Root effect mount mounted=false",
|
|
475
|
+
"Root render mounted=true",
|
|
476
|
+
"Root render mounted=true",
|
|
477
|
+
"LateComponent render state=initial",
|
|
478
|
+
"LateComponent render state=initial",
|
|
479
|
+
"Root effect cleanup mounted=false",
|
|
480
|
+
"LateComponent effect mount state=initial",
|
|
481
|
+
"Root effect mount mounted=true",
|
|
482
|
+
"LateComponent effect cleanup state=initial",
|
|
483
|
+
"LateComponent effect mount state=initial",
|
|
484
|
+
"LateComponent render state=updated",
|
|
485
|
+
"LateComponent render state=updated",
|
|
486
|
+
"LateComponent effect cleanup state=initial",
|
|
487
|
+
"LateComponent effect mount state=updated",
|
|
488
|
+
]);
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
describe("Test 5: setState in effect - strict mode edge cases", () => {
|
|
493
|
+
it("should verify which setState is applied when effect calls setState only on first mount", () => {
|
|
494
|
+
const events: string[] = [];
|
|
495
|
+
let effectRunCount = 0;
|
|
496
|
+
|
|
497
|
+
function TestComponent() {
|
|
498
|
+
const [count, setCount] = useState(0);
|
|
499
|
+
events.push(`render count=${count}`);
|
|
500
|
+
|
|
501
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: testing strict mode behavior with intentionally incomplete deps
|
|
502
|
+
useEffect(() => {
|
|
503
|
+
effectRunCount++;
|
|
504
|
+
events.push(`effect mount #${effectRunCount} count=${count}`);
|
|
505
|
+
|
|
506
|
+
// Only call setState on the FIRST mount, not the remount
|
|
507
|
+
if (effectRunCount === 1) {
|
|
508
|
+
events.push(`setState(1) called in effect #${effectRunCount}`);
|
|
509
|
+
setCount(1);
|
|
510
|
+
} else {
|
|
511
|
+
events.push(`no setState in effect #${effectRunCount}`);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return () => {
|
|
515
|
+
events.push(`effect cleanup #${effectRunCount} count=${count}`);
|
|
516
|
+
};
|
|
517
|
+
}, []);
|
|
518
|
+
|
|
519
|
+
return <div>{count}</div>;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
render(
|
|
523
|
+
<StrictMode>
|
|
524
|
+
<TestComponent />
|
|
525
|
+
</StrictMode>,
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
// KEY FINDING: React DOES apply the setState(1) from effect #1,
|
|
529
|
+
// even though it was called in an effect that was cleaned up!
|
|
530
|
+
// The state update is queued and processed after the strict mode cycle.
|
|
531
|
+
expect(events).toEqual([
|
|
532
|
+
"render count=0",
|
|
533
|
+
"render count=0",
|
|
534
|
+
"effect mount #1 count=0",
|
|
535
|
+
"setState(1) called in effect #1",
|
|
536
|
+
"effect cleanup #1 count=0",
|
|
537
|
+
"effect mount #2 count=0",
|
|
538
|
+
"no setState in effect #2",
|
|
539
|
+
"render count=1", // setState(1) was applied!
|
|
540
|
+
"render count=1",
|
|
541
|
+
]);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("should verify which setState is applied when both effect mounts call setState with different values", () => {
|
|
545
|
+
const events: string[] = [];
|
|
546
|
+
let effectRunCount = 0;
|
|
547
|
+
|
|
548
|
+
function TestComponent() {
|
|
549
|
+
const [count, setCount] = useState(0);
|
|
550
|
+
events.push(`render count=${count}`);
|
|
551
|
+
|
|
552
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: testing strict mode behavior with intentionally incomplete deps
|
|
553
|
+
useEffect(() => {
|
|
554
|
+
effectRunCount++;
|
|
555
|
+
events.push(`effect mount #${effectRunCount} count=${count}`);
|
|
556
|
+
|
|
557
|
+
if (effectRunCount === 1) {
|
|
558
|
+
events.push(`setState(1) called in effect #${effectRunCount}`);
|
|
559
|
+
setCount(1);
|
|
560
|
+
} else if (effectRunCount === 2) {
|
|
561
|
+
events.push(`setState(2) called in effect #${effectRunCount}`);
|
|
562
|
+
setCount(2);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return () => {
|
|
566
|
+
events.push(`effect cleanup #${effectRunCount} count=${count}`);
|
|
567
|
+
};
|
|
568
|
+
}, []);
|
|
569
|
+
|
|
570
|
+
return <div>{count}</div>;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
render(
|
|
574
|
+
<StrictMode>
|
|
575
|
+
<TestComponent />
|
|
576
|
+
</StrictMode>,
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
// KEY FINDING: React applies the LAST setState (setState(2)),
|
|
580
|
+
// not the first one or both. The state updates are batched and
|
|
581
|
+
// the later one overwrites the earlier one.
|
|
582
|
+
expect(events).toEqual([
|
|
583
|
+
"render count=0",
|
|
584
|
+
"render count=0",
|
|
585
|
+
"effect mount #1 count=0",
|
|
586
|
+
"setState(1) called in effect #1",
|
|
587
|
+
"effect cleanup #1 count=0",
|
|
588
|
+
"effect mount #2 count=0",
|
|
589
|
+
"setState(2) called in effect #2",
|
|
590
|
+
"render count=2", // Only setState(2) was applied!
|
|
591
|
+
"render count=2",
|
|
592
|
+
]);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it("should verify setState callback execution during strict mode", () => {
|
|
596
|
+
const events: string[] = [];
|
|
597
|
+
let effectRunCount = 0;
|
|
598
|
+
|
|
599
|
+
function TestComponent() {
|
|
600
|
+
const [count, setCount] = useState(0);
|
|
601
|
+
events.push(`render count=${count}`);
|
|
602
|
+
|
|
603
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: testing strict mode behavior with intentionally incomplete deps
|
|
604
|
+
useEffect(() => {
|
|
605
|
+
effectRunCount++;
|
|
606
|
+
events.push(`effect mount #${effectRunCount} count=${count}`);
|
|
607
|
+
|
|
608
|
+
// Use updater function to see if it's called once or twice
|
|
609
|
+
setCount((prev) => {
|
|
610
|
+
events.push(
|
|
611
|
+
`setState updater called with prev=${prev} in effect #${effectRunCount}`,
|
|
612
|
+
);
|
|
613
|
+
return prev + effectRunCount;
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
return () => {
|
|
617
|
+
events.push(`effect cleanup #${effectRunCount} count=${count}`);
|
|
618
|
+
};
|
|
619
|
+
}, []);
|
|
620
|
+
|
|
621
|
+
return <div>{count}</div>;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
render(
|
|
625
|
+
<StrictMode>
|
|
626
|
+
<TestComponent />
|
|
627
|
+
</StrictMode>,
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
// KEY FINDING: Both updater functions are queued and executed!
|
|
631
|
+
// Effect #1: updater(0) => 0 + 1 = 1
|
|
632
|
+
// Effect #2: updater(0) => 0 + 2 = 2
|
|
633
|
+
// But then the updater from effect #2 runs TWICE MORE with prev=1
|
|
634
|
+
// due to strict mode doubling the updater call itself!
|
|
635
|
+
// Final calculation: 0 -> 1 (from effect #1) -> 3 (from effect #2: 1+2)
|
|
636
|
+
expect(events).toEqual([
|
|
637
|
+
"render count=0",
|
|
638
|
+
"render count=0",
|
|
639
|
+
"effect mount #1 count=0",
|
|
640
|
+
"setState updater called with prev=0 in effect #1",
|
|
641
|
+
"effect cleanup #1 count=0",
|
|
642
|
+
"effect mount #2 count=0",
|
|
643
|
+
"setState updater called with prev=0 in effect #2",
|
|
644
|
+
"setState updater called with prev=1 in effect #2", // Updater doubled!
|
|
645
|
+
"setState updater called with prev=1 in effect #2", // Updater doubled again!
|
|
646
|
+
"render count=3", // Final: 0 -> 1 -> 3
|
|
647
|
+
"render count=3",
|
|
648
|
+
]);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it("should use the SECOND return value when updater is called twice in strict mode", () => {
|
|
652
|
+
const events: string[] = [];
|
|
653
|
+
let updaterCallCount = 0;
|
|
654
|
+
|
|
655
|
+
function TestComponent() {
|
|
656
|
+
const [count, setCount] = useState(0);
|
|
657
|
+
events.push(`render count=${count}`);
|
|
658
|
+
|
|
659
|
+
useEffect(() => {
|
|
660
|
+
events.push("effect mount");
|
|
661
|
+
setCount((prev) => {
|
|
662
|
+
updaterCallCount++;
|
|
663
|
+
events.push(`updater call #${updaterCallCount} with prev=${prev}`);
|
|
664
|
+
// Return different values on each call
|
|
665
|
+
if (updaterCallCount === 1) {
|
|
666
|
+
return 100; // First call returns 100
|
|
667
|
+
}
|
|
668
|
+
return 200; // Second call returns 200
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
return () => {
|
|
672
|
+
events.push("effect cleanup");
|
|
673
|
+
};
|
|
674
|
+
}, []);
|
|
675
|
+
|
|
676
|
+
return <div>{count}</div>;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
render(
|
|
680
|
+
<StrictMode>
|
|
681
|
+
<TestComponent />
|
|
682
|
+
</StrictMode>,
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
// ANSWER: React calls updater 4 times and uses the LAST return value!
|
|
686
|
+
// Sequence:
|
|
687
|
+
// 1. Effect #1 mounts: updater(0) → 100
|
|
688
|
+
// 2. Effect #1 cleanup (strict mode)
|
|
689
|
+
// 3. Effect #2 mounts: updater(0) → 200
|
|
690
|
+
// 4. Strict mode doubles the updater: updater(100) → 200
|
|
691
|
+
// 5. Strict mode doubles again: updater(100) → 200
|
|
692
|
+
// Final value: 200 (from the last call)
|
|
693
|
+
expect(updaterCallCount).toBe(4);
|
|
694
|
+
expect(events).toEqual([
|
|
695
|
+
"render count=0",
|
|
696
|
+
"render count=0",
|
|
697
|
+
"effect mount",
|
|
698
|
+
"updater call #1 with prev=0", // Effect #1: returns 100
|
|
699
|
+
"effect cleanup",
|
|
700
|
+
"effect mount",
|
|
701
|
+
"updater call #2 with prev=0", // Effect #2: returns 200
|
|
702
|
+
"updater call #3 with prev=100", // Strict mode double: returns 200
|
|
703
|
+
"updater call #4 with prev=100", // Strict mode double again: returns 200
|
|
704
|
+
"render count=200", // Uses LAST return value
|
|
705
|
+
"render count=200",
|
|
706
|
+
]);
|
|
707
|
+
});
|
|
708
|
+
});
|
|
709
|
+
});
|