@assistant-ui/tap 0.3.3 → 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.
Files changed (107) hide show
  1. package/dist/core/ResourceFiber.d.ts +1 -1
  2. package/dist/core/ResourceFiber.d.ts.map +1 -1
  3. package/dist/core/ResourceFiber.js +35 -40
  4. package/dist/core/ResourceFiber.js.map +1 -1
  5. package/dist/core/callResourceFn.js +15 -12
  6. package/dist/core/callResourceFn.js.map +1 -1
  7. package/dist/core/commit.d.ts +1 -1
  8. package/dist/core/commit.d.ts.map +1 -1
  9. package/dist/core/commit.js +57 -54
  10. package/dist/core/commit.js.map +1 -1
  11. package/dist/core/context.js +16 -21
  12. package/dist/core/context.js.map +1 -1
  13. package/dist/core/createResource.d.ts +1 -1
  14. package/dist/core/createResource.d.ts.map +1 -1
  15. package/dist/core/createResource.js +54 -61
  16. package/dist/core/createResource.js.map +1 -1
  17. package/dist/core/execution-context.d.ts +1 -1
  18. package/dist/core/execution-context.d.ts.map +1 -1
  19. package/dist/core/execution-context.js +21 -25
  20. package/dist/core/execution-context.js.map +1 -1
  21. package/dist/core/resource.d.ts +1 -1
  22. package/dist/core/resource.d.ts.map +1 -1
  23. package/dist/core/resource.js +8 -12
  24. package/dist/core/resource.js.map +1 -1
  25. package/dist/core/scheduler.js +73 -72
  26. package/dist/core/scheduler.js.map +1 -1
  27. package/dist/core/types.d.ts +3 -3
  28. package/dist/core/types.d.ts.map +1 -1
  29. package/dist/core/types.js +1 -0
  30. package/dist/core/types.js.map +1 -1
  31. package/dist/hooks/depsShallowEqual.js +8 -10
  32. package/dist/hooks/depsShallowEqual.js.map +1 -1
  33. package/dist/hooks/tap-callback.js +2 -6
  34. package/dist/hooks/tap-callback.js.map +1 -1
  35. package/dist/hooks/tap-effect-event.js +21 -10
  36. package/dist/hooks/tap-effect-event.js.map +1 -1
  37. package/dist/hooks/tap-effect.js +30 -31
  38. package/dist/hooks/tap-effect.js.map +1 -1
  39. package/dist/hooks/tap-inline-resource.d.ts +1 -1
  40. package/dist/hooks/tap-inline-resource.d.ts.map +1 -1
  41. package/dist/hooks/tap-inline-resource.js +2 -6
  42. package/dist/hooks/tap-inline-resource.js.map +1 -1
  43. package/dist/hooks/tap-memo.js +10 -14
  44. package/dist/hooks/tap-memo.js.map +1 -1
  45. package/dist/hooks/tap-ref.js +5 -9
  46. package/dist/hooks/tap-ref.js.map +1 -1
  47. package/dist/hooks/tap-resource.d.ts +1 -1
  48. package/dist/hooks/tap-resource.d.ts.map +1 -1
  49. package/dist/hooks/tap-resource.js +13 -28
  50. package/dist/hooks/tap-resource.js.map +1 -1
  51. package/dist/hooks/tap-resources.d.ts +1 -1
  52. package/dist/hooks/tap-resources.d.ts.map +1 -1
  53. package/dist/hooks/tap-resources.js +63 -64
  54. package/dist/hooks/tap-resources.js.map +1 -1
  55. package/dist/hooks/tap-state.js +47 -44
  56. package/dist/hooks/tap-state.js.map +1 -1
  57. package/dist/index.d.ts +14 -14
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +18 -31
  60. package/dist/index.js.map +1 -1
  61. package/dist/react/index.d.ts +1 -1
  62. package/dist/react/index.d.ts.map +1 -1
  63. package/dist/react/index.js +1 -5
  64. package/dist/react/index.js.map +1 -1
  65. package/dist/react/use-resource.d.ts +1 -1
  66. package/dist/react/use-resource.d.ts.map +1 -1
  67. package/dist/react/use-resource.js +16 -26
  68. package/dist/react/use-resource.js.map +1 -1
  69. package/package.json +44 -30
  70. package/react/package.json +5 -0
  71. package/src/__tests__/basic/resourceHandle.test.ts +56 -0
  72. package/src/__tests__/basic/tapEffect.basic.test.ts +247 -0
  73. package/src/__tests__/basic/tapResources.basic.test.ts +222 -0
  74. package/src/__tests__/basic/tapState.basic.test.ts +240 -0
  75. package/src/__tests__/errors/errors.effect-errors.test.ts +222 -0
  76. package/src/__tests__/errors/errors.render-errors.test.ts +190 -0
  77. package/src/__tests__/lifecycle/lifecycle.dependencies.test.ts +241 -0
  78. package/src/__tests__/lifecycle/lifecycle.mount-unmount.test.ts +211 -0
  79. package/src/__tests__/rules/rules.hook-count.test.ts +200 -0
  80. package/src/__tests__/rules/rules.hook-order.test.ts +192 -0
  81. package/src/__tests__/test-utils.ts +219 -0
  82. package/src/core/ResourceFiber.ts +58 -0
  83. package/src/core/callResourceFn.ts +21 -0
  84. package/src/core/commit.ts +73 -0
  85. package/src/core/context.ts +28 -0
  86. package/src/core/createResource.ts +116 -0
  87. package/src/core/execution-context.ts +34 -0
  88. package/src/core/resource.ts +16 -0
  89. package/src/core/scheduler.ts +95 -0
  90. package/src/core/types.ts +59 -0
  91. package/src/hooks/depsShallowEqual.ts +10 -0
  92. package/src/hooks/tap-callback.ts +8 -0
  93. package/src/hooks/tap-effect-event.ts +29 -0
  94. package/src/hooks/tap-effect.ts +59 -0
  95. package/src/hooks/tap-inline-resource.ts +8 -0
  96. package/src/hooks/tap-memo.ts +16 -0
  97. package/src/hooks/tap-ref.ts +16 -0
  98. package/src/hooks/tap-resource.ts +44 -0
  99. package/src/hooks/tap-resources.ts +112 -0
  100. package/src/hooks/tap-state.ts +83 -0
  101. package/src/index.ts +31 -0
  102. package/src/react/index.ts +1 -0
  103. package/src/react/use-resource.ts +35 -0
  104. package/dist/__tests__/test-utils.d.ts +0 -79
  105. package/dist/__tests__/test-utils.d.ts.map +0 -1
  106. package/dist/__tests__/test-utils.js +0 -138
  107. 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
+ });