@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.
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 -67
  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,192 @@
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 {
6
+ renderResourceFiber,
7
+ commitResourceFiber,
8
+ } from "../../core/ResourceFiber";
9
+
10
+ describe("Rules of Hooks - Hook Order", () => {
11
+ it("should throw when hooks are called in different order", () => {
12
+ let condition = true;
13
+
14
+ const resource = createTestResource(() => {
15
+ if (condition) {
16
+ tapState(1);
17
+ tapEffect(() => {}, []);
18
+ } else {
19
+ tapEffect(() => {}, []);
20
+ tapState(1);
21
+ }
22
+ return null;
23
+ });
24
+
25
+ // First render establishes order
26
+ renderTest(resource, undefined);
27
+
28
+ // Change condition
29
+ condition = false;
30
+
31
+ // Second render with different order should throw
32
+ expect(() => renderResourceFiber(resource, undefined)).toThrow(
33
+ "Hook order changed between renders",
34
+ );
35
+ });
36
+
37
+ it("should throw when hook types change between renders", () => {
38
+ let useEffect = false;
39
+
40
+ const resource = createTestResource(() => {
41
+ if (useEffect) {
42
+ tapEffect(() => {});
43
+ } else {
44
+ tapState(0);
45
+ }
46
+ return null;
47
+ });
48
+
49
+ renderTest(resource, undefined);
50
+
51
+ // Change to use different hook type
52
+ useEffect = true;
53
+
54
+ expect(() => renderResourceFiber(resource, undefined)).toThrow(
55
+ "Hook order changed between renders",
56
+ );
57
+ });
58
+
59
+ it("should throw with conditional hooks", () => {
60
+ let condition = true;
61
+
62
+ const resource = createTestResource(() => {
63
+ tapState(1);
64
+
65
+ if (condition) {
66
+ tapState(2); // Conditional hook
67
+ }
68
+
69
+ tapState(3);
70
+ return null;
71
+ });
72
+
73
+ renderTest(resource, undefined);
74
+
75
+ // Change condition
76
+ condition = false;
77
+
78
+ // Should throw because hook count changed
79
+ expect(() => renderResourceFiber(resource, undefined)).toThrow(
80
+ "Rendered 2 hooks but expected 3",
81
+ );
82
+ });
83
+
84
+ it("should allow hooks in loops with consistent count", () => {
85
+ const items = [1, 2, 3];
86
+
87
+ const resource = createTestResource(() => {
88
+ const states = items.map((item) => {
89
+ const [value] = tapState(item);
90
+ return value;
91
+ });
92
+
93
+ return states;
94
+ });
95
+
96
+ const result = renderTest(resource, undefined);
97
+ expect(result).toEqual([1, 2, 3]);
98
+
99
+ // Re-render should work fine
100
+ expect(() => renderResourceFiber(resource, undefined)).not.toThrow();
101
+ });
102
+
103
+ it("should throw when hooks in loops have inconsistent count", () => {
104
+ let items = [1, 2, 3];
105
+
106
+ const resource = createTestResource(() => {
107
+ items.forEach((item) => {
108
+ tapState(item);
109
+ });
110
+ return null;
111
+ });
112
+
113
+ renderTest(resource, undefined);
114
+
115
+ // Change array length
116
+ items = [1, 2];
117
+
118
+ expect(() => renderResourceFiber(resource, undefined)).toThrow(
119
+ "Rendered 2 hooks but expected 3",
120
+ );
121
+ });
122
+
123
+ it("should maintain order with mixed hook types", () => {
124
+ const resource = createTestResource(() => {
125
+ const [a] = tapState(1);
126
+ tapEffect(() => {});
127
+ const [b] = tapState(2);
128
+ tapEffect(() => {});
129
+ const [c] = tapState(3);
130
+
131
+ return { a, b, c };
132
+ });
133
+
134
+ const result = renderTest(resource, undefined);
135
+ expect(result).toEqual({ a: 1, b: 2, c: 3 });
136
+
137
+ // Re-render should maintain same order
138
+ const ctx = renderResourceFiber(resource, undefined);
139
+ expect(() => commitResourceFiber(resource, ctx)).not.toThrow();
140
+ });
141
+
142
+ it("should detect early return causing different hook counts", () => {
143
+ let shouldReturn = false;
144
+
145
+ const resource = createTestResource(() => {
146
+ const [a] = tapState(1);
147
+
148
+ if (shouldReturn) {
149
+ return a; // Early return
150
+ }
151
+
152
+ const [b] = tapState(2);
153
+ return a + b;
154
+ });
155
+
156
+ const result1 = renderTest(resource, undefined);
157
+ expect(result1).toBe(3);
158
+
159
+ // Enable early return
160
+ shouldReturn = true;
161
+
162
+ expect(() => renderResourceFiber(resource, undefined)).toThrow(
163
+ "Rendered 1 hooks but expected 2",
164
+ );
165
+ });
166
+
167
+ it("should throw on nested hook calls", () => {
168
+ const resource = createTestResource(() => {
169
+ const [count, setCount] = tapState(0);
170
+
171
+ // This effect contains a hook call, which is invalid
172
+ tapEffect(() => {
173
+ if (count > 0) {
174
+ expect(() => {
175
+ const [_nested] = tapState(0); // Invalid: hook inside effect
176
+ }).toThrow("No resource fiber available");
177
+ }
178
+ });
179
+
180
+ // Use an effect to trigger the state change
181
+ tapEffect(() => {
182
+ if (count === 0) {
183
+ setCount(1);
184
+ }
185
+ }, [count]);
186
+
187
+ return count;
188
+ });
189
+
190
+ renderTest(resource, undefined);
191
+ });
192
+ });
@@ -0,0 +1,219 @@
1
+ import { resource } from "../core/resource";
2
+ import {
3
+ createResourceFiber,
4
+ unmountResourceFiber,
5
+ renderResourceFiber,
6
+ commitResourceFiber,
7
+ } from "../core/ResourceFiber";
8
+ import { ResourceFiber } from "../core/types";
9
+ import { tapState } from "../hooks/tap-state";
10
+
11
+ // ============================================================================
12
+ // Resource Creation
13
+ // ============================================================================
14
+
15
+ /**
16
+ * Creates a test resource fiber for unit testing.
17
+ * This is a low-level utility that creates a ResourceFiber directly.
18
+ * Sets up a rerender callback that automatically re-renders when state changes.
19
+ */
20
+ export function createTestResource<R, P>(fn: (props: P) => R) {
21
+ const rerenderCallback = () => {
22
+ // Re-render when state changes
23
+ if (activeResources.has(fiber)) {
24
+ const lastProps = propsMap.get(fiber);
25
+ const result = renderResourceFiber(fiber, lastProps);
26
+ commitResourceFiber(fiber, result);
27
+ lastRenderResultMap.set(fiber, result);
28
+ }
29
+ };
30
+
31
+ const fiber = createResourceFiber(resource(fn), rerenderCallback);
32
+ return fiber;
33
+ }
34
+
35
+ // ============================================================================
36
+ // Resource Lifecycle Management
37
+ // ============================================================================
38
+
39
+ // Track resources for cleanup
40
+ const activeResources = new Set<ResourceFiber<any, any>>();
41
+ const propsMap = new WeakMap<ResourceFiber<any, any>, any>();
42
+ const lastRenderResultMap = new WeakMap<ResourceFiber<any, any>, any>();
43
+
44
+ /**
45
+ * Renders a test resource fiber with the given props and manages its lifecycle.
46
+ * - Tracks resources for cleanup
47
+ * - Returns the current state after render
48
+ */
49
+ export function renderTest<R, P>(fiber: ResourceFiber<R, P>, props: P): R {
50
+ propsMap.set(fiber, props);
51
+
52
+ // Track resource for cleanup
53
+ activeResources.add(fiber);
54
+
55
+ // Render with new props
56
+ const result = renderResourceFiber(fiber, props);
57
+ commitResourceFiber(fiber, result);
58
+ lastRenderResultMap.set(fiber, result);
59
+
60
+ // Return the committed state from the result
61
+ // This accounts for any re-renders that happened during commit
62
+ return result.state;
63
+ }
64
+
65
+ /**
66
+ * Unmounts a specific resource fiber and removes it from tracking.
67
+ */
68
+ export function unmountResource<R, P>(fiber: ResourceFiber<R, P>) {
69
+ if (activeResources.has(fiber)) {
70
+ unmountResourceFiber(fiber);
71
+ activeResources.delete(fiber);
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Cleans up all resources. Should be called after each test.
77
+ */
78
+ export function cleanupAllResources() {
79
+ activeResources.forEach((fiber) => unmountResourceFiber(fiber));
80
+ activeResources.clear();
81
+ }
82
+
83
+ /**
84
+ * Gets the current committed state of a resource fiber.
85
+ * Returns the state from the last render/commit cycle.
86
+ */
87
+ export function getCommittedState<R, P>(fiber: ResourceFiber<R, P>): R {
88
+ const lastResult = lastRenderResultMap.get(fiber);
89
+ if (!lastResult) {
90
+ throw new Error(
91
+ "No render result found for fiber. Make sure to call renderResource first.",
92
+ );
93
+ }
94
+ return lastResult.state;
95
+ }
96
+
97
+ // ============================================================================
98
+ // Test Helpers
99
+ // ============================================================================
100
+
101
+ /**
102
+ * Helper to subscribe to resource state changes for testing.
103
+ * Tracks call count and latest state value.
104
+ */
105
+ export class TestSubscriber<T> {
106
+ public callCount = 0;
107
+ public lastState: T;
108
+ private fiber: ResourceFiber<any, any>;
109
+
110
+ constructor(fiber: ResourceFiber<any, any>) {
111
+ this.fiber = fiber;
112
+ // Need to render once to get initial state
113
+ const lastProps = propsMap.get(fiber) ?? undefined;
114
+ const initialResult = renderResourceFiber(fiber, lastProps as any);
115
+ commitResourceFiber(fiber, initialResult);
116
+ this.lastState = initialResult.state;
117
+ lastRenderResultMap.set(fiber, initialResult);
118
+ activeResources.add(fiber);
119
+ }
120
+
121
+ cleanup() {
122
+ if (activeResources.has(this.fiber)) {
123
+ unmountResourceFiber(this.fiber);
124
+ activeResources.delete(this.fiber);
125
+ }
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Helper class to manage resource lifecycle in tests with explicit control.
131
+ * Useful when you need fine-grained control over mount/unmount timing.
132
+ */
133
+ export class TestResourceManager<R, P> {
134
+ private isActive = false;
135
+
136
+ constructor(public fiber: ResourceFiber<R, P>) {}
137
+
138
+ renderAndMount(props: P): R {
139
+ if (this.isActive) {
140
+ throw new Error("Resource already active");
141
+ }
142
+
143
+ this.isActive = true;
144
+ activeResources.add(this.fiber);
145
+ propsMap.set(this.fiber, props);
146
+ const result = renderResourceFiber(this.fiber, props);
147
+ commitResourceFiber(this.fiber, result);
148
+ lastRenderResultMap.set(this.fiber, result);
149
+ return result.state;
150
+ }
151
+
152
+ cleanup() {
153
+ if (this.isActive && activeResources.has(this.fiber)) {
154
+ unmountResourceFiber(this.fiber);
155
+ activeResources.delete(this.fiber);
156
+ this.isActive = false;
157
+ }
158
+ }
159
+ }
160
+
161
+ // ============================================================================
162
+ // Async Utilities
163
+ // ============================================================================
164
+
165
+ /**
166
+ * Waits for the next tick of the event loop.
167
+ * Useful for testing async state updates.
168
+ */
169
+ export function waitForNextTick(): Promise<void> {
170
+ return new Promise((resolve) => setTimeout(resolve, 0));
171
+ }
172
+
173
+ /**
174
+ * Waits for a condition to be true with timeout.
175
+ * Useful for testing eventual consistency.
176
+ */
177
+ export async function waitFor(
178
+ condition: () => boolean,
179
+ timeout = 1000,
180
+ interval = 10,
181
+ ): Promise<void> {
182
+ const start = Date.now();
183
+ while (!condition()) {
184
+ if (Date.now() - start > timeout) {
185
+ throw new Error("Timeout waiting for condition");
186
+ }
187
+ await new Promise((resolve) => setTimeout(resolve, interval));
188
+ }
189
+ }
190
+
191
+ // ============================================================================
192
+ // Test Data Factories
193
+ // ============================================================================
194
+
195
+ /**
196
+ * Creates a simple counter resource for testing.
197
+ * Commonly used across multiple test files.
198
+ */
199
+ export function createCounterResource(initialValue = 0) {
200
+ return (props: { value?: number }) => {
201
+ const value = props.value ?? initialValue;
202
+ return { count: value };
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Creates a stateful counter resource for testing.
208
+ * Includes increment/decrement functions.
209
+ */
210
+ export function createStatefulCounterResource() {
211
+ return (props: { initial: number }) => {
212
+ const [count, setCount] = tapState(props.initial);
213
+ return {
214
+ count,
215
+ increment: () => setCount((c: number) => c + 1),
216
+ decrement: () => setCount((c: number) => c - 1),
217
+ };
218
+ };
219
+ }
@@ -0,0 +1,58 @@
1
+ import { ResourceFiber, RenderResult, Resource } from "./types";
2
+ import { commitRender, cleanupAllEffects } from "./commit";
3
+ import { withResourceFiber } from "./execution-context";
4
+ import { callResourceFn } from "./callResourceFn";
5
+
6
+ export function createResourceFiber<R, P>(
7
+ resource: Resource<R, P>,
8
+ scheduleRerender: () => void,
9
+ ): ResourceFiber<R, P> {
10
+ return {
11
+ resource,
12
+ scheduleRerender,
13
+ cells: [],
14
+ currentIndex: 0,
15
+ renderContext: undefined,
16
+ isFirstRender: true,
17
+ isMounted: false,
18
+ isNeverMounted: true,
19
+ };
20
+ }
21
+
22
+ export function unmountResourceFiber<R, P>(fiber: ResourceFiber<R, P>): void {
23
+ // Clean up all effects
24
+ fiber.isMounted = false;
25
+ cleanupAllEffects(fiber);
26
+ }
27
+
28
+ export function renderResourceFiber<R, P>(
29
+ fiber: ResourceFiber<R, P>,
30
+ props: P,
31
+ ): RenderResult {
32
+ const result: RenderResult = {
33
+ commitTasks: [],
34
+ props,
35
+ state: undefined,
36
+ };
37
+
38
+ withResourceFiber(fiber, () => {
39
+ fiber.renderContext = result;
40
+ try {
41
+ result.state = callResourceFn(fiber.resource, props);
42
+ } finally {
43
+ fiber.renderContext = undefined;
44
+ }
45
+ });
46
+
47
+ return result;
48
+ }
49
+
50
+ export function commitResourceFiber<R, P>(
51
+ fiber: ResourceFiber<R, P>,
52
+ result: RenderResult,
53
+ ): void {
54
+ fiber.isMounted = true;
55
+ fiber.isNeverMounted = false;
56
+
57
+ commitRender(result, fiber);
58
+ }
@@ -0,0 +1,21 @@
1
+ import { Resource } from "./types";
2
+
3
+ /**
4
+ * Renders a resource with the given props.
5
+ * @internal This is for internal use only.
6
+ */
7
+ export function callResourceFn<R, P>(resource: Resource<R, P>, props: P): R {
8
+ const fn = (resource as unknown as { [fnSymbol]?: (props: P) => R })[
9
+ fnSymbol
10
+ ];
11
+ if (!fn) {
12
+ throw new Error("ResourceElement.type is not a valid Resource");
13
+ }
14
+ return fn(props);
15
+ }
16
+
17
+ /**
18
+ * Symbol used to store the ResourceFn in the Resource constructor.
19
+ * @internal This is for internal use only.
20
+ */
21
+ export const fnSymbol = Symbol("fnSymbol");
@@ -0,0 +1,73 @@
1
+ import { ResourceFiber, RenderResult } from "./types";
2
+
3
+ export function commitRender<R, P>(
4
+ renderResult: RenderResult,
5
+ fiber: ResourceFiber<R, P>,
6
+ ): void {
7
+ // Process all tasks collected during render
8
+ renderResult.commitTasks.forEach((task) => {
9
+ const cellIndex = task.cellIndex;
10
+ const effectCell = fiber.cells[cellIndex]!;
11
+ if (effectCell.type !== "effect") {
12
+ throw new Error("Cannot find effect cell");
13
+ }
14
+
15
+ // Check if deps changed
16
+ let shouldRunEffect = true;
17
+
18
+ if (effectCell.deps !== undefined && task.deps !== undefined) {
19
+ shouldRunEffect =
20
+ effectCell.deps.length !== task.deps.length ||
21
+ effectCell.deps.some((dep, j) => !Object.is(dep, task.deps![j]));
22
+ }
23
+
24
+ // Run cleanup if effect will re-run
25
+ if (shouldRunEffect) {
26
+ if (effectCell.mounted) {
27
+ if (typeof effectCell.deps !== typeof task.deps) {
28
+ throw new Error(
29
+ "tapEffect called with and without dependencies across re-renders",
30
+ );
31
+ }
32
+
33
+ try {
34
+ if (effectCell.mounted && effectCell.cleanup) {
35
+ effectCell.cleanup();
36
+ }
37
+ } finally {
38
+ effectCell.mounted = false;
39
+ }
40
+ }
41
+ const cleanup = task.effect();
42
+
43
+ if (cleanup !== undefined && typeof cleanup !== "function") {
44
+ throw new Error(
45
+ "An effect function must either return a cleanup function or nothing. " +
46
+ `Received: ${typeof cleanup}`,
47
+ );
48
+ }
49
+
50
+ effectCell.mounted = true;
51
+ effectCell.cleanup = typeof cleanup === "function" ? cleanup : undefined;
52
+ effectCell.deps = task.deps;
53
+ }
54
+ });
55
+ }
56
+
57
+ export function cleanupAllEffects<R, P>(executionContext: ResourceFiber<R, P>) {
58
+ let firstError: unknown | null = null;
59
+ // Run cleanups in reverse order
60
+ for (let i = executionContext.cells.length - 1; i >= 0; i--) {
61
+ const cell = executionContext.cells[i];
62
+ if (cell?.type === "effect" && cell.mounted && cell.cleanup) {
63
+ try {
64
+ cell.cleanup();
65
+ } catch (e) {
66
+ if (firstError == null) firstError = e;
67
+ } finally {
68
+ cell.mounted = false;
69
+ }
70
+ }
71
+ }
72
+ if (firstError != null) throw firstError;
73
+ }
@@ -0,0 +1,28 @@
1
+ const contextValue: unique symbol = Symbol("tap.Context");
2
+ type Context<T> = {
3
+ [contextValue]: T;
4
+ };
5
+
6
+ export const createContext = <T>(defaultValue: T): Context<T> => {
7
+ return {
8
+ [contextValue]: defaultValue,
9
+ };
10
+ };
11
+
12
+ export const withContextProvider = <T, TResult>(
13
+ context: Context<T>,
14
+ value: T,
15
+ fn: () => TResult,
16
+ ) => {
17
+ const previousValue = context[contextValue];
18
+ context[contextValue] = value;
19
+ try {
20
+ return fn();
21
+ } finally {
22
+ context[contextValue] = previousValue;
23
+ }
24
+ };
25
+
26
+ export const tapContext = <T>(context: Context<T>) => {
27
+ return context[contextValue];
28
+ };
@@ -0,0 +1,116 @@
1
+ import { RenderResult, ResourceElement } from "./types";
2
+ import {
3
+ createResourceFiber,
4
+ unmountResourceFiber,
5
+ renderResourceFiber,
6
+ commitResourceFiber,
7
+ } from "./ResourceFiber";
8
+ import { flushSync, UpdateScheduler } from "./scheduler";
9
+ import { tapRef } from "../hooks/tap-ref";
10
+ import { tapState } from "../hooks/tap-state";
11
+ import { tapMemo } from "../hooks/tap-memo";
12
+ import { tapEffect } from "../hooks/tap-effect";
13
+ import { resource } from "./resource";
14
+ import { tapResource } from "../hooks/tap-resource";
15
+
16
+ export namespace createResource {
17
+ export type Unsubscribe = () => void;
18
+
19
+ export interface Handle<R, P> {
20
+ getState(): R;
21
+ subscribe(callback: () => void): Unsubscribe;
22
+ render(element: ResourceElement<R, P>): void;
23
+ unmount(): void;
24
+ }
25
+ }
26
+
27
+ const HandleWrapperResource = resource(
28
+ <R, P>(state: {
29
+ element: ResourceElement<R, P>;
30
+ onRender: (changed: boolean) => boolean;
31
+ onUnmount: () => void;
32
+ }): createResource.Handle<R, P> => {
33
+ const [, setElement] = tapState(state.element);
34
+ const value = tapResource(state.element);
35
+ const subscribers = tapRef(new Set<() => void>()).current;
36
+ const valueRef = tapRef(value);
37
+
38
+ tapEffect(() => {
39
+ if (value !== valueRef.current) {
40
+ valueRef.current = value;
41
+ subscribers.forEach((callback) => callback());
42
+ }
43
+ });
44
+
45
+ const handle = tapMemo(
46
+ () => ({
47
+ getState: () => valueRef.current,
48
+ subscribe: (callback: () => void) => {
49
+ subscribers.add(callback);
50
+ return () => subscribers.delete(callback);
51
+ },
52
+ render: (element: ResourceElement<R, P>) => {
53
+ const changed = state.element !== element;
54
+ state.element = element;
55
+
56
+ if (state.onRender(changed)) {
57
+ setElement(element);
58
+ }
59
+ },
60
+ unmount: state.onUnmount,
61
+ }),
62
+ [],
63
+ );
64
+
65
+ return handle;
66
+ },
67
+ );
68
+
69
+ export const createResource = <R, P>(
70
+ element: ResourceElement<R, P>,
71
+ { mount = true }: { mount?: boolean } = {},
72
+ ): createResource.Handle<R, P> => {
73
+ let isMounted = mount;
74
+ let render: RenderResult;
75
+ const props = {
76
+ element,
77
+ onRender: (changed: boolean) => {
78
+ if (isMounted) return changed;
79
+ isMounted = true;
80
+
81
+ flushSync(() => {
82
+ if (changed) {
83
+ render = renderResourceFiber(fiber, props);
84
+ }
85
+
86
+ if (scheduler.isDirty) return;
87
+ commitResourceFiber(fiber, render!);
88
+ });
89
+
90
+ return false;
91
+ },
92
+ onUnmount: () => {
93
+ if (!isMounted) throw new Error("Resource not mounted");
94
+ isMounted = false;
95
+
96
+ unmountResourceFiber(fiber);
97
+ },
98
+ };
99
+
100
+ const scheduler = new UpdateScheduler(() => {
101
+ render = renderResourceFiber(fiber, props);
102
+
103
+ if (scheduler.isDirty || !isMounted) return;
104
+ commitResourceFiber(fiber, render);
105
+ });
106
+
107
+ const fiber = createResourceFiber(HandleWrapperResource<R, P>, () =>
108
+ scheduler.markDirty(),
109
+ );
110
+
111
+ flushSync(() => {
112
+ scheduler.markDirty();
113
+ });
114
+
115
+ return render!.state;
116
+ };