@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
|
@@ -2,6 +2,7 @@ import { describe, it, expect, afterEach } from "vitest";
|
|
|
2
2
|
import { tapResources } from "../../hooks/tap-resources";
|
|
3
3
|
import { tapState } from "../../hooks/tap-state";
|
|
4
4
|
import { resource } from "../../core/resource";
|
|
5
|
+
import { withKey } from "../../core/withKey";
|
|
5
6
|
import {
|
|
6
7
|
createTestResource,
|
|
7
8
|
renderTest,
|
|
@@ -59,8 +60,11 @@ describe("tapResources - Basic Functionality", () => {
|
|
|
59
60
|
it("should render multiple resources with keys", () => {
|
|
60
61
|
const testFiber = createTestResource(() => {
|
|
61
62
|
const results = tapResources(
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
() => [
|
|
64
|
+
withKey("a", SimpleCounter({ value: 10 })),
|
|
65
|
+
withKey("b", SimpleCounter({ value: 20 })),
|
|
66
|
+
withKey("c", SimpleCounter({ value: 30 })),
|
|
67
|
+
],
|
|
64
68
|
[],
|
|
65
69
|
);
|
|
66
70
|
|
|
@@ -68,11 +72,7 @@ describe("tapResources - Basic Functionality", () => {
|
|
|
68
72
|
});
|
|
69
73
|
|
|
70
74
|
const result = renderTest(testFiber, undefined);
|
|
71
|
-
expect(result).toEqual({
|
|
72
|
-
a: { count: 10 },
|
|
73
|
-
b: { count: 20 },
|
|
74
|
-
c: { count: 30 },
|
|
75
|
-
});
|
|
75
|
+
expect(result).toEqual([{ count: 10 }, { count: 20 }, { count: 30 }]);
|
|
76
76
|
});
|
|
77
77
|
|
|
78
78
|
it("should work with resource constructor syntax", () => {
|
|
@@ -82,15 +82,17 @@ describe("tapResources - Basic Functionality", () => {
|
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
const testFiber = createTestResource(() => {
|
|
85
|
-
const items =
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
85
|
+
const items = [
|
|
86
|
+
{ key: "first", value: 5 },
|
|
87
|
+
{ key: "second", value: 10 },
|
|
88
|
+
{ key: "third", value: 15 },
|
|
89
|
+
];
|
|
90
90
|
|
|
91
91
|
const results = tapResources(
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
() =>
|
|
93
|
+
items.map((item) =>
|
|
94
|
+
withKey(item.key, Counter({ value: item.value })),
|
|
95
|
+
),
|
|
94
96
|
[],
|
|
95
97
|
);
|
|
96
98
|
|
|
@@ -98,56 +100,66 @@ describe("tapResources - Basic Functionality", () => {
|
|
|
98
100
|
});
|
|
99
101
|
|
|
100
102
|
const result = renderTest(testFiber, undefined);
|
|
101
|
-
expect(result).toEqual(
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
expect(result).toEqual([
|
|
104
|
+
{ count: 5, double: 10 },
|
|
105
|
+
{ count: 10, double: 20 },
|
|
106
|
+
{ count: 15, double: 30 },
|
|
107
|
+
]);
|
|
106
108
|
});
|
|
107
109
|
});
|
|
108
110
|
|
|
109
111
|
describe("Instance Preservation", () => {
|
|
110
112
|
it("should maintain resource instances when keys remain the same", () => {
|
|
111
113
|
const testFiber = createTestResource(
|
|
112
|
-
(props: {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
114
|
+
(props: {
|
|
115
|
+
items: Array<{ key: string; value: number; id: string }>;
|
|
116
|
+
}) => {
|
|
117
|
+
return tapResources(() => {
|
|
118
|
+
return props.items.map((item) =>
|
|
119
|
+
withKey(
|
|
120
|
+
item.key,
|
|
121
|
+
TrackingCounter({ value: item.value, id: item.id }),
|
|
122
|
+
),
|
|
123
|
+
);
|
|
124
|
+
}, [props.items]);
|
|
119
125
|
},
|
|
120
126
|
);
|
|
121
127
|
|
|
122
128
|
// Initial render
|
|
123
129
|
const result1 = renderTest(testFiber, {
|
|
124
|
-
items:
|
|
130
|
+
items: [
|
|
131
|
+
{ key: "a", value: 1, id: "a" },
|
|
132
|
+
{ key: "b", value: 2, id: "b" },
|
|
133
|
+
],
|
|
125
134
|
});
|
|
126
135
|
|
|
127
136
|
// Verify initial state
|
|
128
|
-
expect(result1
|
|
137
|
+
expect(result1[0]).toMatchObject({
|
|
129
138
|
id: "a",
|
|
130
139
|
value: 1,
|
|
131
140
|
renderCount: 1,
|
|
132
141
|
});
|
|
133
|
-
expect(result1
|
|
142
|
+
expect(result1[1]).toMatchObject({
|
|
134
143
|
id: "b",
|
|
135
144
|
value: 2,
|
|
136
145
|
renderCount: 1,
|
|
137
146
|
});
|
|
138
147
|
|
|
139
|
-
// Re-render with same keys but different values
|
|
148
|
+
// Re-render with same keys but different order and values
|
|
140
149
|
const result2 = renderTest(testFiber, {
|
|
141
|
-
items:
|
|
150
|
+
items: [
|
|
151
|
+
{ key: "b", value: 20, id: "b" },
|
|
152
|
+
{ key: "a", value: 10, id: "a" },
|
|
153
|
+
],
|
|
142
154
|
});
|
|
143
155
|
|
|
144
|
-
// Verify instances are preserved
|
|
145
|
-
expect(result2
|
|
156
|
+
// Verify instances are preserved (renderCount should be 2)
|
|
157
|
+
expect(result2[0]).toMatchObject({
|
|
146
158
|
id: "b",
|
|
147
159
|
value: 20,
|
|
148
160
|
renderCount: 2,
|
|
149
161
|
});
|
|
150
|
-
expect(result2
|
|
162
|
+
expect(result2[1]).toMatchObject({
|
|
151
163
|
id: "a",
|
|
152
164
|
value: 10,
|
|
153
165
|
renderCount: 2,
|
|
@@ -158,65 +170,73 @@ describe("tapResources - Basic Functionality", () => {
|
|
|
158
170
|
describe("Dynamic List Management", () => {
|
|
159
171
|
it("should handle adding and removing resources", () => {
|
|
160
172
|
const testFiber = createTestResource(
|
|
161
|
-
(props: { items:
|
|
162
|
-
const results = tapResources(
|
|
163
|
-
props.items
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
);
|
|
173
|
+
(props: { items: Array<{ key: string; value: number }> }) => {
|
|
174
|
+
const results = tapResources(() => {
|
|
175
|
+
return props.items.map((item) =>
|
|
176
|
+
withKey(item.key, SimpleCounter({ value: item.value })),
|
|
177
|
+
);
|
|
178
|
+
}, [props.items]);
|
|
168
179
|
return results;
|
|
169
180
|
},
|
|
170
181
|
);
|
|
171
182
|
|
|
172
183
|
// Initial render with 3 items
|
|
173
|
-
const result1 = renderTest(testFiber, {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
184
|
+
const result1 = renderTest(testFiber, {
|
|
185
|
+
items: [
|
|
186
|
+
{ key: "a", value: 0 },
|
|
187
|
+
{ key: "b", value: 10 },
|
|
188
|
+
{ key: "c", value: 20 },
|
|
189
|
+
],
|
|
178
190
|
});
|
|
191
|
+
expect(result1).toEqual([{ count: 0 }, { count: 10 }, { count: 20 }]);
|
|
179
192
|
|
|
180
193
|
// Remove middle item
|
|
181
|
-
const result2 = renderTest(testFiber, {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
194
|
+
const result2 = renderTest(testFiber, {
|
|
195
|
+
items: [
|
|
196
|
+
{ key: "a", value: 0 },
|
|
197
|
+
{ key: "c", value: 10 },
|
|
198
|
+
],
|
|
185
199
|
});
|
|
200
|
+
expect(result2).toEqual([{ count: 0 }, { count: 10 }]);
|
|
186
201
|
|
|
187
202
|
// Add new item
|
|
188
|
-
const result3 = renderTest(testFiber, {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
203
|
+
const result3 = renderTest(testFiber, {
|
|
204
|
+
items: [
|
|
205
|
+
{ key: "a", value: 0 },
|
|
206
|
+
{ key: "c", value: 10 },
|
|
207
|
+
{ key: "d", value: 20 },
|
|
208
|
+
],
|
|
193
209
|
});
|
|
210
|
+
expect(result3).toEqual([{ count: 0 }, { count: 10 }, { count: 20 }]);
|
|
194
211
|
});
|
|
195
212
|
|
|
196
213
|
it("should handle changing resource types for the same key", () => {
|
|
197
214
|
const testFiber = createTestResource((props: { useCounter: boolean }) => {
|
|
198
215
|
const results = tapResources(
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
216
|
+
() => [
|
|
217
|
+
withKey(
|
|
218
|
+
"item",
|
|
219
|
+
props.useCounter
|
|
220
|
+
? StatefulCounter({ initial: 42 })
|
|
221
|
+
: Display({ text: "Hello" }),
|
|
222
|
+
),
|
|
223
|
+
],
|
|
224
|
+
[props.useCounter],
|
|
205
225
|
);
|
|
206
226
|
return results;
|
|
207
227
|
});
|
|
208
228
|
|
|
209
229
|
// Start with Counter
|
|
210
230
|
const result1 = renderTest(testFiber, { useCounter: true });
|
|
211
|
-
expect(result1).toEqual({
|
|
231
|
+
expect(result1).toEqual([{ count: 42 }]);
|
|
212
232
|
|
|
213
233
|
// Switch to Display
|
|
214
234
|
const result2 = renderTest(testFiber, { useCounter: false });
|
|
215
|
-
expect(result2).toEqual({
|
|
235
|
+
expect(result2).toEqual([{ type: "display", text: "Hello" }]);
|
|
216
236
|
|
|
217
237
|
// Switch back to Counter (new instance)
|
|
218
238
|
const result3 = renderTest(testFiber, { useCounter: true });
|
|
219
|
-
expect(result3).toEqual({
|
|
239
|
+
expect(result3).toEqual([{ count: 42 }]);
|
|
220
240
|
});
|
|
221
241
|
});
|
|
222
242
|
});
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
renderTest,
|
|
7
7
|
cleanupAllResources,
|
|
8
8
|
waitForNextTick,
|
|
9
|
-
|
|
9
|
+
getCommittedOutput,
|
|
10
10
|
} from "../test-utils";
|
|
11
11
|
|
|
12
12
|
describe("tapState - Basic Functionality", () => {
|
|
@@ -85,7 +85,7 @@ describe("tapState - Basic Functionality", () => {
|
|
|
85
85
|
await waitForNextTick();
|
|
86
86
|
|
|
87
87
|
// Check that state was updated
|
|
88
|
-
expect(
|
|
88
|
+
expect(getCommittedOutput(testFiber)).toEqual({
|
|
89
89
|
count: 10,
|
|
90
90
|
renderCount: 2,
|
|
91
91
|
});
|
|
@@ -136,19 +136,19 @@ describe("tapState - Basic Functionality", () => {
|
|
|
136
136
|
|
|
137
137
|
// Initial render
|
|
138
138
|
renderTest(testFiber, undefined);
|
|
139
|
-
expect(
|
|
139
|
+
expect(getCommittedOutput(testFiber)).toBe(10);
|
|
140
140
|
|
|
141
141
|
// Functional update
|
|
142
142
|
setCountFn!((prev) => prev * 2);
|
|
143
143
|
|
|
144
144
|
await waitForNextTick();
|
|
145
|
-
expect(
|
|
145
|
+
expect(getCommittedOutput(testFiber)).toBe(20);
|
|
146
146
|
|
|
147
147
|
// Another functional update
|
|
148
148
|
setCountFn!((prev) => prev + 5);
|
|
149
149
|
|
|
150
150
|
await waitForNextTick();
|
|
151
|
-
expect(
|
|
151
|
+
expect(getCommittedOutput(testFiber)).toBe(25);
|
|
152
152
|
});
|
|
153
153
|
});
|
|
154
154
|
|
|
@@ -192,18 +192,18 @@ describe("tapState - Basic Functionality", () => {
|
|
|
192
192
|
|
|
193
193
|
// Initial render
|
|
194
194
|
renderTest(testFiber, undefined);
|
|
195
|
-
expect(
|
|
195
|
+
expect(getCommittedOutput(testFiber)).toEqual({ a: "a", b: "b", c: "c" });
|
|
196
196
|
|
|
197
197
|
// Update only B
|
|
198
198
|
setters.setB("B");
|
|
199
199
|
await waitForNextTick();
|
|
200
|
-
expect(
|
|
200
|
+
expect(getCommittedOutput(testFiber)).toEqual({ a: "a", b: "B", c: "c" });
|
|
201
201
|
|
|
202
202
|
// Update A and C
|
|
203
203
|
setters.setA("A");
|
|
204
204
|
setters.setC("C");
|
|
205
205
|
await waitForNextTick();
|
|
206
|
-
expect(
|
|
206
|
+
expect(getCommittedOutput(testFiber)).toEqual({ a: "A", b: "B", c: "C" });
|
|
207
207
|
});
|
|
208
208
|
});
|
|
209
209
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/** biome-ignore-all lint/correctness/useExhaustiveDependencies: tests */
|
|
1
2
|
import { describe, it, expect, vi } from "vitest";
|
|
2
3
|
import { tapEffect } from "../../hooks/tap-effect";
|
|
3
4
|
import { tapState } from "../../hooks/tap-state";
|
|
@@ -81,9 +82,13 @@ describe("Errors - Effect Errors", () => {
|
|
|
81
82
|
return null;
|
|
82
83
|
});
|
|
83
84
|
|
|
84
|
-
// Should throw
|
|
85
|
-
expect(() =>
|
|
86
|
-
|
|
85
|
+
// Should throw aggregate error
|
|
86
|
+
expect(() =>
|
|
87
|
+
renderTest(resource, undefined),
|
|
88
|
+
).toThrowErrorMatchingInlineSnapshot(`
|
|
89
|
+
[AggregateError: Errors during commit]
|
|
90
|
+
`);
|
|
91
|
+
expect(goodEffect).toHaveBeenCalledTimes(1);
|
|
87
92
|
});
|
|
88
93
|
|
|
89
94
|
it("should continue cleanup on unmount despite errors", () => {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/** biome-ignore-all lint/correctness/useExhaustiveDependencies: tests */
|
|
1
2
|
import { describe, it, expect, vi } from "vitest";
|
|
2
3
|
import { tapEffect } from "../../hooks/tap-effect";
|
|
3
4
|
import { tapState } from "../../hooks/tap-state";
|
|
@@ -232,9 +233,9 @@ describe("Lifecycle - Dependencies", () => {
|
|
|
232
233
|
|
|
233
234
|
// Change to no deps
|
|
234
235
|
useDeps = false;
|
|
235
|
-
const ctx = renderResourceFiber(resource, undefined);
|
|
236
236
|
|
|
237
|
-
|
|
237
|
+
// Error now throws during render (fail-fast validation)
|
|
238
|
+
expect(() => renderResourceFiber(resource, undefined)).toThrow(
|
|
238
239
|
"tapEffect called with and without dependencies across re-renders",
|
|
239
240
|
);
|
|
240
241
|
});
|
|
@@ -54,7 +54,7 @@ describe("Lifecycle - Mount/Unmount", () => {
|
|
|
54
54
|
renderTest(resource, undefined);
|
|
55
55
|
unmountResource(resource);
|
|
56
56
|
|
|
57
|
-
expect(order).toEqual([
|
|
57
|
+
expect(order).toEqual([1, 2, 3]);
|
|
58
58
|
});
|
|
59
59
|
|
|
60
60
|
it("should preserve state across re-renders", () => {
|
|
@@ -150,7 +150,7 @@ describe("Lifecycle - Mount/Unmount", () => {
|
|
|
150
150
|
|
|
151
151
|
// Unmount
|
|
152
152
|
unmountResourceFiber(resource);
|
|
153
|
-
expect(log).toEqual(["cleanup-
|
|
153
|
+
expect(log).toEqual(["cleanup-1", "cleanup-2"]);
|
|
154
154
|
});
|
|
155
155
|
|
|
156
156
|
it("should handle cleanup errors gracefully", () => {
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render, screen, act } from "@testing-library/react";
|
|
3
|
+
import { Suspense, startTransition, use, useState } from "react";
|
|
4
|
+
import { resource } from "../../core/resource";
|
|
5
|
+
import { useResource } from "../../react/use-resource";
|
|
6
|
+
import { tapState } from "../../hooks/tap-state";
|
|
7
|
+
|
|
8
|
+
const ShouldNeverFallback = () => {
|
|
9
|
+
throw new Error("should never fallback");
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe("Concurrent Mode with useResource", () => {
|
|
13
|
+
// TODO: tapState updates are not rolled back when React discards a concurrent render
|
|
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 () => {
|
|
17
|
+
const TestResource = resource(() => {
|
|
18
|
+
return tapState(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
let resolve: (value: number) => void;
|
|
22
|
+
|
|
23
|
+
const suspendPromise = new Promise<number>((r) => {
|
|
24
|
+
resolve = r;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function Suspender() {
|
|
28
|
+
const result = use(suspendPromise);
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function App() {
|
|
33
|
+
const [load, setLoading] = useResource(TestResource());
|
|
34
|
+
const [message, setMessage] = useState("none");
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<>
|
|
38
|
+
<button data-testid="hello-btn" onClick={() => setMessage("hello")} />
|
|
39
|
+
<div data-testid="message">{message}</div>
|
|
40
|
+
<div data-testid="load">{load ? "true" : "false"}</div>
|
|
41
|
+
|
|
42
|
+
<button
|
|
43
|
+
data-testid="suspend-btn"
|
|
44
|
+
onClick={() => {
|
|
45
|
+
startTransition(() => {
|
|
46
|
+
setLoading(true);
|
|
47
|
+
});
|
|
48
|
+
}}
|
|
49
|
+
/>
|
|
50
|
+
<Suspense fallback={<ShouldNeverFallback />}>
|
|
51
|
+
<div data-testid="value">{load ? <Suspender /> : "none"}</div>
|
|
52
|
+
</Suspense>
|
|
53
|
+
</>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
render(<App />);
|
|
58
|
+
expect(screen.getByTestId("message").textContent).toBe("none");
|
|
59
|
+
expect(screen.getByTestId("value").textContent).toBe("none");
|
|
60
|
+
expect(screen.getByTestId("load").textContent).toBe("false");
|
|
61
|
+
|
|
62
|
+
await act(async () => screen.getByTestId("suspend-btn").click());
|
|
63
|
+
expect(screen.getByTestId("value").textContent).toBe("none");
|
|
64
|
+
expect(screen.getByTestId("load").textContent).toBe("false");
|
|
65
|
+
|
|
66
|
+
await act(async () => screen.getByTestId("hello-btn").click());
|
|
67
|
+
expect(screen.getByTestId("value").textContent).toBe("none");
|
|
68
|
+
expect(screen.getByTestId("message").textContent).toBe("hello");
|
|
69
|
+
expect(screen.getByTestId("load").textContent).toBe("false");
|
|
70
|
+
|
|
71
|
+
await act(async () => resolve!(10));
|
|
72
|
+
|
|
73
|
+
expect(screen.getByTestId("value").textContent).toBe("10");
|
|
74
|
+
expect(screen.getByTestId("message").textContent).toBe("hello");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("react should not commit tapState updates when render is discarded", async () => {
|
|
78
|
+
let resolve: (value: number) => void;
|
|
79
|
+
|
|
80
|
+
const suspendPromise = new Promise<number>((r) => {
|
|
81
|
+
resolve = r;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
function Suspender() {
|
|
85
|
+
const result = use(suspendPromise);
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function App() {
|
|
90
|
+
const [load, setLoading] = useState(false);
|
|
91
|
+
const [message, setMessage] = useState("none");
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<>
|
|
95
|
+
<button data-testid="hello-btn" onClick={() => setMessage("hello")} />
|
|
96
|
+
<div data-testid="message">{message}</div>
|
|
97
|
+
<div data-testid="load">{load ? "true" : "false"}</div>
|
|
98
|
+
|
|
99
|
+
<button
|
|
100
|
+
data-testid="suspend-btn"
|
|
101
|
+
onClick={() => {
|
|
102
|
+
startTransition(() => {
|
|
103
|
+
setLoading(true);
|
|
104
|
+
});
|
|
105
|
+
}}
|
|
106
|
+
/>
|
|
107
|
+
<Suspense fallback={<ShouldNeverFallback />}>
|
|
108
|
+
<div data-testid="value">{load ? <Suspender /> : "none"}</div>
|
|
109
|
+
</Suspense>
|
|
110
|
+
</>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
render(<App />);
|
|
115
|
+
expect(screen.getByTestId("message").textContent).toBe("none");
|
|
116
|
+
expect(screen.getByTestId("value").textContent).toBe("none");
|
|
117
|
+
expect(screen.getByTestId("load").textContent).toBe("false");
|
|
118
|
+
|
|
119
|
+
await act(async () => screen.getByTestId("suspend-btn").click());
|
|
120
|
+
expect(screen.getByTestId("value").textContent).toBe("none");
|
|
121
|
+
expect(screen.getByTestId("load").textContent).toBe("false");
|
|
122
|
+
|
|
123
|
+
await act(async () => screen.getByTestId("hello-btn").click());
|
|
124
|
+
expect(screen.getByTestId("value").textContent).toBe("none");
|
|
125
|
+
expect(screen.getByTestId("message").textContent).toBe("hello");
|
|
126
|
+
expect(screen.getByTestId("load").textContent).toBe("false"); // no tearing
|
|
127
|
+
|
|
128
|
+
await act(async () => resolve!(10));
|
|
129
|
+
|
|
130
|
+
expect(screen.getByTestId("value").textContent).toBe("10");
|
|
131
|
+
expect(screen.getByTestId("message").textContent).toBe("hello");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("should keep old UI during startTransition when resource suspends", async () => {
|
|
135
|
+
let resolve: () => void;
|
|
136
|
+
let shouldSuspend = false;
|
|
137
|
+
|
|
138
|
+
const TestResource = resource((props: { id: number }) => {
|
|
139
|
+
if (shouldSuspend) {
|
|
140
|
+
throw new Promise<void>((r) => {
|
|
141
|
+
resolve = r;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return { value: `content-${props.id}` };
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
function Inner({ id }: { id: number }) {
|
|
148
|
+
const result = useResource(TestResource({ id }));
|
|
149
|
+
return <div data-testid="result">{result.value}</div>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function App() {
|
|
153
|
+
const [id, setId] = useState(1);
|
|
154
|
+
return (
|
|
155
|
+
<div>
|
|
156
|
+
<button
|
|
157
|
+
data-testid="btn"
|
|
158
|
+
onClick={() => {
|
|
159
|
+
shouldSuspend = true;
|
|
160
|
+
startTransition(() => setId(2));
|
|
161
|
+
}}
|
|
162
|
+
/>
|
|
163
|
+
<Suspense fallback={<div data-testid="fallback">Loading</div>}>
|
|
164
|
+
<Inner id={id} />
|
|
165
|
+
</Suspense>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
render(<App />);
|
|
171
|
+
expect(screen.getByTestId("result").textContent).toBe("content-1");
|
|
172
|
+
|
|
173
|
+
// Click triggers transition that suspends
|
|
174
|
+
act(() => screen.getByTestId("btn").click());
|
|
175
|
+
|
|
176
|
+
// Old UI preserved during transition
|
|
177
|
+
expect(screen.getByTestId("result").textContent).toBe("content-1");
|
|
178
|
+
|
|
179
|
+
// Resolve suspension
|
|
180
|
+
shouldSuspend = false;
|
|
181
|
+
await act(async () => resolve());
|
|
182
|
+
|
|
183
|
+
// New UI shown
|
|
184
|
+
expect(screen.getByTestId("result").textContent).toBe("content-2");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("react test", async () => {
|
|
188
|
+
let resolve: (value: number) => void;
|
|
189
|
+
|
|
190
|
+
const suspendPromise = new Promise<number>((r) => {
|
|
191
|
+
resolve = r;
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
function Suspender() {
|
|
195
|
+
const result = use(suspendPromise);
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function App() {
|
|
200
|
+
const [load, setLoading] = useState(false);
|
|
201
|
+
const [message, setMessage] = useState("none");
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<>
|
|
205
|
+
<button data-testid="hello-btn" onClick={() => setMessage("hello")} />
|
|
206
|
+
<div data-testid="message">{message}</div>
|
|
207
|
+
<div data-testid="load">{load ? "true" : "false"}</div>
|
|
208
|
+
|
|
209
|
+
<button
|
|
210
|
+
data-testid="suspend-btn"
|
|
211
|
+
onClick={() => {
|
|
212
|
+
startTransition(() => {
|
|
213
|
+
setLoading(true);
|
|
214
|
+
});
|
|
215
|
+
}}
|
|
216
|
+
/>
|
|
217
|
+
<Suspense fallback={<ShouldNeverFallback />}>
|
|
218
|
+
<div data-testid="value">{load ? <Suspender /> : "none"}</div>
|
|
219
|
+
</Suspense>
|
|
220
|
+
</>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
render(<App />);
|
|
225
|
+
expect(screen.getByTestId("message").textContent).toBe("none");
|
|
226
|
+
expect(screen.getByTestId("value").textContent).toBe("none");
|
|
227
|
+
expect(screen.getByTestId("load").textContent).toBe("false");
|
|
228
|
+
|
|
229
|
+
await act(async () => screen.getByTestId("suspend-btn").click());
|
|
230
|
+
expect(screen.getByTestId("value").textContent).toBe("none");
|
|
231
|
+
expect(screen.getByTestId("load").textContent).toBe("false");
|
|
232
|
+
|
|
233
|
+
await act(async () => screen.getByTestId("hello-btn").click());
|
|
234
|
+
expect(screen.getByTestId("value").textContent).toBe("none");
|
|
235
|
+
expect(screen.getByTestId("message").textContent).toBe("hello");
|
|
236
|
+
expect(screen.getByTestId("load").textContent).toBe("false"); // no tearing
|
|
237
|
+
|
|
238
|
+
await act(async () => resolve!(10));
|
|
239
|
+
|
|
240
|
+
expect(screen.getByTestId("value").textContent).toBe("10");
|
|
241
|
+
expect(screen.getByTestId("message").textContent).toBe("hello");
|
|
242
|
+
});
|
|
243
|
+
});
|