@assistant-ui/tap 0.3.6 → 0.4.2

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 (124) 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 +40 -50
  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 +48 -22
  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 +4 -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 +4 -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 +46 -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.d.ts.map +1 -1
  57. package/dist/hooks/tap-memo.js +9 -1
  58. package/dist/hooks/tap-memo.js.map +1 -1
  59. package/dist/hooks/tap-resource.d.ts +3 -3
  60. package/dist/hooks/tap-resource.d.ts.map +1 -1
  61. package/dist/hooks/tap-resource.js +17 -9
  62. package/dist/hooks/tap-resource.js.map +1 -1
  63. package/dist/hooks/tap-resources.d.ts +2 -10
  64. package/dist/hooks/tap-resources.d.ts.map +1 -1
  65. package/dist/hooks/tap-resources.js +74 -43
  66. package/dist/hooks/tap-resources.js.map +1 -1
  67. package/dist/hooks/tap-state.d.ts.map +1 -1
  68. package/dist/hooks/tap-state.js +37 -24
  69. package/dist/hooks/tap-state.js.map +1 -1
  70. package/dist/hooks/utils/depsShallowEqual.d.ts.map +1 -0
  71. package/dist/hooks/utils/depsShallowEqual.js.map +1 -0
  72. package/dist/hooks/utils/tapHook.d.ts +6 -0
  73. package/dist/hooks/utils/tapHook.d.ts.map +1 -0
  74. package/dist/hooks/utils/tapHook.js +24 -0
  75. package/dist/hooks/utils/tapHook.js.map +1 -0
  76. package/dist/index.d.ts +5 -3
  77. package/dist/index.d.ts.map +1 -1
  78. package/dist/index.js +4 -2
  79. package/dist/index.js.map +1 -1
  80. package/dist/react/use-resource.d.ts +2 -2
  81. package/dist/react/use-resource.d.ts.map +1 -1
  82. package/dist/react/use-resource.js +24 -10
  83. package/dist/react/use-resource.js.map +1 -1
  84. package/package.json +10 -3
  85. package/src/__tests__/basic/resourceHandle.test.ts +4 -4
  86. package/src/__tests__/basic/tapEffect.basic.test.ts +3 -2
  87. package/src/__tests__/basic/tapResources.basic.test.ts +84 -64
  88. package/src/__tests__/basic/tapState.basic.test.ts +8 -8
  89. package/src/__tests__/errors/errors.effect-errors.test.ts +8 -3
  90. package/src/__tests__/lifecycle/lifecycle.dependencies.test.ts +3 -2
  91. package/src/__tests__/lifecycle/lifecycle.mount-unmount.test.ts +2 -2
  92. package/src/__tests__/react/concurrent-mode.test.tsx +243 -0
  93. package/src/__tests__/strictmode/react-strictmode-behavior.test.tsx +709 -0
  94. package/src/__tests__/strictmode/react-strictmode-rerender-sources.test.tsx +392 -0
  95. package/src/__tests__/strictmode/strictmode.test.ts +274 -0
  96. package/src/__tests__/strictmode/tap-strictmode-rerender-sources.test.ts +723 -0
  97. package/src/__tests__/test-utils.ts +8 -6
  98. package/src/core/ResourceFiber.ts +21 -11
  99. package/src/core/commit.ts +37 -57
  100. package/src/core/context.ts +2 -2
  101. package/src/core/createResource.ts +64 -25
  102. package/src/core/env.ts +3 -0
  103. package/src/core/execution-context.ts +9 -0
  104. package/src/core/resource.ts +9 -3
  105. package/src/core/scheduler.ts +4 -1
  106. package/src/core/types.ts +25 -26
  107. package/src/core/withKey.ts +8 -0
  108. package/src/hooks/tap-callback.ts +1 -0
  109. package/src/hooks/tap-const.ts +6 -0
  110. package/src/hooks/tap-effect-event.ts +15 -0
  111. package/src/hooks/tap-effect.ts +51 -38
  112. package/src/hooks/tap-inline-resource.ts +2 -2
  113. package/src/hooks/tap-memo.ts +10 -1
  114. package/src/hooks/tap-resource.ts +24 -20
  115. package/src/hooks/tap-resources.ts +86 -63
  116. package/src/hooks/tap-state.ts +49 -26
  117. package/src/hooks/utils/tapHook.ts +35 -0
  118. package/src/index.ts +8 -3
  119. package/src/react/use-resource.ts +27 -16
  120. package/dist/hooks/depsShallowEqual.d.ts.map +0 -1
  121. package/dist/hooks/depsShallowEqual.js.map +0 -1
  122. /package/dist/hooks/{depsShallowEqual.d.ts → utils/depsShallowEqual.d.ts} +0 -0
  123. /package/dist/hooks/{depsShallowEqual.js → utils/depsShallowEqual.js} +0 -0
  124. /package/src/hooks/{depsShallowEqual.ts → utils/depsShallowEqual.ts} +0 -0
@@ -2,6 +2,7 @@ import { describe, it, expect, afterEach } from "vitest";
2
2
  import { tapResources } from "../../hooks/tap-resources";
3
3
  import { tapState } from "../../hooks/tap-state";
4
4
  import { resource } from "../../core/resource";
5
+ import { withKey } from "../../core/withKey";
5
6
  import {
6
7
  createTestResource,
7
8
  renderTest,
@@ -59,8 +60,11 @@ describe("tapResources - Basic Functionality", () => {
59
60
  it("should render multiple resources with keys", () => {
60
61
  const testFiber = createTestResource(() => {
61
62
  const results = tapResources(
62
- { a: 10, b: 20, c: 30 },
63
- (value) => SimpleCounter({ value }),
63
+ () => [
64
+ withKey("a", SimpleCounter({ value: 10 })),
65
+ withKey("b", SimpleCounter({ value: 20 })),
66
+ withKey("c", SimpleCounter({ value: 30 })),
67
+ ],
64
68
  [],
65
69
  );
66
70
 
@@ -68,11 +72,7 @@ describe("tapResources - Basic Functionality", () => {
68
72
  });
69
73
 
70
74
  const result = renderTest(testFiber, undefined);
71
- expect(result).toEqual({
72
- a: { count: 10 },
73
- b: { count: 20 },
74
- c: { count: 30 },
75
- });
75
+ expect(result).toEqual([{ count: 10 }, { count: 20 }, { count: 30 }]);
76
76
  });
77
77
 
78
78
  it("should work with resource constructor syntax", () => {
@@ -82,15 +82,17 @@ describe("tapResources - Basic Functionality", () => {
82
82
  });
83
83
 
84
84
  const testFiber = createTestResource(() => {
85
- const items = {
86
- first: { value: 5 },
87
- second: { value: 10 },
88
- third: { value: 15 },
89
- };
85
+ const items = [
86
+ { key: "first", value: 5 },
87
+ { key: "second", value: 10 },
88
+ { key: "third", value: 15 },
89
+ ];
90
90
 
91
91
  const results = tapResources(
92
- items,
93
- (item) => Counter({ value: item.value }),
92
+ () =>
93
+ items.map((item) =>
94
+ withKey(item.key, Counter({ value: item.value })),
95
+ ),
94
96
  [],
95
97
  );
96
98
 
@@ -98,56 +100,66 @@ describe("tapResources - Basic Functionality", () => {
98
100
  });
99
101
 
100
102
  const result = renderTest(testFiber, undefined);
101
- expect(result).toEqual({
102
- first: { count: 5, double: 10 },
103
- second: { count: 10, double: 20 },
104
- third: { count: 15, double: 30 },
105
- });
103
+ expect(result).toEqual([
104
+ { count: 5, double: 10 },
105
+ { count: 10, double: 20 },
106
+ { count: 15, double: 30 },
107
+ ]);
106
108
  });
107
109
  });
108
110
 
109
111
  describe("Instance Preservation", () => {
110
112
  it("should maintain resource instances when keys remain the same", () => {
111
113
  const testFiber = createTestResource(
112
- (props: { items: Record<string, { value: number; id: string }> }) => {
113
- return tapResources(
114
- props.items,
115
- (item) => TrackingCounter({ value: item.value, id: item.id }),
116
-
117
- [],
118
- );
114
+ (props: {
115
+ items: Array<{ key: string; value: number; id: string }>;
116
+ }) => {
117
+ return tapResources(() => {
118
+ return props.items.map((item) =>
119
+ withKey(
120
+ item.key,
121
+ TrackingCounter({ value: item.value, id: item.id }),
122
+ ),
123
+ );
124
+ }, [props.items]);
119
125
  },
120
126
  );
121
127
 
122
128
  // Initial render
123
129
  const result1 = renderTest(testFiber, {
124
- items: { a: { value: 1, id: "a" }, b: { value: 2, id: "b" } },
130
+ items: [
131
+ { key: "a", value: 1, id: "a" },
132
+ { key: "b", value: 2, id: "b" },
133
+ ],
125
134
  });
126
135
 
127
136
  // Verify initial state
128
- expect(result1.a).toMatchObject({
137
+ expect(result1[0]).toMatchObject({
129
138
  id: "a",
130
139
  value: 1,
131
140
  renderCount: 1,
132
141
  });
133
- expect(result1.b).toMatchObject({
142
+ expect(result1[1]).toMatchObject({
134
143
  id: "b",
135
144
  value: 2,
136
145
  renderCount: 1,
137
146
  });
138
147
 
139
- // Re-render with same keys but different values
148
+ // Re-render with same keys but different order and values
140
149
  const result2 = renderTest(testFiber, {
141
- items: { b: { value: 20, id: "b" }, a: { value: 10, id: "a" } },
150
+ items: [
151
+ { key: "b", value: 20, id: "b" },
152
+ { key: "a", value: 10, id: "a" },
153
+ ],
142
154
  });
143
155
 
144
- // Verify instances are preserved
145
- expect(result2.b).toMatchObject({
156
+ // Verify instances are preserved (renderCount should be 2)
157
+ expect(result2[0]).toMatchObject({
146
158
  id: "b",
147
159
  value: 20,
148
160
  renderCount: 2,
149
161
  });
150
- expect(result2.a).toMatchObject({
162
+ expect(result2[1]).toMatchObject({
151
163
  id: "a",
152
164
  value: 10,
153
165
  renderCount: 2,
@@ -158,65 +170,73 @@ describe("tapResources - Basic Functionality", () => {
158
170
  describe("Dynamic List Management", () => {
159
171
  it("should handle adding and removing resources", () => {
160
172
  const testFiber = createTestResource(
161
- (props: { items: Record<string, number> }) => {
162
- const results = tapResources(
163
- props.items,
164
- (value) => SimpleCounter({ value }),
165
-
166
- [],
167
- );
173
+ (props: { items: Array<{ key: string; value: number }> }) => {
174
+ const results = tapResources(() => {
175
+ return props.items.map((item) =>
176
+ withKey(item.key, SimpleCounter({ value: item.value })),
177
+ );
178
+ }, [props.items]);
168
179
  return results;
169
180
  },
170
181
  );
171
182
 
172
183
  // Initial render with 3 items
173
- const result1 = renderTest(testFiber, { items: { a: 0, b: 10, c: 20 } });
174
- expect(result1).toEqual({
175
- a: { count: 0 },
176
- b: { count: 10 },
177
- c: { count: 20 },
184
+ const result1 = renderTest(testFiber, {
185
+ items: [
186
+ { key: "a", value: 0 },
187
+ { key: "b", value: 10 },
188
+ { key: "c", value: 20 },
189
+ ],
178
190
  });
191
+ expect(result1).toEqual([{ count: 0 }, { count: 10 }, { count: 20 }]);
179
192
 
180
193
  // Remove middle item
181
- const result2 = renderTest(testFiber, { items: { a: 0, c: 10 } });
182
- expect(result2).toEqual({
183
- a: { count: 0 },
184
- c: { count: 10 },
194
+ const result2 = renderTest(testFiber, {
195
+ items: [
196
+ { key: "a", value: 0 },
197
+ { key: "c", value: 10 },
198
+ ],
185
199
  });
200
+ expect(result2).toEqual([{ count: 0 }, { count: 10 }]);
186
201
 
187
202
  // Add new item
188
- const result3 = renderTest(testFiber, { items: { a: 0, c: 10, d: 20 } });
189
- expect(result3).toEqual({
190
- a: { count: 0 },
191
- c: { count: 10 },
192
- d: { count: 20 },
203
+ const result3 = renderTest(testFiber, {
204
+ items: [
205
+ { key: "a", value: 0 },
206
+ { key: "c", value: 10 },
207
+ { key: "d", value: 20 },
208
+ ],
193
209
  });
210
+ expect(result3).toEqual([{ count: 0 }, { count: 10 }, { count: 20 }]);
194
211
  });
195
212
 
196
213
  it("should handle changing resource types for the same key", () => {
197
214
  const testFiber = createTestResource((props: { useCounter: boolean }) => {
198
215
  const results = tapResources(
199
- { item: props.useCounter },
200
- (useCounter) =>
201
- useCounter
202
- ? StatefulCounter({ initial: 42 })
203
- : Display({ text: "Hello" }),
204
- [],
216
+ () => [
217
+ withKey(
218
+ "item",
219
+ props.useCounter
220
+ ? StatefulCounter({ initial: 42 })
221
+ : Display({ text: "Hello" }),
222
+ ),
223
+ ],
224
+ [props.useCounter],
205
225
  );
206
226
  return results;
207
227
  });
208
228
 
209
229
  // Start with Counter
210
230
  const result1 = renderTest(testFiber, { useCounter: true });
211
- expect(result1).toEqual({ item: { count: 42 } });
231
+ expect(result1).toEqual([{ count: 42 }]);
212
232
 
213
233
  // Switch to Display
214
234
  const result2 = renderTest(testFiber, { useCounter: false });
215
- expect(result2).toEqual({ item: { type: "display", text: "Hello" } });
235
+ expect(result2).toEqual([{ type: "display", text: "Hello" }]);
216
236
 
217
237
  // Switch back to Counter (new instance)
218
238
  const result3 = renderTest(testFiber, { useCounter: true });
219
- expect(result3).toEqual({ item: { count: 42 } });
239
+ expect(result3).toEqual([{ count: 42 }]);
220
240
  });
221
241
  });
222
242
  });
@@ -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
+ });