@assistant-ui/tap 0.4.5 → 0.5.0
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 +20 -17
- package/dist/core/ResourceFiber.d.ts +2 -2
- package/dist/core/ResourceFiber.d.ts.map +1 -1
- package/dist/core/ResourceFiber.js +11 -9
- package/dist/core/ResourceFiber.js.map +1 -1
- package/dist/core/createResourceRoot.d.ts +6 -0
- package/dist/core/createResourceRoot.d.ts.map +1 -0
- package/dist/core/createResourceRoot.js +32 -0
- package/dist/core/createResourceRoot.js.map +1 -0
- package/dist/core/helpers/callResourceFn.d.ts.map +1 -0
- package/dist/core/helpers/callResourceFn.js.map +1 -0
- package/dist/core/helpers/commit.d.ts +4 -0
- package/dist/core/helpers/commit.d.ts.map +1 -0
- package/dist/core/{commit.js → helpers/commit.js} +2 -2
- package/dist/core/helpers/commit.js.map +1 -0
- package/dist/core/helpers/env.d.ts.map +1 -0
- package/dist/core/helpers/env.js +3 -0
- package/dist/core/helpers/env.js.map +1 -0
- package/dist/core/{execution-context.d.ts → helpers/execution-context.d.ts} +1 -1
- package/dist/core/helpers/execution-context.d.ts.map +1 -0
- package/dist/core/helpers/execution-context.js.map +1 -0
- package/dist/core/helpers/root.d.ts +8 -0
- package/dist/core/helpers/root.d.ts.map +1 -0
- package/dist/core/helpers/root.js +52 -0
- package/dist/core/helpers/root.js.map +1 -0
- package/dist/core/resource.js +1 -1
- package/dist/core/resource.js.map +1 -1
- package/dist/core/scheduler.d.ts.map +1 -1
- package/dist/core/scheduler.js +12 -1
- package/dist/core/scheduler.js.map +1 -1
- package/dist/core/types.d.ts +25 -7
- package/dist/core/types.d.ts.map +1 -1
- package/dist/hooks/tap-effect-event.d.ts.map +1 -1
- package/dist/hooks/tap-effect-event.js +3 -2
- package/dist/hooks/tap-effect-event.js.map +1 -1
- package/dist/hooks/tap-memo.d.ts.map +1 -1
- package/dist/hooks/tap-memo.js +16 -17
- package/dist/hooks/tap-memo.js.map +1 -1
- package/dist/hooks/tap-reducer.d.ts +7 -0
- package/dist/hooks/tap-reducer.d.ts.map +1 -0
- package/dist/hooks/tap-reducer.js +87 -0
- package/dist/hooks/tap-reducer.js.map +1 -0
- package/dist/hooks/tap-resource.js +9 -9
- package/dist/hooks/tap-resource.js.map +1 -1
- package/dist/hooks/tap-resources.d.ts.map +1 -1
- package/dist/hooks/tap-resources.js +11 -11
- 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 +6 -63
- package/dist/hooks/tap-state.js.map +1 -1
- package/dist/hooks/utils/tapHook.d.ts +1 -1
- package/dist/hooks/utils/tapHook.d.ts.map +1 -1
- package/dist/hooks/utils/tapHook.js +2 -2
- package/dist/hooks/utils/tapHook.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/react/use-resource.d.ts +1 -1
- package/dist/react/use-resource.d.ts.map +1 -1
- package/dist/react/use-resource.js +14 -8
- package/dist/react/use-resource.js.map +1 -1
- package/dist/{tapSubscribableResource.d.ts → tapResourceRoot.d.ts} +3 -3
- package/dist/tapResourceRoot.d.ts.map +1 -0
- package/dist/tapResourceRoot.js +80 -0
- package/dist/tapResourceRoot.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/basic/resourceHandle.test.ts +17 -14
- package/src/__tests__/basic/tapReducer.basic.test.ts +200 -0
- package/src/__tests__/react/concurrent-mode.test.tsx +1 -4
- package/src/__tests__/strictmode/react-strictmode-behavior.test.tsx +215 -2
- package/src/__tests__/strictmode/react-strictmode-rerender-sources.test.tsx +77 -0
- package/src/__tests__/strictmode/strictmode.test.ts +82 -21
- package/src/__tests__/strictmode/tap-strictmode-rerender-sources.test.ts +67 -110
- package/src/__tests__/test-utils.ts +5 -1
- package/src/core/ResourceFiber.ts +22 -10
- package/src/core/createResourceRoot.ts +53 -0
- package/src/core/{callResourceFn.ts → helpers/callResourceFn.ts} +1 -1
- package/src/core/{commit.ts → helpers/commit.ts} +3 -3
- package/src/core/helpers/env.ts +3 -0
- package/src/core/{execution-context.ts → helpers/execution-context.ts} +1 -1
- package/src/core/helpers/root.ts +67 -0
- package/src/core/resource.ts +1 -1
- package/src/core/scheduler.ts +13 -1
- package/src/core/types.ts +27 -7
- package/src/hooks/tap-effect-event.ts +3 -2
- package/src/hooks/tap-memo.ts +24 -19
- package/src/hooks/tap-reducer.ts +148 -0
- package/src/hooks/tap-resource.ts +9 -9
- package/src/hooks/tap-resources.ts +23 -10
- package/src/hooks/tap-state.ts +11 -88
- package/src/hooks/utils/tapHook.ts +3 -3
- package/src/index.ts +3 -3
- package/src/react/use-resource.ts +24 -11
- package/src/tapResourceRoot.ts +131 -0
- package/dist/core/callResourceFn.d.ts.map +0 -1
- package/dist/core/callResourceFn.js.map +0 -1
- package/dist/core/commit.d.ts +0 -4
- package/dist/core/commit.d.ts.map +0 -1
- package/dist/core/commit.js.map +0 -1
- package/dist/core/createResource.d.ts +0 -15
- package/dist/core/createResource.d.ts.map +0 -1
- package/dist/core/createResource.js +0 -101
- package/dist/core/createResource.js.map +0 -1
- package/dist/core/env.d.ts.map +0 -1
- package/dist/core/env.js +0 -4
- package/dist/core/env.js.map +0 -1
- package/dist/core/execution-context.d.ts.map +0 -1
- package/dist/core/execution-context.js.map +0 -1
- package/dist/hooks/tap-inline-resource.d.ts +0 -3
- package/dist/hooks/tap-inline-resource.d.ts.map +0 -1
- package/dist/hooks/tap-inline-resource.js +0 -5
- package/dist/hooks/tap-inline-resource.js.map +0 -1
- package/dist/tapSubscribableResource.d.ts.map +0 -1
- package/dist/tapSubscribableResource.js +0 -60
- package/dist/tapSubscribableResource.js.map +0 -1
- package/src/core/createResource.ts +0 -155
- package/src/core/env.ts +0 -4
- package/src/hooks/tap-inline-resource.ts +0 -8
- package/src/tapSubscribableResource.ts +0 -101
- /package/dist/core/{callResourceFn.d.ts → helpers/callResourceFn.d.ts} +0 -0
- /package/dist/core/{callResourceFn.js → helpers/callResourceFn.js} +0 -0
- /package/dist/core/{env.d.ts → helpers/env.d.ts} +0 -0
- /package/dist/core/{execution-context.js → helpers/execution-context.js} +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import { createResourceRoot } from "../../core/createResourceRoot";
|
|
3
3
|
import { resource } from "../../core/resource";
|
|
4
4
|
|
|
5
5
|
describe("ResourceHandle - Basic Usage", () => {
|
|
@@ -10,41 +10,44 @@ describe("ResourceHandle - Basic Usage", () => {
|
|
|
10
10
|
propsUsed: props,
|
|
11
11
|
};
|
|
12
12
|
});
|
|
13
|
-
const
|
|
13
|
+
const root = createResourceRoot();
|
|
14
|
+
const sub = root.render(TestResource(5));
|
|
14
15
|
|
|
15
|
-
// The
|
|
16
|
-
expect(typeof
|
|
17
|
-
expect(typeof
|
|
18
|
-
expect(typeof
|
|
16
|
+
// The subscribable provides getValue and subscribe
|
|
17
|
+
expect(typeof sub.getValue).toBe("function");
|
|
18
|
+
expect(typeof sub.subscribe).toBe("function");
|
|
19
|
+
expect(typeof root.render).toBe("function");
|
|
19
20
|
|
|
20
21
|
// Initial state
|
|
21
|
-
expect(
|
|
22
|
-
expect(
|
|
22
|
+
expect(sub.getValue().value).toBe(10);
|
|
23
|
+
expect(sub.getValue().propsUsed).toBe(5);
|
|
23
24
|
});
|
|
24
25
|
|
|
25
26
|
it("should allow updating props", () => {
|
|
26
27
|
const TestResource = resource((props: { multiplier: number }) => {
|
|
27
28
|
return { result: 10 * props.multiplier };
|
|
28
29
|
});
|
|
29
|
-
const
|
|
30
|
+
const root = createResourceRoot();
|
|
31
|
+
const sub = root.render(TestResource({ multiplier: 2 }));
|
|
30
32
|
|
|
31
33
|
// Initial state
|
|
32
|
-
expect(
|
|
34
|
+
expect(sub.getValue().result).toBe(20);
|
|
33
35
|
|
|
34
36
|
// Can call render to update props
|
|
35
|
-
expect(() =>
|
|
37
|
+
expect(() => root.render(TestResource({ multiplier: 3 }))).not.toThrow();
|
|
36
38
|
});
|
|
37
39
|
|
|
38
40
|
it("should support subscribing and unsubscribing", () => {
|
|
39
41
|
const TestResource = resource(() => ({ timestamp: Date.now() }));
|
|
40
|
-
const
|
|
42
|
+
const root = createResourceRoot();
|
|
43
|
+
const sub = root.render(TestResource());
|
|
41
44
|
|
|
42
45
|
const subscriber1 = vi.fn();
|
|
43
46
|
const subscriber2 = vi.fn();
|
|
44
47
|
|
|
45
48
|
// Can subscribe multiple callbacks
|
|
46
|
-
const unsub1 =
|
|
47
|
-
const unsub2 =
|
|
49
|
+
const unsub1 = sub.subscribe(subscriber1);
|
|
50
|
+
const unsub2 = sub.subscribe(subscriber2);
|
|
48
51
|
|
|
49
52
|
// Can unsubscribe individually
|
|
50
53
|
expect(typeof unsub1).toBe("function");
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
+
import { tapReducer } from "../../hooks/tap-reducer";
|
|
3
|
+
import { tapEffect } from "../../hooks/tap-effect";
|
|
4
|
+
import {
|
|
5
|
+
createTestResource,
|
|
6
|
+
renderTest,
|
|
7
|
+
cleanupAllResources,
|
|
8
|
+
waitForNextTick,
|
|
9
|
+
getCommittedOutput,
|
|
10
|
+
} from "../test-utils";
|
|
11
|
+
|
|
12
|
+
describe("tapReducer - Basic Functionality", () => {
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
cleanupAllResources();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("Initialization", () => {
|
|
18
|
+
it("should initialize with direct value", () => {
|
|
19
|
+
const reducer = (state: number, action: number) => state + action;
|
|
20
|
+
|
|
21
|
+
const testFiber = createTestResource(() => {
|
|
22
|
+
const [count] = tapReducer(reducer, 0);
|
|
23
|
+
return count;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const result = renderTest(testFiber, undefined);
|
|
27
|
+
expect(result).toBe(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("should initialize with init function", () => {
|
|
31
|
+
let initCalled = 0;
|
|
32
|
+
const reducer = (state: number, action: number) => state + action;
|
|
33
|
+
|
|
34
|
+
const testFiber = createTestResource(() => {
|
|
35
|
+
const [count] = tapReducer(reducer, 10, (arg) => {
|
|
36
|
+
initCalled++;
|
|
37
|
+
return arg * 2;
|
|
38
|
+
});
|
|
39
|
+
return count;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const result = renderTest(testFiber, undefined);
|
|
43
|
+
expect(result).toBe(20);
|
|
44
|
+
expect(initCalled).toBe(1);
|
|
45
|
+
|
|
46
|
+
// Re-render should not call init again
|
|
47
|
+
renderTest(testFiber, undefined);
|
|
48
|
+
expect(initCalled).toBe(1);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("Dispatch and re-render", () => {
|
|
53
|
+
it("should dispatch actions and trigger re-render", async () => {
|
|
54
|
+
type Action = { type: "increment" } | { type: "decrement" };
|
|
55
|
+
const reducer = (state: number, action: Action) => {
|
|
56
|
+
switch (action.type) {
|
|
57
|
+
case "increment":
|
|
58
|
+
return state + 1;
|
|
59
|
+
case "decrement":
|
|
60
|
+
return state - 1;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
let dispatchFn: ((action: Action) => void) | null = null;
|
|
65
|
+
|
|
66
|
+
const testFiber = createTestResource(() => {
|
|
67
|
+
const [count, dispatch] = tapReducer(reducer, 0);
|
|
68
|
+
|
|
69
|
+
tapEffect(() => {
|
|
70
|
+
dispatchFn = dispatch;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return count;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
renderTest(testFiber, undefined);
|
|
77
|
+
expect(getCommittedOutput(testFiber)).toBe(0);
|
|
78
|
+
|
|
79
|
+
dispatchFn!({ type: "increment" });
|
|
80
|
+
await waitForNextTick();
|
|
81
|
+
expect(getCommittedOutput(testFiber)).toBe(1);
|
|
82
|
+
|
|
83
|
+
dispatchFn!({ type: "increment" });
|
|
84
|
+
await waitForNextTick();
|
|
85
|
+
expect(getCommittedOutput(testFiber)).toBe(2);
|
|
86
|
+
|
|
87
|
+
dispatchFn!({ type: "decrement" });
|
|
88
|
+
await waitForNextTick();
|
|
89
|
+
expect(getCommittedOutput(testFiber)).toBe(1);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("Same-state bailout", () => {
|
|
94
|
+
it("should not re-render when reducer returns same state (Object.is)", async () => {
|
|
95
|
+
let renderCount = 0;
|
|
96
|
+
const reducer = (state: number, action: number) =>
|
|
97
|
+
action === 0 ? state : state + action;
|
|
98
|
+
|
|
99
|
+
let dispatchFn: ((action: number) => void) | null = null;
|
|
100
|
+
|
|
101
|
+
const testFiber = createTestResource(() => {
|
|
102
|
+
renderCount++;
|
|
103
|
+
const [count, dispatch] = tapReducer(reducer, 42);
|
|
104
|
+
|
|
105
|
+
tapEffect(() => {
|
|
106
|
+
dispatchFn = dispatch;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return count;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
renderTest(testFiber, undefined);
|
|
113
|
+
expect(renderCount).toBe(1);
|
|
114
|
+
|
|
115
|
+
// Dispatch action that returns same state
|
|
116
|
+
dispatchFn!(0);
|
|
117
|
+
await waitForNextTick();
|
|
118
|
+
expect(renderCount).toBe(1);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("Reducer function updates", () => {
|
|
123
|
+
it("should use latest reducer reference", async () => {
|
|
124
|
+
let multiplier = 1;
|
|
125
|
+
let dispatchFn: ((action: number) => void) | null = null;
|
|
126
|
+
|
|
127
|
+
const testFiber = createTestResource(() => {
|
|
128
|
+
const reducer = (state: number, action: number) =>
|
|
129
|
+
state + action * multiplier;
|
|
130
|
+
const [count, dispatch] = tapReducer(reducer, 0);
|
|
131
|
+
|
|
132
|
+
tapEffect(() => {
|
|
133
|
+
dispatchFn = dispatch;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return count;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
renderTest(testFiber, undefined);
|
|
140
|
+
expect(getCommittedOutput(testFiber)).toBe(0);
|
|
141
|
+
|
|
142
|
+
// Dispatch with multiplier=1
|
|
143
|
+
dispatchFn!(5);
|
|
144
|
+
await waitForNextTick();
|
|
145
|
+
expect(getCommittedOutput(testFiber)).toBe(5);
|
|
146
|
+
|
|
147
|
+
// Change multiplier and dispatch
|
|
148
|
+
multiplier = 10;
|
|
149
|
+
renderTest(testFiber, undefined); // re-render to update reducer
|
|
150
|
+
dispatchFn!(5);
|
|
151
|
+
await waitForNextTick();
|
|
152
|
+
expect(getCommittedOutput(testFiber)).toBe(55); // 5 + 5*10
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("Multiple dispatches", () => {
|
|
157
|
+
it("should handle multiple dispatches correctly", async () => {
|
|
158
|
+
const reducer = (state: number, action: number) => state + action;
|
|
159
|
+
let dispatchFn: ((action: number) => void) | null = null;
|
|
160
|
+
|
|
161
|
+
const testFiber = createTestResource(() => {
|
|
162
|
+
const [count, dispatch] = tapReducer(reducer, 0);
|
|
163
|
+
|
|
164
|
+
tapEffect(() => {
|
|
165
|
+
dispatchFn = dispatch;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return count;
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
renderTest(testFiber, undefined);
|
|
172
|
+
expect(getCommittedOutput(testFiber)).toBe(0);
|
|
173
|
+
|
|
174
|
+
// Multiple dispatches
|
|
175
|
+
dispatchFn!(1);
|
|
176
|
+
dispatchFn!(2);
|
|
177
|
+
dispatchFn!(3);
|
|
178
|
+
await waitForNextTick();
|
|
179
|
+
expect(getCommittedOutput(testFiber)).toBe(6);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("Dispatch identity stability", () => {
|
|
184
|
+
it("should return same dispatch reference across renders", () => {
|
|
185
|
+
const reducer = (state: number, action: number) => state + action;
|
|
186
|
+
const dispatches: ((action: number) => void)[] = [];
|
|
187
|
+
|
|
188
|
+
const testFiber = createTestResource(() => {
|
|
189
|
+
const [count, dispatch] = tapReducer(reducer, 0);
|
|
190
|
+
dispatches.push(dispatch);
|
|
191
|
+
return count;
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
renderTest(testFiber, undefined);
|
|
195
|
+
renderTest(testFiber, undefined);
|
|
196
|
+
|
|
197
|
+
expect(dispatches[0]).toBe(dispatches[1]);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -10,10 +10,7 @@ const ShouldNeverFallback = () => {
|
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
describe("Concurrent Mode with useResource", () => {
|
|
13
|
-
|
|
14
|
-
// This requires architectural changes to make tapState updates "tentative" until React commits
|
|
15
|
-
// For now, tapState behaves like external state (Zustand, Jotai) which has the same limitation
|
|
16
|
-
it.skip("should not commit tapState updates when render is discarded", async () => {
|
|
13
|
+
it("should not commit tapState updates when render is discarded", async () => {
|
|
17
14
|
const TestResource = resource(() => {
|
|
18
15
|
return tapState(false);
|
|
19
16
|
});
|
|
@@ -5,7 +5,15 @@
|
|
|
5
5
|
|
|
6
6
|
import { describe, it, expect } from "vitest";
|
|
7
7
|
import { render } from "@testing-library/react";
|
|
8
|
-
import {
|
|
8
|
+
import { act } from "react";
|
|
9
|
+
import {
|
|
10
|
+
StrictMode,
|
|
11
|
+
useState,
|
|
12
|
+
useEffect,
|
|
13
|
+
useMemo,
|
|
14
|
+
useReducer,
|
|
15
|
+
useRef,
|
|
16
|
+
} from "react";
|
|
9
17
|
|
|
10
18
|
describe("React Strict Mode Behavior Verification", () => {
|
|
11
19
|
describe("Test 1: Effect + setState behavior in strict mode", () => {
|
|
@@ -489,7 +497,212 @@ describe("React Strict Mode Behavior Verification", () => {
|
|
|
489
497
|
});
|
|
490
498
|
});
|
|
491
499
|
|
|
492
|
-
describe("Test 5:
|
|
500
|
+
describe("Test 5: useMemo strict mode behavior", () => {
|
|
501
|
+
it("should double-invoke useMemo factory and use the first result", () => {
|
|
502
|
+
const events: string[] = [];
|
|
503
|
+
let memoCallCount = 0;
|
|
504
|
+
|
|
505
|
+
function TestComponent() {
|
|
506
|
+
const memoValue = useMemo(() => {
|
|
507
|
+
memoCallCount++;
|
|
508
|
+
events.push(`memo-${memoCallCount}`);
|
|
509
|
+
return memoCallCount;
|
|
510
|
+
}, []);
|
|
511
|
+
|
|
512
|
+
events.push(`render memoValue=${memoValue}`);
|
|
513
|
+
|
|
514
|
+
return <div>{memoValue}</div>;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
render(
|
|
518
|
+
<StrictMode>
|
|
519
|
+
<TestComponent />
|
|
520
|
+
</StrictMode>,
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
expect(events).toEqual([
|
|
524
|
+
"memo-1",
|
|
525
|
+
"memo-2",
|
|
526
|
+
"render memoValue=1",
|
|
527
|
+
"render memoValue=1",
|
|
528
|
+
]);
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
describe("Test 6: useReducer strict mode behavior", () => {
|
|
533
|
+
it("should double-invoke useReducer initializer and use the first result", () => {
|
|
534
|
+
const events: string[] = [];
|
|
535
|
+
let initCallCount = 0;
|
|
536
|
+
|
|
537
|
+
function TestComponent() {
|
|
538
|
+
const [state] = useReducer(
|
|
539
|
+
(s: number, a: number) => s + a,
|
|
540
|
+
0,
|
|
541
|
+
(arg) => {
|
|
542
|
+
initCallCount++;
|
|
543
|
+
events.push(`init-${initCallCount}`);
|
|
544
|
+
return arg + initCallCount * 10;
|
|
545
|
+
},
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
events.push(`render state=${state}`);
|
|
549
|
+
|
|
550
|
+
return <div>{state}</div>;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
render(
|
|
554
|
+
<StrictMode>
|
|
555
|
+
<TestComponent />
|
|
556
|
+
</StrictMode>,
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
expect(events).toEqual([
|
|
560
|
+
"init-1",
|
|
561
|
+
"init-2",
|
|
562
|
+
"render state=10",
|
|
563
|
+
"render state=10",
|
|
564
|
+
]);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it("should double-invoke useReducer reducer on dispatch and use the first result", () => {
|
|
568
|
+
const events: string[] = [];
|
|
569
|
+
let reducerCallCount = 0;
|
|
570
|
+
|
|
571
|
+
function TestComponent() {
|
|
572
|
+
const [state, dispatch] = useReducer((s: number, _a: number) => {
|
|
573
|
+
reducerCallCount++;
|
|
574
|
+
const result = reducerCallCount * 100;
|
|
575
|
+
events.push(`reducer-${reducerCallCount} state=${s} -> ${result}`);
|
|
576
|
+
return result;
|
|
577
|
+
}, 0);
|
|
578
|
+
|
|
579
|
+
events.push(`render state=${state}`);
|
|
580
|
+
|
|
581
|
+
useEffect(() => {
|
|
582
|
+
if (state === 0) {
|
|
583
|
+
events.push("dispatch");
|
|
584
|
+
dispatch(1);
|
|
585
|
+
}
|
|
586
|
+
}, [state]);
|
|
587
|
+
|
|
588
|
+
return <div>{state}</div>;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
render(
|
|
592
|
+
<StrictMode>
|
|
593
|
+
<TestComponent />
|
|
594
|
+
</StrictMode>,
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
// React behavior: reducer is called 4 times (2 dispatches × 2 strict mode double-calls)
|
|
598
|
+
// Dispatch #1 (effect mount): reducer called twice, SECOND result (200) kept
|
|
599
|
+
// Dispatch #2 (effect remount): reducer called twice, SECOND result (400) kept
|
|
600
|
+
// Note: this is opposite to useMemo/useState which keep the FIRST result!
|
|
601
|
+
expect(reducerCallCount).toBe(4);
|
|
602
|
+
expect(events).toEqual([
|
|
603
|
+
"render state=0",
|
|
604
|
+
"render state=0",
|
|
605
|
+
"dispatch",
|
|
606
|
+
"dispatch",
|
|
607
|
+
"reducer-1 state=0 -> 100",
|
|
608
|
+
"reducer-2 state=0 -> 200",
|
|
609
|
+
"reducer-3 state=200 -> 300",
|
|
610
|
+
"reducer-4 state=200 -> 400",
|
|
611
|
+
"render state=400",
|
|
612
|
+
"render state=400",
|
|
613
|
+
]);
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
describe("Test 7: useState/useReducer dispatch double-invoke (isolated from effects)", () => {
|
|
618
|
+
it("should double-invoke useState updater and use the first result", () => {
|
|
619
|
+
const events: string[] = [];
|
|
620
|
+
let updaterCallCount = 0;
|
|
621
|
+
let setCountRef: ((fn: (prev: number) => number) => void) | null = null;
|
|
622
|
+
|
|
623
|
+
function TestComponent() {
|
|
624
|
+
const [count, setCount] = useState(0);
|
|
625
|
+
setCountRef = setCount;
|
|
626
|
+
|
|
627
|
+
events.push(`render count=${count}`);
|
|
628
|
+
|
|
629
|
+
return <div>{count}</div>;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
render(
|
|
633
|
+
<StrictMode>
|
|
634
|
+
<TestComponent />
|
|
635
|
+
</StrictMode>,
|
|
636
|
+
);
|
|
637
|
+
|
|
638
|
+
events.length = 0;
|
|
639
|
+
updaterCallCount = 0;
|
|
640
|
+
|
|
641
|
+
act(() => {
|
|
642
|
+
setCountRef!((prev) => {
|
|
643
|
+
updaterCallCount++;
|
|
644
|
+
const result = updaterCallCount * 100;
|
|
645
|
+
events.push(`updater-${updaterCallCount} prev=${prev} -> ${result}`);
|
|
646
|
+
return result;
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
// useState updater is double-invoked, FIRST result kept
|
|
651
|
+
// (same as useMemo/useState init — NOT like useReducer dispatch!)
|
|
652
|
+
expect(updaterCallCount).toBe(2);
|
|
653
|
+
expect(events).toEqual([
|
|
654
|
+
"updater-1 prev=0 -> 100",
|
|
655
|
+
"updater-2 prev=0 -> 200",
|
|
656
|
+
"render count=100",
|
|
657
|
+
"render count=100",
|
|
658
|
+
]);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it("should double-invoke useReducer reducer and use the first result", () => {
|
|
662
|
+
const events: string[] = [];
|
|
663
|
+
let reducerCallCount = 0;
|
|
664
|
+
let dispatchRef: ((a: number) => void) | null = null;
|
|
665
|
+
|
|
666
|
+
function TestComponent() {
|
|
667
|
+
const [state, dispatch] = useReducer((s: number, _a: number) => {
|
|
668
|
+
reducerCallCount++;
|
|
669
|
+
const result = reducerCallCount * 100;
|
|
670
|
+
events.push(`reducer-${reducerCallCount} state=${s} -> ${result}`);
|
|
671
|
+
return result;
|
|
672
|
+
}, 0);
|
|
673
|
+
dispatchRef = dispatch;
|
|
674
|
+
|
|
675
|
+
events.push(`render state=${state}`);
|
|
676
|
+
|
|
677
|
+
return <div>{state}</div>;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
render(
|
|
681
|
+
<StrictMode>
|
|
682
|
+
<TestComponent />
|
|
683
|
+
</StrictMode>,
|
|
684
|
+
);
|
|
685
|
+
|
|
686
|
+
events.length = 0;
|
|
687
|
+
reducerCallCount = 0;
|
|
688
|
+
|
|
689
|
+
act(() => {
|
|
690
|
+
dispatchRef!(1);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
// useReducer reducer is double-invoked, SECOND result kept!
|
|
694
|
+
// This differs from useState updater which keeps the FIRST result.
|
|
695
|
+
expect(reducerCallCount).toBe(2);
|
|
696
|
+
expect(events).toEqual([
|
|
697
|
+
"reducer-1 state=0 -> 100",
|
|
698
|
+
"reducer-2 state=0 -> 200",
|
|
699
|
+
"render state=200",
|
|
700
|
+
"render state=200",
|
|
701
|
+
]);
|
|
702
|
+
});
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
describe("Test 8: setState in effect - strict mode edge cases", () => {
|
|
493
706
|
it("should verify which setState is applied when effect calls setState only on first mount", () => {
|
|
494
707
|
const events: string[] = [];
|
|
495
708
|
let effectRunCount = 0;
|
|
@@ -389,4 +389,81 @@ describe("React Strict Mode - Rerender Sources", () => {
|
|
|
389
389
|
]);
|
|
390
390
|
});
|
|
391
391
|
});
|
|
392
|
+
|
|
393
|
+
describe("Source 9: Effect with dependencies calling setState (derived state)", () => {
|
|
394
|
+
it("should handle effect with dependencies and setState", () => {
|
|
395
|
+
const events: string[] = [];
|
|
396
|
+
|
|
397
|
+
function TestComponent() {
|
|
398
|
+
const [count] = useState(0);
|
|
399
|
+
const [doubled, setDoubled] = useState(0);
|
|
400
|
+
events.push(`render count=${count} doubled=${doubled}`);
|
|
401
|
+
|
|
402
|
+
useEffect(() => {
|
|
403
|
+
events.push(`effect count=${count}`);
|
|
404
|
+
setDoubled(count * 2);
|
|
405
|
+
return () => {
|
|
406
|
+
events.push(`cleanup count=${count}`);
|
|
407
|
+
};
|
|
408
|
+
}, [count]);
|
|
409
|
+
|
|
410
|
+
return <div>{doubled}</div>;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
render(
|
|
414
|
+
<StrictMode>
|
|
415
|
+
<TestComponent />
|
|
416
|
+
</StrictMode>,
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
// setDoubled(0*2) = setDoubled(0) is a no-op, so no extra render
|
|
420
|
+
expect(events).toEqual([
|
|
421
|
+
"render count=0 doubled=0",
|
|
422
|
+
"render count=0 doubled=0",
|
|
423
|
+
"effect count=0",
|
|
424
|
+
"cleanup count=0",
|
|
425
|
+
"effect count=0",
|
|
426
|
+
]);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("should handle effect with dependencies and setState after state change", () => {
|
|
430
|
+
const events: string[] = [];
|
|
431
|
+
|
|
432
|
+
function TestComponent() {
|
|
433
|
+
const [count, setCount] = useState(0);
|
|
434
|
+
const [doubled, setDoubled] = useState(0);
|
|
435
|
+
events.push(`render count=${count} doubled=${doubled}`);
|
|
436
|
+
|
|
437
|
+
useEffect(() => {
|
|
438
|
+
events.push(`effect count=${count}`);
|
|
439
|
+
setDoubled(count * 2);
|
|
440
|
+
return () => {
|
|
441
|
+
events.push(`cleanup count=${count}`);
|
|
442
|
+
};
|
|
443
|
+
}, [count]);
|
|
444
|
+
|
|
445
|
+
return <button onClick={() => setCount((c) => c + 1)}>Click</button>;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const { getByRole } = render(
|
|
449
|
+
<StrictMode>
|
|
450
|
+
<TestComponent />
|
|
451
|
+
</StrictMode>,
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
events.length = 0;
|
|
455
|
+
|
|
456
|
+
fireEvent.click(getByRole("button"));
|
|
457
|
+
|
|
458
|
+
// Double-render with new count, effect sets doubled=2, triggers another double-render
|
|
459
|
+
expect(events).toEqual([
|
|
460
|
+
"render count=1 doubled=0",
|
|
461
|
+
"render count=1 doubled=0",
|
|
462
|
+
"cleanup count=0",
|
|
463
|
+
"effect count=1",
|
|
464
|
+
"render count=1 doubled=2",
|
|
465
|
+
"render count=1 doubled=2",
|
|
466
|
+
]);
|
|
467
|
+
});
|
|
468
|
+
});
|
|
392
469
|
});
|