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