@assistant-ui/tap 0.3.4 → 0.3.5
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/dist/core/ResourceFiber.d.ts +1 -1
- package/dist/core/ResourceFiber.d.ts.map +1 -1
- package/dist/core/ResourceFiber.js +35 -40
- package/dist/core/ResourceFiber.js.map +1 -1
- package/dist/core/callResourceFn.js +15 -12
- package/dist/core/callResourceFn.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 +57 -54
- package/dist/core/commit.js.map +1 -1
- package/dist/core/context.js +16 -21
- package/dist/core/context.js.map +1 -1
- package/dist/core/createResource.d.ts +1 -1
- package/dist/core/createResource.d.ts.map +1 -1
- package/dist/core/createResource.js +54 -67
- package/dist/core/createResource.js.map +1 -1
- package/dist/core/execution-context.d.ts +1 -1
- package/dist/core/execution-context.d.ts.map +1 -1
- package/dist/core/execution-context.js +21 -25
- package/dist/core/execution-context.js.map +1 -1
- package/dist/core/resource.d.ts +1 -1
- package/dist/core/resource.d.ts.map +1 -1
- package/dist/core/resource.js +8 -12
- package/dist/core/resource.js.map +1 -1
- package/dist/core/scheduler.js +73 -72
- package/dist/core/scheduler.js.map +1 -1
- package/dist/core/types.d.ts +3 -3
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +1 -0
- package/dist/core/types.js.map +1 -1
- package/dist/hooks/depsShallowEqual.js +8 -10
- package/dist/hooks/depsShallowEqual.js.map +1 -1
- package/dist/hooks/tap-callback.js +2 -6
- package/dist/hooks/tap-callback.js.map +1 -1
- package/dist/hooks/tap-effect-event.js +21 -10
- package/dist/hooks/tap-effect-event.js.map +1 -1
- package/dist/hooks/tap-effect.js +30 -31
- package/dist/hooks/tap-effect.js.map +1 -1
- package/dist/hooks/tap-inline-resource.d.ts +1 -1
- package/dist/hooks/tap-inline-resource.d.ts.map +1 -1
- package/dist/hooks/tap-inline-resource.js +2 -6
- package/dist/hooks/tap-inline-resource.js.map +1 -1
- package/dist/hooks/tap-memo.js +10 -14
- package/dist/hooks/tap-memo.js.map +1 -1
- package/dist/hooks/tap-ref.js +5 -9
- package/dist/hooks/tap-ref.js.map +1 -1
- package/dist/hooks/tap-resource.d.ts +1 -1
- package/dist/hooks/tap-resource.d.ts.map +1 -1
- package/dist/hooks/tap-resource.js +13 -28
- package/dist/hooks/tap-resource.js.map +1 -1
- package/dist/hooks/tap-resources.d.ts +1 -1
- package/dist/hooks/tap-resources.d.ts.map +1 -1
- package/dist/hooks/tap-resources.js +63 -64
- package/dist/hooks/tap-resources.js.map +1 -1
- package/dist/hooks/tap-state.js +47 -44
- package/dist/hooks/tap-state.js.map +1 -1
- package/dist/index.d.ts +14 -14
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -31
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.ts +1 -1
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -5
- package/dist/react/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 +16 -26
- package/dist/react/use-resource.js.map +1 -1
- package/package.json +44 -30
- package/react/package.json +5 -0
- package/src/__tests__/basic/resourceHandle.test.ts +56 -0
- package/src/__tests__/basic/tapEffect.basic.test.ts +247 -0
- package/src/__tests__/basic/tapResources.basic.test.ts +222 -0
- package/src/__tests__/basic/tapState.basic.test.ts +240 -0
- package/src/__tests__/errors/errors.effect-errors.test.ts +222 -0
- package/src/__tests__/errors/errors.render-errors.test.ts +190 -0
- package/src/__tests__/lifecycle/lifecycle.dependencies.test.ts +241 -0
- package/src/__tests__/lifecycle/lifecycle.mount-unmount.test.ts +211 -0
- package/src/__tests__/rules/rules.hook-count.test.ts +200 -0
- package/src/__tests__/rules/rules.hook-order.test.ts +192 -0
- package/src/__tests__/test-utils.ts +219 -0
- package/src/core/ResourceFiber.ts +58 -0
- package/src/core/callResourceFn.ts +21 -0
- package/src/core/commit.ts +73 -0
- package/src/core/context.ts +28 -0
- package/src/core/createResource.ts +116 -0
- package/src/core/execution-context.ts +34 -0
- package/src/core/resource.ts +16 -0
- package/src/core/scheduler.ts +95 -0
- package/src/core/types.ts +59 -0
- package/src/hooks/depsShallowEqual.ts +10 -0
- package/src/hooks/tap-callback.ts +8 -0
- package/src/hooks/tap-effect-event.ts +29 -0
- package/src/hooks/tap-effect.ts +59 -0
- package/src/hooks/tap-inline-resource.ts +8 -0
- package/src/hooks/tap-memo.ts +16 -0
- package/src/hooks/tap-ref.ts +16 -0
- package/src/hooks/tap-resource.ts +44 -0
- package/src/hooks/tap-resources.ts +112 -0
- package/src/hooks/tap-state.ts +83 -0
- package/src/index.ts +31 -0
- package/src/react/index.ts +1 -0
- package/src/react/use-resource.ts +35 -0
- package/dist/__tests__/test-utils.d.ts +0 -79
- package/dist/__tests__/test-utils.d.ts.map +0 -1
- package/dist/__tests__/test-utils.js +0 -138
- package/dist/__tests__/test-utils.js.map +0 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { tapEffect } from "../../hooks/tap-effect";
|
|
3
|
+
import { tapState } from "../../hooks/tap-state";
|
|
4
|
+
import { createTestResource, renderTest, waitForNextTick } from "../test-utils";
|
|
5
|
+
import {
|
|
6
|
+
renderResourceFiber,
|
|
7
|
+
commitResourceFiber,
|
|
8
|
+
unmountResourceFiber,
|
|
9
|
+
} from "../../core/ResourceFiber";
|
|
10
|
+
|
|
11
|
+
describe("Lifecycle - Dependencies", () => {
|
|
12
|
+
it("should re-run effect when deps change", async () => {
|
|
13
|
+
const effect = vi.fn();
|
|
14
|
+
let setDep: any;
|
|
15
|
+
|
|
16
|
+
const resource = createTestResource(() => {
|
|
17
|
+
const [dep, _setDep] = tapState(1);
|
|
18
|
+
setDep = _setDep;
|
|
19
|
+
|
|
20
|
+
tapEffect(effect, [dep]);
|
|
21
|
+
return dep;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
renderTest(resource, undefined);
|
|
25
|
+
expect(effect).toHaveBeenCalledTimes(1);
|
|
26
|
+
|
|
27
|
+
// Change dependency - this triggers automatic re-render
|
|
28
|
+
setDep(2);
|
|
29
|
+
|
|
30
|
+
// Wait for scheduled re-render
|
|
31
|
+
await waitForNextTick();
|
|
32
|
+
expect(effect).toHaveBeenCalledTimes(2);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should not re-run effect when deps are same", async () => {
|
|
36
|
+
const effect = vi.fn();
|
|
37
|
+
let triggerRerender: any;
|
|
38
|
+
|
|
39
|
+
const resource = createTestResource(() => {
|
|
40
|
+
const [count, setCount] = tapState(0);
|
|
41
|
+
const [dep] = tapState("constant");
|
|
42
|
+
triggerRerender = setCount;
|
|
43
|
+
|
|
44
|
+
tapEffect(effect, [dep]);
|
|
45
|
+
return { count, dep };
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
renderTest(resource, undefined);
|
|
49
|
+
expect(effect).toHaveBeenCalledTimes(1);
|
|
50
|
+
|
|
51
|
+
// Trigger re-render without changing dep
|
|
52
|
+
triggerRerender(1);
|
|
53
|
+
|
|
54
|
+
// Wait for scheduled re-render
|
|
55
|
+
await waitForNextTick();
|
|
56
|
+
expect(effect).toHaveBeenCalledTimes(1); // Should not re-run
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("should run cleanup before effect re-runs", () => {
|
|
60
|
+
const log: string[] = [];
|
|
61
|
+
let setDep: any;
|
|
62
|
+
|
|
63
|
+
const resource = createTestResource(() => {
|
|
64
|
+
const [dep, _setDep] = tapState(1);
|
|
65
|
+
setDep = _setDep;
|
|
66
|
+
|
|
67
|
+
tapEffect(() => {
|
|
68
|
+
log.push(`effect-${dep}`);
|
|
69
|
+
return () => log.push(`cleanup-${dep}`);
|
|
70
|
+
}, [dep]);
|
|
71
|
+
|
|
72
|
+
return dep;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
renderTest(resource, undefined);
|
|
76
|
+
expect(log).toEqual(["effect-1"]);
|
|
77
|
+
|
|
78
|
+
// Change dep
|
|
79
|
+
setDep(2);
|
|
80
|
+
const ctx = renderResourceFiber(resource, undefined);
|
|
81
|
+
commitResourceFiber(resource, ctx);
|
|
82
|
+
|
|
83
|
+
expect(log).toEqual(["effect-1", "cleanup-1", "effect-2"]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("should handle undefined deps (always re-run)", async () => {
|
|
87
|
+
const effect = vi.fn();
|
|
88
|
+
let triggerRerender: any;
|
|
89
|
+
|
|
90
|
+
const resource = createTestResource(() => {
|
|
91
|
+
const [count, setCount] = tapState(0);
|
|
92
|
+
triggerRerender = setCount;
|
|
93
|
+
|
|
94
|
+
tapEffect(effect); // No deps = always re-run
|
|
95
|
+
return count;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
renderTest(resource, undefined);
|
|
99
|
+
expect(effect).toHaveBeenCalledTimes(1);
|
|
100
|
+
|
|
101
|
+
// Re-render
|
|
102
|
+
triggerRerender(1);
|
|
103
|
+
|
|
104
|
+
await waitForNextTick();
|
|
105
|
+
|
|
106
|
+
expect(effect).toHaveBeenCalledTimes(2); // Should re-run
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("should handle empty deps array (run once)", () => {
|
|
110
|
+
const effect = vi.fn();
|
|
111
|
+
let triggerRerender: any;
|
|
112
|
+
|
|
113
|
+
const resource = createTestResource(() => {
|
|
114
|
+
const [count, setCount] = tapState(0);
|
|
115
|
+
triggerRerender = setCount;
|
|
116
|
+
|
|
117
|
+
tapEffect(effect, []); // Empty deps = run once
|
|
118
|
+
return count;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
renderTest(resource, undefined);
|
|
122
|
+
expect(effect).toHaveBeenCalledTimes(1);
|
|
123
|
+
|
|
124
|
+
// Re-render
|
|
125
|
+
triggerRerender(1);
|
|
126
|
+
const ctx = renderResourceFiber(resource, undefined);
|
|
127
|
+
commitResourceFiber(resource, ctx);
|
|
128
|
+
|
|
129
|
+
expect(effect).toHaveBeenCalledTimes(1); // Should not re-run
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("should handle multiple dependencies", () => {
|
|
133
|
+
const effect = vi.fn();
|
|
134
|
+
let setDep1: any, setDep2: any;
|
|
135
|
+
|
|
136
|
+
const resource = createTestResource(() => {
|
|
137
|
+
const [dep1, _setDep1] = tapState("a");
|
|
138
|
+
const [dep2, _setDep2] = tapState(1);
|
|
139
|
+
setDep1 = _setDep1;
|
|
140
|
+
setDep2 = _setDep2;
|
|
141
|
+
|
|
142
|
+
tapEffect(effect, [dep1, dep2]);
|
|
143
|
+
return { dep1, dep2 };
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Initial render
|
|
147
|
+
let ctx = renderResourceFiber(resource, undefined);
|
|
148
|
+
commitResourceFiber(resource, ctx);
|
|
149
|
+
expect(effect).toHaveBeenCalledTimes(1);
|
|
150
|
+
|
|
151
|
+
// Change first dep
|
|
152
|
+
setDep1("b");
|
|
153
|
+
ctx = renderResourceFiber(resource, undefined);
|
|
154
|
+
commitResourceFiber(resource, ctx);
|
|
155
|
+
expect(effect).toHaveBeenCalledTimes(2);
|
|
156
|
+
|
|
157
|
+
// Change second dep
|
|
158
|
+
setDep2(2);
|
|
159
|
+
ctx = renderResourceFiber(resource, undefined);
|
|
160
|
+
commitResourceFiber(resource, ctx);
|
|
161
|
+
expect(effect).toHaveBeenCalledTimes(3);
|
|
162
|
+
|
|
163
|
+
// Change both deps
|
|
164
|
+
setDep1("c");
|
|
165
|
+
setDep2(3);
|
|
166
|
+
ctx = renderResourceFiber(resource, undefined);
|
|
167
|
+
commitResourceFiber(resource, ctx);
|
|
168
|
+
expect(effect).toHaveBeenCalledTimes(4);
|
|
169
|
+
|
|
170
|
+
unmountResourceFiber(resource);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("should use Object.is for dependency comparison", () => {
|
|
174
|
+
const effect = vi.fn();
|
|
175
|
+
let setObj: any;
|
|
176
|
+
|
|
177
|
+
const resource = createTestResource(() => {
|
|
178
|
+
const [obj, _setObj] = tapState({ value: 1 });
|
|
179
|
+
setObj = _setObj;
|
|
180
|
+
|
|
181
|
+
tapEffect(effect, [obj]);
|
|
182
|
+
return obj;
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
renderTest(resource, undefined);
|
|
186
|
+
expect(effect).toHaveBeenCalledTimes(1);
|
|
187
|
+
|
|
188
|
+
// Set to new object with same shape
|
|
189
|
+
setObj({ value: 1 });
|
|
190
|
+
const ctx = renderResourceFiber(resource, undefined);
|
|
191
|
+
commitResourceFiber(resource, ctx);
|
|
192
|
+
|
|
193
|
+
expect(effect).toHaveBeenCalledTimes(2); // Should re-run (different object)
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("should handle NaN in dependencies", () => {
|
|
197
|
+
const effect = vi.fn();
|
|
198
|
+
let setValue: any;
|
|
199
|
+
|
|
200
|
+
const resource = createTestResource(() => {
|
|
201
|
+
const [value, _setValue] = tapState(NaN);
|
|
202
|
+
setValue = _setValue;
|
|
203
|
+
|
|
204
|
+
tapEffect(effect, [value]);
|
|
205
|
+
return value;
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
renderTest(resource, undefined);
|
|
209
|
+
expect(effect).toHaveBeenCalledTimes(1);
|
|
210
|
+
|
|
211
|
+
// Set to NaN again
|
|
212
|
+
const ctx = renderResourceFiber(resource, undefined);
|
|
213
|
+
setValue(NaN);
|
|
214
|
+
commitResourceFiber(resource, ctx);
|
|
215
|
+
|
|
216
|
+
expect(effect).toHaveBeenCalledTimes(1); // Should not re-run (NaN === NaN in Object.is)
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("should throw error when mixing deps and no-deps", () => {
|
|
220
|
+
let useDeps = true;
|
|
221
|
+
|
|
222
|
+
const resource = createTestResource(() => {
|
|
223
|
+
if (useDeps) {
|
|
224
|
+
tapEffect(() => {}, [1]);
|
|
225
|
+
} else {
|
|
226
|
+
tapEffect(() => {}); // No deps
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
renderTest(resource, undefined);
|
|
232
|
+
|
|
233
|
+
// Change to no deps
|
|
234
|
+
useDeps = false;
|
|
235
|
+
const ctx = renderResourceFiber(resource, undefined);
|
|
236
|
+
|
|
237
|
+
expect(() => commitResourceFiber(resource, ctx)).toThrow(
|
|
238
|
+
"tapEffect called with and without dependencies across re-renders",
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { tapEffect } from "../../hooks/tap-effect";
|
|
3
|
+
import { tapState } from "../../hooks/tap-state";
|
|
4
|
+
import { createTestResource, renderTest, unmountResource } from "../test-utils";
|
|
5
|
+
import {
|
|
6
|
+
renderResourceFiber,
|
|
7
|
+
commitResourceFiber,
|
|
8
|
+
unmountResourceFiber,
|
|
9
|
+
} from "../../core/ResourceFiber";
|
|
10
|
+
|
|
11
|
+
describe("Lifecycle - Mount/Unmount", () => {
|
|
12
|
+
it("should run all effects on mount", () => {
|
|
13
|
+
const effects = [vi.fn(), vi.fn(), vi.fn()];
|
|
14
|
+
|
|
15
|
+
const resource = createTestResource(() => {
|
|
16
|
+
effects.forEach((fn) => tapEffect(fn));
|
|
17
|
+
return null;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
renderTest(resource, undefined);
|
|
21
|
+
|
|
22
|
+
effects.forEach((fn) => {
|
|
23
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should cleanup all effects on unmount", () => {
|
|
28
|
+
const cleanups = [vi.fn(), vi.fn(), vi.fn()];
|
|
29
|
+
|
|
30
|
+
const resource = createTestResource(() => {
|
|
31
|
+
cleanups.forEach((cleanup) => {
|
|
32
|
+
tapEffect(() => cleanup);
|
|
33
|
+
});
|
|
34
|
+
return null;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
renderTest(resource, undefined);
|
|
38
|
+
cleanups.forEach((fn) => expect(fn).not.toHaveBeenCalled());
|
|
39
|
+
|
|
40
|
+
unmountResource(resource);
|
|
41
|
+
cleanups.forEach((fn) => expect(fn).toHaveBeenCalledTimes(1));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should cleanup effects in reverse order", () => {
|
|
45
|
+
const order: number[] = [];
|
|
46
|
+
|
|
47
|
+
const resource = createTestResource(() => {
|
|
48
|
+
tapEffect(() => () => order.push(1));
|
|
49
|
+
tapEffect(() => () => order.push(2));
|
|
50
|
+
tapEffect(() => () => order.push(3));
|
|
51
|
+
return null;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
renderTest(resource, undefined);
|
|
55
|
+
unmountResource(resource);
|
|
56
|
+
|
|
57
|
+
expect(order).toEqual([3, 2, 1]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("should preserve state across re-renders", () => {
|
|
61
|
+
let renderCount = 0;
|
|
62
|
+
let setState: any;
|
|
63
|
+
let effectRunCount = 0;
|
|
64
|
+
|
|
65
|
+
const resource = createTestResource((props: number) => {
|
|
66
|
+
renderCount++;
|
|
67
|
+
const [state, _setState] = tapState({ count: 0 });
|
|
68
|
+
setState = _setState;
|
|
69
|
+
|
|
70
|
+
// Simple effect that tracks runs
|
|
71
|
+
tapEffect(() => {
|
|
72
|
+
effectRunCount++;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return { ...state, renderCount, currentProps: props };
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const result1 = renderTest(resource, 1);
|
|
79
|
+
expect(result1.count).toBe(0);
|
|
80
|
+
expect(result1.renderCount).toBe(1);
|
|
81
|
+
expect(effectRunCount).toBe(1);
|
|
82
|
+
|
|
83
|
+
// Update state manually - should trigger re-render
|
|
84
|
+
setState({ count: 42 });
|
|
85
|
+
|
|
86
|
+
// Re-render with same input - note: renderTest always renders
|
|
87
|
+
const result2 = renderTest(resource, 1);
|
|
88
|
+
expect(result2.count).toBe(42); // State preserved
|
|
89
|
+
expect(result2.currentProps).toBe(1); // Same props
|
|
90
|
+
expect(result2.renderCount).toBe(3); // 1 initial + 1 from setState + 1 from renderResource
|
|
91
|
+
|
|
92
|
+
// Re-render with new input
|
|
93
|
+
const result3 = renderTest(resource, 2);
|
|
94
|
+
expect(result3.count).toBe(42); // State still preserved
|
|
95
|
+
expect(result3.currentProps).toBe(2); // New props used
|
|
96
|
+
expect(result3.renderCount).toBe(4); // Another render
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should handle mixed state and effects lifecycle", () => {
|
|
100
|
+
const log: string[] = [];
|
|
101
|
+
|
|
102
|
+
const resource = createTestResource(() => {
|
|
103
|
+
const [mounted, setMounted] = tapState(false);
|
|
104
|
+
|
|
105
|
+
log.push("render");
|
|
106
|
+
|
|
107
|
+
tapEffect(() => {
|
|
108
|
+
log.push("effect-1");
|
|
109
|
+
setMounted(true);
|
|
110
|
+
|
|
111
|
+
return () => log.push("cleanup-1");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
tapEffect(() => {
|
|
115
|
+
log.push("effect-2");
|
|
116
|
+
return () => log.push("cleanup-2");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return mounted;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Initial render
|
|
123
|
+
const ctx = renderResourceFiber(resource, undefined);
|
|
124
|
+
expect(log).toEqual(["render"]);
|
|
125
|
+
|
|
126
|
+
// Commit - effects will run
|
|
127
|
+
commitResourceFiber(resource, ctx);
|
|
128
|
+
// After commit: initial render + effects
|
|
129
|
+
expect(log).toEqual(["render", "effect-1", "effect-2"]);
|
|
130
|
+
|
|
131
|
+
// The setState in effect schedules a re-render
|
|
132
|
+
// With the new architecture, we need to manually trigger it
|
|
133
|
+
const ctx2 = renderResourceFiber(resource, undefined);
|
|
134
|
+
commitResourceFiber(resource, ctx2);
|
|
135
|
+
|
|
136
|
+
// Now we should see the re-render and cleanup/re-run of effects
|
|
137
|
+
expect(log).toEqual([
|
|
138
|
+
"render",
|
|
139
|
+
"effect-1",
|
|
140
|
+
"effect-2",
|
|
141
|
+
"render", // Re-render triggered by setMounted(true)
|
|
142
|
+
"cleanup-1", // Cleanup from first render
|
|
143
|
+
"effect-1", // Effect from re-render
|
|
144
|
+
"cleanup-2", // Cleanup from first render
|
|
145
|
+
"effect-2", // Effect from re-render
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
// Clear log for unmount testing
|
|
149
|
+
log.length = 0;
|
|
150
|
+
|
|
151
|
+
// Unmount
|
|
152
|
+
unmountResourceFiber(resource);
|
|
153
|
+
expect(log).toEqual(["cleanup-2", "cleanup-1"]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("should handle cleanup errors gracefully", () => {
|
|
157
|
+
const error = new Error("Cleanup error");
|
|
158
|
+
const goodCleanup = vi.fn();
|
|
159
|
+
|
|
160
|
+
const resource = createTestResource(() => {
|
|
161
|
+
tapEffect(() => () => {
|
|
162
|
+
throw error;
|
|
163
|
+
});
|
|
164
|
+
tapEffect(() => goodCleanup);
|
|
165
|
+
return null;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
renderTest(resource, undefined);
|
|
169
|
+
|
|
170
|
+
// Unmount should throw the error
|
|
171
|
+
expect(() => unmountResource(resource)).toThrow(error);
|
|
172
|
+
expect(goodCleanup).toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("should not run cleanup if effect never ran", () => {
|
|
176
|
+
const cleanup = vi.fn();
|
|
177
|
+
const skipEffect = true;
|
|
178
|
+
|
|
179
|
+
const resource = createTestResource(() => {
|
|
180
|
+
if (!skipEffect) {
|
|
181
|
+
tapEffect(() => cleanup);
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
renderTest(resource, undefined);
|
|
187
|
+
unmountResource(resource);
|
|
188
|
+
|
|
189
|
+
expect(cleanup).not.toHaveBeenCalled();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("should handle immediate unmount after mount", () => {
|
|
193
|
+
const effect = vi.fn();
|
|
194
|
+
const cleanup = vi.fn();
|
|
195
|
+
|
|
196
|
+
const resource = createTestResource(() => {
|
|
197
|
+
tapEffect(() => {
|
|
198
|
+
effect();
|
|
199
|
+
return cleanup;
|
|
200
|
+
});
|
|
201
|
+
return null;
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const ctx = renderResourceFiber(resource, undefined);
|
|
205
|
+
commitResourceFiber(resource, ctx);
|
|
206
|
+
unmountResourceFiber(resource);
|
|
207
|
+
|
|
208
|
+
expect(effect).toHaveBeenCalledTimes(1);
|
|
209
|
+
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { tapEffect } from "../../hooks/tap-effect";
|
|
3
|
+
import { tapState } from "../../hooks/tap-state";
|
|
4
|
+
import { createTestResource, renderTest } from "../test-utils";
|
|
5
|
+
import { renderResourceFiber } from "../../core/ResourceFiber";
|
|
6
|
+
|
|
7
|
+
describe("Rules of Hooks - Hook Count", () => {
|
|
8
|
+
it("should establish hook count on first render", () => {
|
|
9
|
+
const resource = createTestResource(() => {
|
|
10
|
+
const [a] = tapState(1);
|
|
11
|
+
const [b] = tapState(2);
|
|
12
|
+
const [c] = tapState(3);
|
|
13
|
+
tapEffect(() => {});
|
|
14
|
+
tapEffect(() => {});
|
|
15
|
+
|
|
16
|
+
return { a, b, c };
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// First render establishes 5 hooks
|
|
20
|
+
renderTest(resource, undefined);
|
|
21
|
+
|
|
22
|
+
// Second render should work with same count
|
|
23
|
+
expect(() => {
|
|
24
|
+
renderTest(resource, undefined);
|
|
25
|
+
}).not.toThrow();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should throw when rendering more hooks than first render", () => {
|
|
29
|
+
let addExtraHook = false;
|
|
30
|
+
|
|
31
|
+
const resource = createTestResource(() => {
|
|
32
|
+
tapState(1);
|
|
33
|
+
tapState(2);
|
|
34
|
+
|
|
35
|
+
if (addExtraHook) {
|
|
36
|
+
tapState(3); // Extra hook
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return null;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// First render with 2 hooks
|
|
43
|
+
renderResourceFiber(resource, undefined);
|
|
44
|
+
|
|
45
|
+
// Try to render with 3 hooks
|
|
46
|
+
addExtraHook = true;
|
|
47
|
+
|
|
48
|
+
expect(() => renderResourceFiber(resource, undefined)).toThrow(
|
|
49
|
+
"Rendered more hooks than during the previous render",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("should throw when rendering fewer hooks than first render", () => {
|
|
54
|
+
let skipHook = false;
|
|
55
|
+
|
|
56
|
+
const resource = createTestResource(() => {
|
|
57
|
+
tapState(1);
|
|
58
|
+
|
|
59
|
+
if (!skipHook) {
|
|
60
|
+
tapState(2);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
tapState(3);
|
|
64
|
+
return null;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// First render with 3 hooks
|
|
68
|
+
renderResourceFiber(resource, undefined);
|
|
69
|
+
|
|
70
|
+
// Try to render with 2 hooks
|
|
71
|
+
skipHook = true;
|
|
72
|
+
|
|
73
|
+
expect(() => renderResourceFiber(resource, undefined)).toThrow(
|
|
74
|
+
"Rendered 2 hooks but expected 3",
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("should detect hook count mismatch with effects", () => {
|
|
79
|
+
let includeEffect = true;
|
|
80
|
+
|
|
81
|
+
const resource = createTestResource(() => {
|
|
82
|
+
tapState(1);
|
|
83
|
+
tapState(2);
|
|
84
|
+
|
|
85
|
+
if (includeEffect) {
|
|
86
|
+
tapEffect(() => {});
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
renderResourceFiber(resource, undefined);
|
|
92
|
+
|
|
93
|
+
includeEffect = false;
|
|
94
|
+
|
|
95
|
+
expect(() => renderResourceFiber(resource, undefined)).toThrow(
|
|
96
|
+
"Rendered 2 hooks but expected 3",
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("should handle zero hooks consistently", () => {
|
|
101
|
+
const resource = createTestResource(() => {
|
|
102
|
+
// No hooks
|
|
103
|
+
return "no hooks";
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
renderTest(resource, undefined);
|
|
107
|
+
|
|
108
|
+
// Should allow multiple renders with zero hooks
|
|
109
|
+
expect(() => renderTest(resource, undefined)).not.toThrow();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("should detect dynamic hook creation", () => {
|
|
113
|
+
let hookCount = 2;
|
|
114
|
+
|
|
115
|
+
const resource = createTestResource(() => {
|
|
116
|
+
for (let i = 0; i < hookCount; i++) {
|
|
117
|
+
tapState(i);
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
renderResourceFiber(resource, undefined);
|
|
123
|
+
|
|
124
|
+
// Change hook count
|
|
125
|
+
hookCount = 3;
|
|
126
|
+
|
|
127
|
+
expect(() => renderResourceFiber(resource, undefined)).toThrow(
|
|
128
|
+
"Rendered more hooks than during the previous render",
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("should maintain count across multiple re-renders", () => {
|
|
133
|
+
let renderCount = 0;
|
|
134
|
+
|
|
135
|
+
const resource = createTestResource(() => {
|
|
136
|
+
renderCount++;
|
|
137
|
+
const [a] = tapState(1);
|
|
138
|
+
const [b] = tapState(2);
|
|
139
|
+
tapEffect(() => {});
|
|
140
|
+
|
|
141
|
+
return { a, b, renderCount };
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Multiple renders should all maintain same hook count
|
|
145
|
+
for (let i = 0; i < 5; i++) {
|
|
146
|
+
expect(() => renderTest(resource, undefined)).not.toThrow();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
expect(renderCount).toBe(5);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("should track count separately for different resource instances", () => {
|
|
153
|
+
const resource1 = createTestResource(() => {
|
|
154
|
+
tapState(1);
|
|
155
|
+
tapState(2);
|
|
156
|
+
return "two hooks";
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const resource2 = createTestResource(() => {
|
|
160
|
+
tapState(1);
|
|
161
|
+
tapState(2);
|
|
162
|
+
tapState(3);
|
|
163
|
+
tapEffect(() => {});
|
|
164
|
+
return "four hooks";
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Render both
|
|
168
|
+
renderTest(resource1, undefined);
|
|
169
|
+
renderTest(resource2, undefined);
|
|
170
|
+
|
|
171
|
+
// Each should maintain its own count
|
|
172
|
+
expect(() => renderTest(resource1, undefined)).not.toThrow();
|
|
173
|
+
expect(() => renderTest(resource2, undefined)).not.toThrow();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("should detect hook count changes in nested function calls", () => {
|
|
177
|
+
let useExtraHooks = false;
|
|
178
|
+
|
|
179
|
+
const useFeature = () => {
|
|
180
|
+
tapState("feature");
|
|
181
|
+
if (useExtraHooks) {
|
|
182
|
+
tapState("extra");
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const resource = createTestResource(() => {
|
|
187
|
+
tapState("main");
|
|
188
|
+
useFeature();
|
|
189
|
+
return null;
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
renderResourceFiber(resource, undefined);
|
|
193
|
+
|
|
194
|
+
useExtraHooks = true;
|
|
195
|
+
|
|
196
|
+
expect(() => renderResourceFiber(resource, undefined)).toThrow(
|
|
197
|
+
"Rendered more hooks than during the previous render",
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
});
|