@assistant-ui/tap 0.3.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +24 -23
  2. package/dist/core/ResourceFiber.d.ts +1 -1
  3. package/dist/core/ResourceFiber.d.ts.map +1 -1
  4. package/dist/core/ResourceFiber.js +15 -8
  5. package/dist/core/ResourceFiber.js.map +1 -1
  6. package/dist/core/commit.d.ts +1 -1
  7. package/dist/core/commit.d.ts.map +1 -1
  8. package/dist/core/commit.js +30 -48
  9. package/dist/core/commit.js.map +1 -1
  10. package/dist/core/context.d.ts +2 -2
  11. package/dist/core/context.d.ts.map +1 -1
  12. package/dist/core/context.js +2 -2
  13. package/dist/core/context.js.map +1 -1
  14. package/dist/core/createResource.d.ts +3 -2
  15. package/dist/core/createResource.d.ts.map +1 -1
  16. package/dist/core/createResource.js +33 -19
  17. package/dist/core/createResource.js.map +1 -1
  18. package/dist/core/env.d.ts +2 -0
  19. package/dist/core/env.d.ts.map +1 -0
  20. package/dist/core/env.js +3 -0
  21. package/dist/core/env.js.map +1 -0
  22. package/dist/core/execution-context.d.ts +1 -0
  23. package/dist/core/execution-context.d.ts.map +1 -1
  24. package/dist/core/execution-context.js +8 -0
  25. package/dist/core/execution-context.js.map +1 -1
  26. package/dist/core/resource.d.ts +3 -3
  27. package/dist/core/resource.d.ts.map +1 -1
  28. package/dist/core/resource.js.map +1 -1
  29. package/dist/core/scheduler.d.ts +1 -1
  30. package/dist/core/scheduler.d.ts.map +1 -1
  31. package/dist/core/scheduler.js +1 -1
  32. package/dist/core/scheduler.js.map +1 -1
  33. package/dist/core/types.d.ts +22 -21
  34. package/dist/core/types.d.ts.map +1 -1
  35. package/dist/core/types.js +1 -1
  36. package/dist/core/types.js.map +1 -1
  37. package/dist/core/withKey.d.ts +3 -0
  38. package/dist/core/withKey.d.ts.map +1 -0
  39. package/dist/core/withKey.js +4 -0
  40. package/dist/core/withKey.js.map +1 -0
  41. package/dist/hooks/tap-callback.d.ts.map +1 -1
  42. package/dist/hooks/tap-callback.js +1 -0
  43. package/dist/hooks/tap-callback.js.map +1 -1
  44. package/dist/hooks/tap-const.d.ts +2 -0
  45. package/dist/hooks/tap-const.d.ts.map +1 -0
  46. package/dist/hooks/tap-const.js +6 -0
  47. package/dist/hooks/tap-const.js.map +1 -0
  48. package/dist/hooks/tap-effect-event.d.ts.map +1 -1
  49. package/dist/hooks/tap-effect-event.js +11 -0
  50. package/dist/hooks/tap-effect-event.js.map +1 -1
  51. package/dist/hooks/tap-effect.d.ts.map +1 -1
  52. package/dist/hooks/tap-effect.js +43 -31
  53. package/dist/hooks/tap-effect.js.map +1 -1
  54. package/dist/hooks/tap-inline-resource.d.ts +2 -2
  55. package/dist/hooks/tap-inline-resource.d.ts.map +1 -1
  56. package/dist/hooks/tap-memo.js +1 -1
  57. package/dist/hooks/tap-memo.js.map +1 -1
  58. package/dist/hooks/tap-resource.d.ts +3 -3
  59. package/dist/hooks/tap-resource.d.ts.map +1 -1
  60. package/dist/hooks/tap-resource.js +17 -9
  61. package/dist/hooks/tap-resource.js.map +1 -1
  62. package/dist/hooks/tap-resources.d.ts +2 -10
  63. package/dist/hooks/tap-resources.d.ts.map +1 -1
  64. package/dist/hooks/tap-resources.js +74 -43
  65. package/dist/hooks/tap-resources.js.map +1 -1
  66. package/dist/hooks/tap-state.d.ts.map +1 -1
  67. package/dist/hooks/tap-state.js +37 -24
  68. package/dist/hooks/tap-state.js.map +1 -1
  69. package/dist/hooks/utils/depsShallowEqual.d.ts.map +1 -0
  70. package/dist/hooks/utils/depsShallowEqual.js.map +1 -0
  71. package/dist/hooks/utils/tapHook.d.ts +6 -0
  72. package/dist/hooks/utils/tapHook.d.ts.map +1 -0
  73. package/dist/hooks/utils/tapHook.js +24 -0
  74. package/dist/hooks/utils/tapHook.js.map +1 -0
  75. package/dist/index.d.ts +5 -3
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +4 -2
  78. package/dist/index.js.map +1 -1
  79. package/dist/react/use-resource.d.ts +2 -2
  80. package/dist/react/use-resource.d.ts.map +1 -1
  81. package/dist/react/use-resource.js +24 -10
  82. package/dist/react/use-resource.js.map +1 -1
  83. package/package.json +8 -1
  84. package/src/__tests__/basic/resourceHandle.test.ts +4 -4
  85. package/src/__tests__/basic/tapEffect.basic.test.ts +3 -2
  86. package/src/__tests__/basic/tapResources.basic.test.ts +84 -64
  87. package/src/__tests__/basic/tapState.basic.test.ts +8 -8
  88. package/src/__tests__/errors/errors.effect-errors.test.ts +8 -3
  89. package/src/__tests__/lifecycle/lifecycle.dependencies.test.ts +3 -2
  90. package/src/__tests__/lifecycle/lifecycle.mount-unmount.test.ts +2 -2
  91. package/src/__tests__/react/concurrent-mode.test.tsx +243 -0
  92. package/src/__tests__/strictmode/react-strictmode-behavior.test.tsx +709 -0
  93. package/src/__tests__/strictmode/react-strictmode-rerender-sources.test.tsx +392 -0
  94. package/src/__tests__/strictmode/strictmode.test.ts +270 -0
  95. package/src/__tests__/strictmode/tap-strictmode-rerender-sources.test.ts +723 -0
  96. package/src/__tests__/test-utils.ts +8 -6
  97. package/src/core/ResourceFiber.ts +21 -11
  98. package/src/core/commit.ts +29 -58
  99. package/src/core/context.ts +2 -2
  100. package/src/core/createResource.ts +46 -22
  101. package/src/core/env.ts +3 -0
  102. package/src/core/execution-context.ts +9 -0
  103. package/src/core/resource.ts +6 -3
  104. package/src/core/scheduler.ts +1 -1
  105. package/src/core/types.ts +25 -26
  106. package/src/core/withKey.ts +8 -0
  107. package/src/hooks/tap-callback.ts +1 -0
  108. package/src/hooks/tap-const.ts +6 -0
  109. package/src/hooks/tap-effect-event.ts +15 -0
  110. package/src/hooks/tap-effect.ts +48 -38
  111. package/src/hooks/tap-inline-resource.ts +2 -2
  112. package/src/hooks/tap-memo.ts +1 -1
  113. package/src/hooks/tap-resource.ts +24 -20
  114. package/src/hooks/tap-resources.ts +86 -63
  115. package/src/hooks/tap-state.ts +49 -26
  116. package/src/hooks/utils/tapHook.ts +35 -0
  117. package/src/index.ts +8 -3
  118. package/src/react/use-resource.ts +27 -16
  119. package/dist/hooks/depsShallowEqual.d.ts.map +0 -1
  120. package/dist/hooks/depsShallowEqual.js.map +0 -1
  121. /package/dist/hooks/{depsShallowEqual.d.ts → utils/depsShallowEqual.d.ts} +0 -0
  122. /package/dist/hooks/{depsShallowEqual.js → utils/depsShallowEqual.js} +0 -0
  123. /package/src/hooks/{depsShallowEqual.ts → utils/depsShallowEqual.ts} +0 -0
@@ -18,7 +18,9 @@ import { tapState } from "../hooks/tap-state";
18
18
  * Sets up a rerender callback that automatically re-renders when state changes.
19
19
  */
20
20
  export function createTestResource<R, P>(fn: (props: P) => R) {
21
- const rerenderCallback = () => {
21
+ const rerenderCallback = (callback: () => boolean) => {
22
+ if (!callback()) return;
23
+
22
24
  // Re-render when state changes
23
25
  if (activeResources.has(fiber)) {
24
26
  const lastProps = propsMap.get(fiber);
@@ -59,7 +61,7 @@ export function renderTest<R, P>(fiber: ResourceFiber<R, P>, props: P): R {
59
61
 
60
62
  // Return the committed state from the result
61
63
  // This accounts for any re-renders that happened during commit
62
- return result.state;
64
+ return result.output;
63
65
  }
64
66
 
65
67
  /**
@@ -84,14 +86,14 @@ export function cleanupAllResources() {
84
86
  * Gets the current committed state of a resource fiber.
85
87
  * Returns the state from the last render/commit cycle.
86
88
  */
87
- export function getCommittedState<R, P>(fiber: ResourceFiber<R, P>): R {
89
+ export function getCommittedOutput<R, P>(fiber: ResourceFiber<R, P>): R {
88
90
  const lastResult = lastRenderResultMap.get(fiber);
89
91
  if (!lastResult) {
90
92
  throw new Error(
91
93
  "No render result found for fiber. Make sure to call renderResource first.",
92
94
  );
93
95
  }
94
- return lastResult.state;
96
+ return lastResult.output;
95
97
  }
96
98
 
97
99
  // ============================================================================
@@ -113,7 +115,7 @@ export class TestSubscriber<T> {
113
115
  const lastProps = propsMap.get(fiber) ?? undefined;
114
116
  const initialResult = renderResourceFiber(fiber, lastProps as any);
115
117
  commitResourceFiber(fiber, initialResult);
116
- this.lastState = initialResult.state;
118
+ this.lastState = initialResult.output;
117
119
  lastRenderResultMap.set(fiber, initialResult);
118
120
  activeResources.add(fiber);
119
121
  }
@@ -146,7 +148,7 @@ export class TestResourceManager<R, P> {
146
148
  const result = renderResourceFiber(this.fiber, props);
147
149
  commitResourceFiber(this.fiber, result);
148
150
  lastRenderResultMap.set(this.fiber, result);
149
- return result.state;
151
+ return result.output;
150
152
  }
151
153
 
152
154
  cleanup() {
@@ -1,15 +1,18 @@
1
1
  import { ResourceFiber, RenderResult, Resource } from "./types";
2
2
  import { commitRender, cleanupAllEffects } from "./commit";
3
- import { withResourceFiber } from "./execution-context";
3
+ import { getDevStrictMode, withResourceFiber } from "./execution-context";
4
4
  import { callResourceFn } from "./callResourceFn";
5
+ import { isDevelopment } from "./env";
5
6
 
6
7
  export function createResourceFiber<R, P>(
7
- resource: Resource<R, P>,
8
- scheduleRerender: () => void,
8
+ type: Resource<R, P>,
9
+ dispatchUpdate: (callback: () => boolean) => void,
10
+ strictMode: "root" | "child" | null = getDevStrictMode(false),
9
11
  ): ResourceFiber<R, P> {
10
12
  return {
11
- resource,
12
- scheduleRerender,
13
+ type,
14
+ dispatchUpdate,
15
+ devStrictMode: strictMode,
13
16
  cells: [],
14
17
  currentIndex: 0,
15
18
  renderContext: undefined,
@@ -20,7 +23,9 @@ export function createResourceFiber<R, P>(
20
23
  }
21
24
 
22
25
  export function unmountResourceFiber<R, P>(fiber: ResourceFiber<R, P>): void {
23
- // Clean up all effects
26
+ if (!fiber.isMounted)
27
+ throw new Error("Tried to unmount a fiber that is already unmounted");
28
+
24
29
  fiber.isMounted = false;
25
30
  cleanupAllEffects(fiber);
26
31
  }
@@ -29,16 +34,16 @@ export function renderResourceFiber<R, P>(
29
34
  fiber: ResourceFiber<R, P>,
30
35
  props: P,
31
36
  ): RenderResult {
32
- const result: RenderResult = {
37
+ const result = {
33
38
  commitTasks: [],
34
39
  props,
35
- state: undefined,
40
+ output: undefined as R | undefined,
36
41
  };
37
42
 
38
43
  withResourceFiber(fiber, () => {
39
44
  fiber.renderContext = result;
40
45
  try {
41
- result.state = callResourceFn(fiber.resource, props);
46
+ result.output = callResourceFn(fiber.type, props);
42
47
  } finally {
43
48
  fiber.renderContext = undefined;
44
49
  }
@@ -52,7 +57,12 @@ export function commitResourceFiber<R, P>(
52
57
  result: RenderResult,
53
58
  ): void {
54
59
  fiber.isMounted = true;
55
- fiber.isNeverMounted = false;
56
60
 
57
- commitRender(result, fiber);
61
+ if (isDevelopment && fiber.isNeverMounted && fiber.devStrictMode === "root") {
62
+ commitRender(result);
63
+ cleanupAllEffects(fiber);
64
+ }
65
+
66
+ fiber.isNeverMounted = false;
67
+ commitRender(result);
58
68
  }
@@ -1,73 +1,44 @@
1
1
  import { ResourceFiber, RenderResult } from "./types";
2
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");
3
+ export function commitRender(renderResult: RenderResult): void {
4
+ const errors: unknown[] = [];
5
+
6
+ for (const task of renderResult.commitTasks) {
7
+ try {
8
+ task();
9
+ } catch (error) {
10
+ errors.push(error);
13
11
  }
12
+ }
14
13
 
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;
14
+ if (errors.length > 0) {
15
+ if (errors.length === 1) {
16
+ throw errors[0];
17
+ } else {
18
+ throw new AggregateError(errors, "Errors during commit");
53
19
  }
54
- });
20
+ }
55
21
  }
56
22
 
57
23
  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) {
24
+ const errors: unknown[] = [];
25
+ for (const cell of executionContext.cells) {
26
+ if (cell?.type === "effect" && cell.cleanup) {
63
27
  try {
64
- cell.cleanup();
28
+ cell.cleanup?.();
65
29
  } catch (e) {
66
- if (firstError == null) firstError = e;
30
+ errors.push(e);
67
31
  } finally {
68
- cell.mounted = false;
32
+ cell.cleanup = undefined;
33
+ cell.deps = null; // Reset deps so effect runs again on next mount
69
34
  }
70
35
  }
71
36
  }
72
- if (firstError != null) throw firstError;
37
+ if (errors.length > 0) {
38
+ if (errors.length === 1) {
39
+ throw errors[0];
40
+ } else {
41
+ throw new AggregateError(errors, "Errors during cleanup");
42
+ }
43
+ }
73
44
  }
@@ -3,7 +3,7 @@ type Context<T> = {
3
3
  [contextValue]: T;
4
4
  };
5
5
 
6
- export const createContext = <T>(defaultValue: T): Context<T> => {
6
+ export const createResourceContext = <T>(defaultValue: T): Context<T> => {
7
7
  return {
8
8
  [contextValue]: defaultValue,
9
9
  };
@@ -23,6 +23,6 @@ export const withContextProvider = <T, TResult>(
23
23
  }
24
24
  };
25
25
 
26
- export const tapContext = <T>(context: Context<T>) => {
26
+ export const tap = <T>(context: Context<T>) => {
27
27
  return context[contextValue];
28
28
  };
@@ -5,19 +5,22 @@ import {
5
5
  renderResourceFiber,
6
6
  commitResourceFiber,
7
7
  } from "./ResourceFiber";
8
- import { flushSync, UpdateScheduler } from "./scheduler";
8
+ import { flushResourcesSync, UpdateScheduler } from "./scheduler";
9
9
  import { tapRef } from "../hooks/tap-ref";
10
10
  import { tapState } from "../hooks/tap-state";
11
11
  import { tapMemo } from "../hooks/tap-memo";
12
12
  import { tapEffect } from "../hooks/tap-effect";
13
13
  import { resource } from "./resource";
14
14
  import { tapResource } from "../hooks/tap-resource";
15
+ import { tapConst } from "../hooks/tap-const";
16
+ import { getDevStrictMode } from "./execution-context";
17
+ import { isDevelopment } from "./env";
15
18
 
16
19
  export namespace createResource {
17
20
  export type Unsubscribe = () => void;
18
21
 
19
22
  export interface Handle<R, P> {
20
- getState(): R;
23
+ getValue(): R;
21
24
  subscribe(callback: () => void): Unsubscribe;
22
25
  render(element: ResourceElement<R, P>): void;
23
26
  unmount(): void;
@@ -26,40 +29,44 @@ export namespace createResource {
26
29
 
27
30
  const HandleWrapperResource = resource(
28
31
  <R, P>(state: {
29
- element: ResourceElement<R, P>;
32
+ elementRef: {
33
+ current: ResourceElement<R, P>;
34
+ };
30
35
  onRender: (changed: boolean) => boolean;
31
36
  onUnmount: () => void;
32
37
  }): 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);
38
+ const [, setElement] = tapState(state.elementRef.current);
39
+ const output = tapResource(state.elementRef.current);
40
+
41
+ const subscribers = tapConst(() => new Set<() => void>(), []);
42
+ const valueRef = tapRef(output);
37
43
 
38
44
  tapEffect(() => {
39
- if (value !== valueRef.current) {
40
- valueRef.current = value;
45
+ if (output !== valueRef.current) {
46
+ valueRef.current = output;
41
47
  subscribers.forEach((callback) => callback());
42
48
  }
43
49
  });
44
50
 
45
51
  const handle = tapMemo(
46
52
  () => ({
47
- getState: () => valueRef.current,
53
+ getValue: () => valueRef.current,
48
54
  subscribe: (callback: () => void) => {
49
55
  subscribers.add(callback);
50
56
  return () => subscribers.delete(callback);
51
57
  },
52
- render: (element: ResourceElement<R, P>) => {
53
- const changed = state.element !== element;
54
- state.element = element;
58
+
59
+ render: (el: ResourceElement<R, P>) => {
60
+ const changed = state.elementRef.current !== el;
61
+ state.elementRef.current = el;
55
62
 
56
63
  if (state.onRender(changed)) {
57
- setElement(element);
64
+ setElement(el);
58
65
  }
59
66
  },
60
67
  unmount: state.onUnmount,
61
68
  }),
62
- [],
69
+ [state],
63
70
  );
64
71
 
65
72
  return handle;
@@ -68,18 +75,26 @@ const HandleWrapperResource = resource(
68
75
 
69
76
  export const createResource = <R, P>(
70
77
  element: ResourceElement<R, P>,
71
- { mount = true }: { mount?: boolean } = {},
78
+ {
79
+ mount = true,
80
+ devStrictMode = false,
81
+ }: { mount?: boolean; devStrictMode?: boolean } = {},
72
82
  ): createResource.Handle<R, P> => {
73
83
  let isMounted = mount;
74
84
  let render: RenderResult;
75
85
  const props = {
76
- element,
86
+ elementRef: { current: element },
77
87
  onRender: (changed: boolean) => {
78
88
  if (isMounted) return changed;
79
89
  isMounted = true;
80
90
 
81
- flushSync(() => {
91
+ flushResourcesSync(() => {
82
92
  if (changed) {
93
+ // In strict mode, render twice to detect side effects
94
+ if (isDevelopment && fiber.devStrictMode === "root") {
95
+ void renderResourceFiber(fiber, props);
96
+ }
97
+
83
98
  render = renderResourceFiber(fiber, props);
84
99
  }
85
100
 
@@ -98,19 +113,28 @@ export const createResource = <R, P>(
98
113
  };
99
114
 
100
115
  const scheduler = new UpdateScheduler(() => {
116
+ // In strict mode, render twice to detect side effects
117
+ if (isDevelopment && fiber.devStrictMode === "root") {
118
+ void renderResourceFiber(fiber, props);
119
+ }
120
+
101
121
  render = renderResourceFiber(fiber, props);
102
122
 
103
123
  if (scheduler.isDirty || !isMounted) return;
104
124
  commitResourceFiber(fiber, render);
105
125
  });
106
126
 
107
- const fiber = createResourceFiber(HandleWrapperResource<R, P>, () =>
108
- scheduler.markDirty(),
127
+ const fiber = createResourceFiber(
128
+ HandleWrapperResource<R, P>,
129
+ (callback) => {
130
+ if (callback()) scheduler.markDirty();
131
+ },
132
+ getDevStrictMode(devStrictMode),
109
133
  );
110
134
 
111
- flushSync(() => {
135
+ flushResourcesSync(() => {
112
136
  scheduler.markDirty();
113
137
  });
114
138
 
115
- return render!.state;
139
+ return render!.output;
116
140
  };
@@ -0,0 +1,3 @@
1
+ export const isDevelopment =
2
+ process.env["NODE_ENV"] === "development" ||
3
+ process.env["NODE_ENV"] === "test";
@@ -1,3 +1,4 @@
1
+ import { isDevelopment } from "./env";
1
2
  import { ResourceFiber } from "./types";
2
3
 
3
4
  let currentResourceFiber: ResourceFiber<any, any> | null = null;
@@ -32,3 +33,11 @@ export function getCurrentResourceFiber(): ResourceFiber<unknown, unknown> {
32
33
  }
33
34
  return currentResourceFiber;
34
35
  }
36
+
37
+ export function getDevStrictMode(enable: boolean) {
38
+ if (!isDevelopment) return null;
39
+ if (currentResourceFiber?.devStrictMode)
40
+ return currentResourceFiber.isFirstRender ? "child" : "root";
41
+
42
+ return enable ? "root" : null;
43
+ }
@@ -1,7 +1,10 @@
1
- import { Resource, ResourceElement } from "./types";
1
+ import { ResourceElement } from "./types";
2
2
  import { fnSymbol } from "./callResourceFn";
3
- export function resource<R>(fn: () => R): Resource<R, undefined>;
4
- export function resource<R, P>(fn: (props: P) => R): Resource<R, P>;
3
+
4
+ export function resource<R>(fn: () => R): () => ResourceElement<R, undefined>;
5
+ export function resource<R, P>(
6
+ fn: (props: P) => R,
7
+ ): (props: P) => ResourceElement<R, P>;
5
8
  export function resource<R, P = undefined>(fn: (props: P) => R) {
6
9
  const type = (props?: P) => {
7
10
  return {
@@ -77,7 +77,7 @@ const flushScheduled = () => {
77
77
  }
78
78
  };
79
79
 
80
- export const flushSync = <T>(callback: () => T): T => {
80
+ export const flushResourcesSync = <T>(callback: () => T): T => {
81
81
  const prev = flushState;
82
82
  flushState = {
83
83
  schedulers: new Set([]),
package/src/core/types.ts CHANGED
@@ -1,52 +1,51 @@
1
1
  import type { tapEffect } from "../hooks/tap-effect";
2
2
  import type { tapState } from "../hooks/tap-state";
3
- import { fnSymbol } from "./callResourceFn";
3
+ import type { fnSymbol } from "./callResourceFn";
4
4
 
5
5
  export type ResourceElement<R, P = any> = {
6
- type: Resource<R, P> & { [fnSymbol]: (props: P) => R };
7
- props: P;
6
+ readonly type: Resource<R, P> & { [fnSymbol]: (props: P) => R };
7
+ readonly props: P;
8
+ readonly key?: string | number;
8
9
  };
9
10
 
10
- type ResourceArgs<P> = undefined extends P ? [props?: P] : [props: P];
11
- export type Resource<R, P> = (
12
- ...args: ResourceArgs<P>
13
- ) => ResourceElement<R, P>;
11
+ export type Resource<R, P> = (props: P) => ResourceElement<R, P>;
12
+ export type ContravariantResource<R, P> = (props: P) => ResourceElement<R>;
14
13
 
15
- export type ContravariantResource<R, P> = (
16
- ...args: ResourceArgs<P>
17
- ) => ResourceElement<R>;
18
-
19
- export type ExtractResourceOutput<T> =
20
- T extends ResourceElement<infer R, any> ? R : never;
14
+ export type ExtractResourceReturnType<T> =
15
+ T extends ResourceElement<infer R, any>
16
+ ? R
17
+ : T extends Resource<infer R, any>
18
+ ? R
19
+ : never;
21
20
 
22
21
  export type Cell =
23
22
  | {
24
- type: "state";
23
+ readonly type: "state";
25
24
  value: any;
26
25
  set: (updater: tapState.StateUpdater<any>) => void;
27
26
  }
28
27
  | {
29
- type: "effect";
30
- mounted: boolean;
31
- cleanup?: tapEffect.Destructor | undefined;
32
- deps?: readonly unknown[] | undefined;
28
+ readonly type: "effect";
29
+ cleanup: tapEffect.Destructor | void;
30
+ deps: readonly unknown[] | null | undefined;
33
31
  };
34
32
 
35
33
  export interface EffectTask {
36
- effect: tapEffect.EffectCallback;
37
- deps?: readonly unknown[] | undefined;
38
- cellIndex: number;
34
+ readonly effect: tapEffect.EffectCallback;
35
+ readonly deps: readonly unknown[] | undefined;
36
+ readonly cell: Cell & { type: "effect" };
39
37
  }
40
38
 
41
39
  export interface RenderResult {
42
- state: any;
43
- props: any;
44
- commitTasks: EffectTask[];
40
+ readonly output: any;
41
+ readonly props: any;
42
+ readonly commitTasks: (() => void)[];
45
43
  }
46
44
 
47
45
  export interface ResourceFiber<R, P> {
48
- readonly scheduleRerender: () => void;
49
- readonly resource: Resource<R, P>;
46
+ readonly dispatchUpdate: (callback: () => boolean) => void;
47
+ readonly type: Resource<R, P>;
48
+ readonly devStrictMode: "root" | "child" | null;
50
49
 
51
50
  cells: Cell[];
52
51
  currentIndex: number;
@@ -0,0 +1,8 @@
1
+ import { ResourceElement } from "./types";
2
+
3
+ export function withKey<E extends ResourceElement<any, any>>(
4
+ key: string | number,
5
+ element: E,
6
+ ): E {
7
+ return { ...element, key };
8
+ }
@@ -4,5 +4,6 @@ export const tapCallback = <T extends (...args: any[]) => any>(
4
4
  fn: T,
5
5
  deps: readonly unknown[],
6
6
  ): T => {
7
+ // biome-ignore lint/correctness/useExhaustiveDependencies: user provided deps instead of callback identity
7
8
  return tapMemo(() => fn, deps);
8
9
  };
@@ -0,0 +1,6 @@
1
+ import { tapState } from "./tap-state";
2
+
3
+ export function tapConst<T>(getValue: () => T, _deps: readonly never[]): T {
4
+ const [state] = tapState(getValue);
5
+ return state;
6
+ }
@@ -1,5 +1,8 @@
1
1
  import { tapRef } from "./tap-ref";
2
2
  import { tapEffect } from "./tap-effect";
3
+ import { isDevelopment } from "../core/env";
4
+ import { tapCallback } from "./tap-callback";
5
+ import { getCurrentResourceFiber } from "../core/execution-context";
3
6
 
4
7
  /**
5
8
  * Creates a stable function reference that always calls the most recent version of the callback.
@@ -25,5 +28,17 @@ export function tapEffectEvent<T extends (...args: any[]) => any>(
25
28
  callbackRef.current = callback;
26
29
  });
27
30
 
31
+ if (isDevelopment) {
32
+ const fiber = getCurrentResourceFiber();
33
+ return tapCallback(
34
+ ((...args: Parameters<T>) => {
35
+ if (fiber.renderContext)
36
+ throw new Error("tapEffectEvent cannot be called during render");
37
+ return callbackRef.current(...args);
38
+ }) as T,
39
+ [],
40
+ );
41
+ }
42
+
28
43
  return callbackRef.current;
29
44
  }
@@ -1,35 +1,12 @@
1
- import { getCurrentResourceFiber } from "../core/execution-context";
2
1
  import { Cell } from "../core/types";
2
+ import { depsShallowEqual } from "./utils/depsShallowEqual";
3
+ import { tapHook, registerRenderMountTask } from "./utils/tapHook";
3
4
 
4
- function getEffectCell(): number {
5
- const fiber = getCurrentResourceFiber();
6
- const index = fiber.currentIndex++;
7
-
8
- // Check if we're trying to use more hooks than in previous renders
9
- if (!fiber.isFirstRender && index >= fiber.cells.length) {
10
- throw new Error(
11
- "Rendered more hooks than during the previous render. " +
12
- "Hooks must be called in the exact same order in every render.",
13
- );
14
- }
15
-
16
- if (!fiber.cells[index]) {
17
- // Create the effect cell
18
- const cell: Cell & { type: "effect" } = {
19
- type: "effect",
20
- mounted: false,
21
- };
22
-
23
- fiber.cells[index] = cell;
24
- }
25
-
26
- const cell = fiber.cells[index];
27
- if (cell.type !== "effect") {
28
- throw new Error("Hook order changed between renders");
29
- }
30
-
31
- return index;
32
- }
5
+ const newEffect = (): Cell & { type: "effect" } => ({
6
+ type: "effect",
7
+ cleanup: undefined,
8
+ deps: null, // null means the effect has never been run
9
+ });
33
10
 
34
11
  export namespace tapEffect {
35
12
  export type Destructor = () => void;
@@ -45,15 +22,48 @@ export function tapEffect(
45
22
  effect: tapEffect.EffectCallback,
46
23
  deps?: readonly unknown[],
47
24
  ): void {
48
- const fiber = getCurrentResourceFiber();
25
+ const cell = tapHook("effect", newEffect);
26
+
27
+ if (deps && cell.deps && depsShallowEqual(cell.deps, deps)) return;
28
+ if (cell.deps !== null && !!deps !== !!cell.deps)
29
+ throw new Error(
30
+ "tapEffect called with and without dependencies across re-renders",
31
+ );
32
+
33
+ registerRenderMountTask(() => {
34
+ const errors: unknown[] = [];
35
+
36
+ try {
37
+ cell.cleanup?.();
38
+ } catch (error) {
39
+ errors.push(error);
40
+ } finally {
41
+ cell.cleanup = undefined;
42
+ }
43
+
44
+ try {
45
+ const cleanup = effect();
46
+
47
+ if (cleanup !== undefined && typeof cleanup !== "function") {
48
+ throw new Error(
49
+ "An effect function must either return a cleanup function or nothing. " +
50
+ `Received: ${typeof cleanup}`,
51
+ );
52
+ }
53
+
54
+ cell.cleanup = cleanup;
55
+ } catch (error) {
56
+ errors.push(error);
57
+ }
49
58
 
50
- // Reserve a spot for the effect cell and get its index
51
- const cellIndex = getEffectCell();
59
+ cell.deps = deps;
52
60
 
53
- // Add task to render context for execution in commit phase
54
- fiber.renderContext!.commitTasks.push({
55
- effect,
56
- deps,
57
- cellIndex,
61
+ if (errors.length > 0) {
62
+ if (errors.length === 1) {
63
+ throw errors[0];
64
+ } else {
65
+ throw new AggregateError(errors, "Errors during commit");
66
+ }
67
+ }
58
68
  });
59
69
  }