jazz-tools 0.19.8 → 0.19.11

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 (154) hide show
  1. package/.turbo/turbo-build.log +56 -50
  2. package/CHANGELOG.md +30 -3
  3. package/dist/{chunk-2S3Z2CN6.js → chunk-HX5S6W5E.js} +372 -103
  4. package/dist/chunk-HX5S6W5E.js.map +1 -0
  5. package/dist/index.js +1 -1
  6. package/dist/inspector/account-switcher.d.ts +4 -0
  7. package/dist/inspector/account-switcher.d.ts.map +1 -0
  8. package/dist/inspector/chunk-C6BJPHBQ.js +4096 -0
  9. package/dist/inspector/chunk-C6BJPHBQ.js.map +1 -0
  10. package/dist/inspector/contexts/node.d.ts +19 -0
  11. package/dist/inspector/contexts/node.d.ts.map +1 -0
  12. package/dist/inspector/{custom-element-P76EIWEV.js → custom-element-GJVBPZES.js} +1011 -884
  13. package/dist/inspector/custom-element-GJVBPZES.js.map +1 -0
  14. package/dist/inspector/{viewer/new-app.d.ts → in-app.d.ts} +3 -3
  15. package/dist/inspector/in-app.d.ts.map +1 -0
  16. package/dist/inspector/index.d.ts +0 -11
  17. package/dist/inspector/index.d.ts.map +1 -1
  18. package/dist/inspector/index.js +56 -3910
  19. package/dist/inspector/index.js.map +1 -1
  20. package/dist/inspector/pages/home.d.ts +2 -0
  21. package/dist/inspector/pages/home.d.ts.map +1 -0
  22. package/dist/inspector/register-custom-element.js +1 -1
  23. package/dist/inspector/router/context.d.ts +12 -0
  24. package/dist/inspector/router/context.d.ts.map +1 -0
  25. package/dist/inspector/router/hash-router.d.ts +7 -0
  26. package/dist/inspector/router/hash-router.d.ts.map +1 -0
  27. package/dist/inspector/router/in-memory-router.d.ts +7 -0
  28. package/dist/inspector/router/in-memory-router.d.ts.map +1 -0
  29. package/dist/inspector/router/index.d.ts +5 -0
  30. package/dist/inspector/router/index.d.ts.map +1 -0
  31. package/dist/inspector/standalone.d.ts +6 -0
  32. package/dist/inspector/standalone.d.ts.map +1 -0
  33. package/dist/inspector/standalone.js +420 -0
  34. package/dist/inspector/standalone.js.map +1 -0
  35. package/dist/inspector/tests/router/hash-router.test.d.ts +2 -0
  36. package/dist/inspector/tests/router/hash-router.test.d.ts.map +1 -0
  37. package/dist/inspector/tests/router/in-memory-router.test.d.ts +2 -0
  38. package/dist/inspector/tests/router/in-memory-router.test.d.ts.map +1 -0
  39. package/dist/inspector/ui/modal.d.ts +1 -0
  40. package/dist/inspector/ui/modal.d.ts.map +1 -1
  41. package/dist/inspector/viewer/breadcrumbs.d.ts +1 -7
  42. package/dist/inspector/viewer/breadcrumbs.d.ts.map +1 -1
  43. package/dist/inspector/viewer/header.d.ts +7 -0
  44. package/dist/inspector/viewer/header.d.ts.map +1 -0
  45. package/dist/inspector/viewer/page-stack.d.ts +4 -13
  46. package/dist/inspector/viewer/page-stack.d.ts.map +1 -1
  47. package/dist/inspector/viewer/page.d.ts.map +1 -1
  48. package/dist/react/hooks.d.ts +1 -1
  49. package/dist/react/hooks.d.ts.map +1 -1
  50. package/dist/react/index.d.ts +1 -1
  51. package/dist/react/index.d.ts.map +1 -1
  52. package/dist/react/index.js +5 -1
  53. package/dist/react/index.js.map +1 -1
  54. package/dist/react-core/hooks.d.ts +59 -0
  55. package/dist/react-core/hooks.d.ts.map +1 -1
  56. package/dist/react-core/index.js +124 -36
  57. package/dist/react-core/index.js.map +1 -1
  58. package/dist/react-core/tests/testUtils.d.ts +1 -0
  59. package/dist/react-core/tests/testUtils.d.ts.map +1 -1
  60. package/dist/react-core/tests/useSuspenseAccount.test.d.ts +2 -0
  61. package/dist/react-core/tests/useSuspenseAccount.test.d.ts.map +1 -0
  62. package/dist/react-core/tests/useSuspenseCoState.test.d.ts +2 -0
  63. package/dist/react-core/tests/useSuspenseCoState.test.d.ts.map +1 -0
  64. package/dist/react-core/use.d.ts +3 -0
  65. package/dist/react-core/use.d.ts.map +1 -0
  66. package/dist/react-native/index.js +5 -1
  67. package/dist/react-native/index.js.map +1 -1
  68. package/dist/react-native-core/crypto/RNCrypto.d.ts +2 -0
  69. package/dist/react-native-core/crypto/RNCrypto.d.ts.map +1 -0
  70. package/dist/react-native-core/crypto/RNCrypto.js +3 -0
  71. package/dist/react-native-core/crypto/RNCrypto.js.map +1 -0
  72. package/dist/react-native-core/hooks.d.ts +1 -1
  73. package/dist/react-native-core/hooks.d.ts.map +1 -1
  74. package/dist/react-native-core/index.js +5 -1
  75. package/dist/react-native-core/index.js.map +1 -1
  76. package/dist/react-native-core/platform.d.ts +2 -1
  77. package/dist/react-native-core/platform.d.ts.map +1 -1
  78. package/dist/testing.js +1 -1
  79. package/dist/testing.js.map +1 -1
  80. package/dist/tools/coValues/account.d.ts +7 -1
  81. package/dist/tools/coValues/account.d.ts.map +1 -1
  82. package/dist/tools/coValues/interfaces.d.ts +1 -1
  83. package/dist/tools/coValues/interfaces.d.ts.map +1 -1
  84. package/dist/tools/implementation/ContextManager.d.ts +3 -0
  85. package/dist/tools/implementation/ContextManager.d.ts.map +1 -1
  86. package/dist/tools/implementation/zodSchema/schemaTypes/AccountSchema.d.ts +8 -1
  87. package/dist/tools/implementation/zodSchema/schemaTypes/AccountSchema.d.ts.map +1 -1
  88. package/dist/tools/implementation/zodSchema/zodCo.d.ts.map +1 -1
  89. package/dist/tools/subscribe/CoValueCoreSubscription.d.ts +8 -22
  90. package/dist/tools/subscribe/CoValueCoreSubscription.d.ts.map +1 -1
  91. package/dist/tools/subscribe/SubscriptionCache.d.ts +51 -0
  92. package/dist/tools/subscribe/SubscriptionCache.d.ts.map +1 -0
  93. package/dist/tools/subscribe/SubscriptionScope.d.ts +17 -1
  94. package/dist/tools/subscribe/SubscriptionScope.d.ts.map +1 -1
  95. package/dist/tools/subscribe/utils.d.ts +9 -1
  96. package/dist/tools/subscribe/utils.d.ts.map +1 -1
  97. package/dist/tools/testing.d.ts +2 -2
  98. package/dist/tools/testing.d.ts.map +1 -1
  99. package/dist/tools/tests/SubscriptionCache.test.d.ts +2 -0
  100. package/dist/tools/tests/SubscriptionCache.test.d.ts.map +1 -0
  101. package/package.json +18 -6
  102. package/src/inspector/account-switcher.tsx +440 -0
  103. package/src/inspector/contexts/node.tsx +129 -0
  104. package/src/inspector/custom-element.tsx +2 -2
  105. package/src/inspector/in-app.tsx +61 -0
  106. package/src/inspector/index.tsx +2 -22
  107. package/src/inspector/pages/home.tsx +77 -0
  108. package/src/inspector/router/context.ts +21 -0
  109. package/src/inspector/router/hash-router.tsx +128 -0
  110. package/src/inspector/{viewer/use-page-path.ts → router/in-memory-router.tsx} +31 -29
  111. package/src/inspector/router/index.ts +4 -0
  112. package/src/inspector/standalone.tsx +60 -0
  113. package/src/inspector/tests/router/hash-router.test.tsx +847 -0
  114. package/src/inspector/tests/router/in-memory-router.test.tsx +724 -0
  115. package/src/inspector/ui/modal.tsx +5 -2
  116. package/src/inspector/viewer/breadcrumbs.tsx +5 -11
  117. package/src/inspector/viewer/header.tsx +67 -0
  118. package/src/inspector/viewer/page-stack.tsx +18 -26
  119. package/src/inspector/viewer/page.tsx +0 -1
  120. package/src/react/hooks.tsx +2 -0
  121. package/src/react/index.ts +1 -14
  122. package/src/react-core/hooks.ts +167 -18
  123. package/src/react-core/tests/createCoValueSubscriptionContext.test.tsx +18 -8
  124. package/src/react-core/tests/testUtils.tsx +67 -5
  125. package/src/react-core/tests/useCoState.test.ts +3 -7
  126. package/src/react-core/tests/useSubscriptionSelector.test.ts +3 -7
  127. package/src/react-core/tests/useSuspenseAccount.test.tsx +343 -0
  128. package/src/react-core/tests/useSuspenseCoState.test.tsx +1182 -0
  129. package/src/react-core/use.ts +46 -0
  130. package/src/react-native-core/crypto/RNCrypto.ts +1 -0
  131. package/src/react-native-core/hooks.tsx +2 -0
  132. package/src/react-native-core/platform.ts +2 -1
  133. package/src/tools/coValues/account.ts +13 -2
  134. package/src/tools/coValues/interfaces.ts +2 -3
  135. package/src/tools/implementation/ContextManager.ts +13 -0
  136. package/src/tools/implementation/zodSchema/schemaTypes/AccountSchema.ts +8 -1
  137. package/src/tools/subscribe/CoValueCoreSubscription.ts +71 -100
  138. package/src/tools/subscribe/SubscriptionCache.ts +272 -0
  139. package/src/tools/subscribe/SubscriptionScope.ts +113 -7
  140. package/src/tools/subscribe/utils.ts +77 -0
  141. package/src/tools/testing.ts +0 -3
  142. package/src/tools/tests/CoValueCoreSubscription.test.ts +46 -12
  143. package/src/tools/tests/ContextManager.test.ts +85 -0
  144. package/src/tools/tests/SubscriptionCache.test.ts +237 -0
  145. package/src/tools/tests/account.test.ts +11 -4
  146. package/src/tools/tests/coMap.test.ts +5 -7
  147. package/src/tools/tests/schema.resolved.test.ts +3 -3
  148. package/tsup.config.ts +2 -0
  149. package/dist/chunk-2S3Z2CN6.js.map +0 -1
  150. package/dist/inspector/custom-element-P76EIWEV.js.map +0 -1
  151. package/dist/inspector/viewer/new-app.d.ts.map +0 -1
  152. package/dist/inspector/viewer/use-page-path.d.ts +0 -10
  153. package/dist/inspector/viewer/use-page-path.d.ts.map +0 -1
  154. package/src/inspector/viewer/new-app.tsx +0 -156
@@ -0,0 +1,1182 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { cojsonInternals } from "cojson";
4
+ import { Group, Loaded, co, z } from "jazz-tools";
5
+ import { assertLoaded, disableJazzTestSync } from "jazz-tools/testing";
6
+ import { beforeEach, describe, expect, expectTypeOf, it } from "vitest";
7
+ import React, { Suspense, useRef } from "react";
8
+ import { useSuspenseCoState } from "../hooks.js";
9
+ import { createJazzTestAccount, setupJazzTestSync } from "../testing.js";
10
+ import {
11
+ act,
12
+ createAsyncStorage,
13
+ render,
14
+ renderHook,
15
+ waitFor,
16
+ } from "./testUtils.js";
17
+ import { ErrorBoundary } from "react-error-boundary";
18
+
19
+ // Hook to track render count
20
+ const useRenderCount = <T,>(hook: () => T) => {
21
+ const renderCountRef = useRef(0);
22
+ const result = hook();
23
+ renderCountRef.current = renderCountRef.current + 1;
24
+ return {
25
+ renderCount: renderCountRef.current,
26
+ result,
27
+ };
28
+ };
29
+
30
+ // Silence unhandled rejection errors coming from Suspense
31
+ process.on("unhandledRejection", () => {});
32
+
33
+ beforeEach(async () => {
34
+ cojsonInternals.setCoValueLoadingRetryDelay(20);
35
+
36
+ await setupJazzTestSync({
37
+ asyncPeers: true,
38
+ });
39
+
40
+ await createJazzTestAccount({
41
+ isCurrentActiveAccount: true,
42
+ });
43
+ });
44
+
45
+ describe("useSuspenseCoState", () => {
46
+ it("should return loaded value without suspending when data is available", async () => {
47
+ const TestMap = co.map({
48
+ value: z.string(),
49
+ });
50
+
51
+ const account = await createJazzTestAccount({
52
+ isCurrentActiveAccount: true,
53
+ });
54
+
55
+ const map = TestMap.create({
56
+ value: "123",
57
+ });
58
+
59
+ let suspenseTriggered = false;
60
+
61
+ const SuspenseFallback = () => {
62
+ suspenseTriggered = true;
63
+ return <div>Loading...</div>;
64
+ };
65
+
66
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
67
+ <Suspense fallback={<SuspenseFallback />}>{children}</Suspense>
68
+ );
69
+
70
+ const { result } = renderHook(
71
+ () => useSuspenseCoState(TestMap, map.$jazz.id),
72
+ {
73
+ account,
74
+ wrapper,
75
+ },
76
+ );
77
+
78
+ // Wait for any async operations to complete
79
+ await waitFor(() => {
80
+ expect(result.current).toBeDefined();
81
+ });
82
+
83
+ // Verify Suspense was not triggered since data was immediately available
84
+ expect(suspenseTriggered).toBe(false);
85
+
86
+ // Verify the hook returns loaded data
87
+ assertLoaded(result.current);
88
+ expect(result.current.value).toBe("123");
89
+ });
90
+
91
+ it("should have Loaded<T> return type", async () => {
92
+ const TestMap = co.map({
93
+ value: z.string(),
94
+ });
95
+
96
+ const account = await createJazzTestAccount({
97
+ isCurrentActiveAccount: true,
98
+ });
99
+
100
+ const map = TestMap.create({
101
+ value: "123",
102
+ });
103
+
104
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
105
+ <Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
106
+ );
107
+
108
+ const { result } = renderHook(
109
+ () => useSuspenseCoState(TestMap, map.$jazz.id),
110
+ {
111
+ account,
112
+ wrapper,
113
+ },
114
+ );
115
+
116
+ await waitFor(() => {
117
+ expect(result.current).toBeDefined();
118
+ });
119
+
120
+ // Verify the return type is Loaded<typeof TestMap>
121
+ expectTypeOf(result.current).toEqualTypeOf<Loaded<typeof TestMap>>();
122
+ });
123
+
124
+ it("should suspend when data is not immediately available", async () => {
125
+ const TestMap = co.map({
126
+ value: z.string(),
127
+ });
128
+
129
+ const map = TestMap.create(
130
+ {
131
+ value: "123",
132
+ },
133
+ Group.create().makePublic("reader"),
134
+ );
135
+
136
+ const viewerAccount = await createJazzTestAccount({
137
+ isCurrentActiveAccount: true,
138
+ });
139
+
140
+ let suspenseTriggered = false;
141
+
142
+ const SuspenseFallback = () => {
143
+ suspenseTriggered = true;
144
+ return <div>Loading...</div>;
145
+ };
146
+
147
+ const TestComponent = () => {
148
+ const value = useSuspenseCoState(TestMap, map.$jazz.id);
149
+ return <div>{value.value}</div>;
150
+ };
151
+
152
+ const { container } = await act(async () => {
153
+ return render(
154
+ <Suspense fallback={<SuspenseFallback />}>
155
+ <TestComponent />
156
+ </Suspense>,
157
+ {
158
+ account: viewerAccount,
159
+ },
160
+ );
161
+ });
162
+
163
+ expect(suspenseTriggered).toBe(true);
164
+
165
+ // Wait for data to load - the subscription should update and resolve
166
+ await waitFor(() => {
167
+ expect(container.textContent).toContain("123");
168
+ expect(container.textContent).not.toContain("Loading...");
169
+ });
170
+ });
171
+
172
+ it("should throw error when CoValue is unavailable", async () => {
173
+ const TestMap = co.map({
174
+ value: z.string(),
175
+ });
176
+
177
+ const map = TestMap.create(
178
+ {
179
+ value: "123",
180
+ },
181
+ Group.create().makePublic("reader"),
182
+ );
183
+
184
+ await setupJazzTestSync();
185
+
186
+ const viewerAccount = await createJazzTestAccount({
187
+ isCurrentActiveAccount: true,
188
+ });
189
+
190
+ const TestComponent = () => {
191
+ const value = useSuspenseCoState(TestMap, map.$jazz.id);
192
+ return <div>{value.value}</div>;
193
+ };
194
+
195
+ const { container } = await act(async () => {
196
+ return render(
197
+ <ErrorBoundary fallback={<div>Error!</div>}>
198
+ <Suspense fallback={<div>Loading...</div>}>
199
+ <TestComponent />
200
+ </Suspense>
201
+ </ErrorBoundary>,
202
+ {
203
+ account: viewerAccount,
204
+ },
205
+ );
206
+ });
207
+
208
+ // Verify error is displayed in error boundary
209
+ await waitFor(
210
+ () => {
211
+ expect(container.textContent).toContain("Error");
212
+ },
213
+ { timeout: 10_000 },
214
+ );
215
+ });
216
+
217
+ it("should throw error when CoValue is unavailable due disabled network", async () => {
218
+ disableJazzTestSync();
219
+
220
+ const TestMap = co.map({
221
+ value: z.string(),
222
+ });
223
+
224
+ const map = TestMap.create(
225
+ {
226
+ value: "123",
227
+ },
228
+ Group.create().makePublic("reader"),
229
+ );
230
+
231
+ const viewerAccount = await createJazzTestAccount({
232
+ isCurrentActiveAccount: true,
233
+ });
234
+
235
+ viewerAccount.$jazz.localNode.setStorage(await createAsyncStorage());
236
+
237
+ const TestComponent = () => {
238
+ const value = useSuspenseCoState(TestMap, map.$jazz.id);
239
+ return <div>{value.value}</div>;
240
+ };
241
+
242
+ const { container } = await act(async () => {
243
+ return render(
244
+ <ErrorBoundary fallback={<div>Error!</div>}>
245
+ <Suspense fallback={<div>Loading...</div>}>
246
+ <TestComponent />
247
+ </Suspense>
248
+ </ErrorBoundary>,
249
+ {
250
+ account: viewerAccount,
251
+ },
252
+ );
253
+ });
254
+
255
+ // Verify error is displayed in error boundary
256
+ await waitFor(
257
+ () => {
258
+ expect(container.textContent).toContain("Error!");
259
+ },
260
+ { timeout: 10_000 },
261
+ );
262
+ });
263
+
264
+ it("should throw error when CoValue is unavailable due to missing loading sources", async () => {
265
+ disableJazzTestSync();
266
+
267
+ const TestMap = co.map({
268
+ value: z.string(),
269
+ });
270
+
271
+ const map = TestMap.create(
272
+ {
273
+ value: "123",
274
+ },
275
+ Group.create().makePublic("reader"),
276
+ );
277
+
278
+ const viewerAccount = await createJazzTestAccount({
279
+ isCurrentActiveAccount: true,
280
+ });
281
+
282
+ const TestComponent = () => {
283
+ const value = useSuspenseCoState(TestMap, map.$jazz.id);
284
+ return <div>{value.value}</div>;
285
+ };
286
+
287
+ const { container } = await act(async () => {
288
+ return render(
289
+ <ErrorBoundary fallback={<div>Error!</div>}>
290
+ <Suspense fallback={<div>Loading...</div>}>
291
+ <TestComponent />
292
+ </Suspense>
293
+ </ErrorBoundary>,
294
+ {
295
+ account: viewerAccount,
296
+ },
297
+ );
298
+ });
299
+
300
+ // Verify error is displayed in error boundary
301
+ await waitFor(() => {
302
+ expect(container.textContent).toContain("Error!");
303
+ });
304
+ });
305
+
306
+ it("should throw error with invalid subscription ID", async () => {
307
+ const TestMap = co.map({
308
+ value: z.string(),
309
+ });
310
+
311
+ const TestComponent = () => {
312
+ const value = useSuspenseCoState(TestMap, "invalid-id");
313
+ return <div>{value.value}</div>;
314
+ };
315
+
316
+ const { container } = await act(async () => {
317
+ return render(
318
+ <ErrorBoundary fallback={<div>Error!</div>}>
319
+ <Suspense fallback={<div>Loading...</div>}>
320
+ <TestComponent />
321
+ </Suspense>
322
+ </ErrorBoundary>,
323
+ );
324
+ });
325
+
326
+ // Wait for error to be thrown
327
+ await waitFor(
328
+ () => {
329
+ expect(container.textContent).toContain("Error!");
330
+ },
331
+ { timeout: 1000 },
332
+ );
333
+ });
334
+
335
+ it("should throw error when CoValue is unauthorized", async () => {
336
+ const TestMap = co.map({
337
+ value: z.string(),
338
+ });
339
+
340
+ // Create CoValue owned by another account without sharing
341
+ const map = TestMap.create(
342
+ {
343
+ value: "123",
344
+ },
345
+ Group.create(),
346
+ );
347
+
348
+ await createJazzTestAccount({
349
+ isCurrentActiveAccount: true,
350
+ });
351
+
352
+ const TestComponent = () => {
353
+ const value = useSuspenseCoState(TestMap, map.$jazz.id);
354
+ return <div>{value.value}</div>;
355
+ };
356
+
357
+ const { container } = await act(async () => {
358
+ return render(
359
+ <ErrorBoundary fallback={<div>Error!</div>}>
360
+ <Suspense fallback={<div>Loading...</div>}>
361
+ <TestComponent />
362
+ </Suspense>
363
+ </ErrorBoundary>,
364
+ );
365
+ });
366
+
367
+ // Wait for error to be thrown (unauthorized access)
368
+ await waitFor(() => {
369
+ expect(container.textContent).toContain("Error!");
370
+ });
371
+ });
372
+
373
+ it("should update value when CoValue changes", async () => {
374
+ const TestMap = co.map({
375
+ value: z.string(),
376
+ });
377
+
378
+ const account = await createJazzTestAccount({
379
+ isCurrentActiveAccount: true,
380
+ });
381
+
382
+ const map = TestMap.create({
383
+ value: "123",
384
+ });
385
+
386
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
387
+ <Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
388
+ );
389
+
390
+ const { result } = renderHook(
391
+ () => useSuspenseCoState(TestMap, map.$jazz.id),
392
+ {
393
+ account,
394
+ wrapper,
395
+ },
396
+ );
397
+
398
+ // Wait for initial load
399
+ await waitFor(() => {
400
+ expect(result.current).toBeTruthy();
401
+ });
402
+
403
+ // Verify initial value is correct
404
+ assertLoaded(result.current);
405
+ expect(result.current.value).toBe("123");
406
+
407
+ // Update the CoValue field
408
+ act(() => {
409
+ map.$jazz.set("value", "456");
410
+ });
411
+
412
+ // Verify the hook returns updated value
413
+ await waitFor(() => {
414
+ expect(result.current.value).toBe("456");
415
+ });
416
+
417
+ // Verify it's still loaded (no suspension occurred)
418
+ assertLoaded(result.current);
419
+ });
420
+
421
+ it("should maintain loaded state during updates", async () => {
422
+ const TestMap = co.map({
423
+ value: z.string(),
424
+ count: z.number(),
425
+ });
426
+
427
+ const account = await createJazzTestAccount({
428
+ isCurrentActiveAccount: true,
429
+ });
430
+
431
+ const map = TestMap.create({
432
+ value: "initial",
433
+ count: 0,
434
+ });
435
+
436
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
437
+ <Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
438
+ );
439
+
440
+ const { result } = renderHook(
441
+ () => useSuspenseCoState(TestMap, map.$jazz.id),
442
+ {
443
+ account,
444
+ wrapper,
445
+ },
446
+ );
447
+
448
+ // Wait for initial load
449
+ await waitFor(() => {
450
+ expect(result.current).toBeDefined();
451
+ });
452
+
453
+ assertLoaded(result.current);
454
+ expect(result.current.value).toBe("initial");
455
+ expect(result.current.count).toBe(0);
456
+
457
+ // Update multiple fields
458
+ act(() => {
459
+ map.$jazz.set("value", "updated");
460
+ map.$jazz.set("count", 42);
461
+ });
462
+
463
+ // Verify all changes are reflected
464
+ await waitFor(() => {
465
+ expect(result.current.value).toBe("updated");
466
+ expect(result.current.count).toBe(42);
467
+ });
468
+
469
+ // Verify still loaded (no suspension)
470
+ assertLoaded(result.current);
471
+ });
472
+
473
+ it("should load nested values with resolve query", async () => {
474
+ const TestNestedMap = co.map({
475
+ value: z.string(),
476
+ });
477
+
478
+ const TestMap = co.map({
479
+ value: z.string(),
480
+ nested: TestNestedMap,
481
+ });
482
+
483
+ const account = await createJazzTestAccount({
484
+ isCurrentActiveAccount: true,
485
+ });
486
+
487
+ const map = TestMap.create({
488
+ value: "123",
489
+ nested: TestNestedMap.create({
490
+ value: "456",
491
+ }),
492
+ });
493
+
494
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
495
+ <Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
496
+ );
497
+
498
+ const { result } = renderHook(
499
+ () =>
500
+ useSuspenseCoState(TestMap, map.$jazz.id, {
501
+ resolve: {
502
+ nested: true,
503
+ },
504
+ }),
505
+ {
506
+ account,
507
+ wrapper,
508
+ },
509
+ );
510
+
511
+ // Wait for both parent and nested values to load
512
+ await waitFor(() => {
513
+ expect(result.current).toBeDefined();
514
+ });
515
+
516
+ // Verify both parent and nested values are loaded
517
+ assertLoaded(result.current);
518
+ expect(result.current.value).toBe("123");
519
+ assertLoaded(result.current.nested);
520
+ expect(result.current.nested.value).toBe("456");
521
+ });
522
+
523
+ it("should auto-load nested values on access", async () => {
524
+ const TestNestedMap = co.map({
525
+ value: z.string(),
526
+ });
527
+
528
+ const TestMap = co.map({
529
+ value: z.string(),
530
+ nested: TestNestedMap,
531
+ });
532
+
533
+ const map = TestMap.create(
534
+ {
535
+ value: "123",
536
+ nested: {
537
+ value: "456",
538
+ },
539
+ },
540
+ Group.create().makePublic("reader"),
541
+ );
542
+
543
+ const account = await createJazzTestAccount({
544
+ isCurrentActiveAccount: true,
545
+ });
546
+
547
+ // Preload the CoValue to avoid that the initial load triggers a suspension
548
+ await TestMap.load(map.$jazz.id);
549
+
550
+ let suspenseTriggered = false;
551
+
552
+ const SuspenseFallback = () => {
553
+ suspenseTriggered = true;
554
+ return <div>Loading...</div>;
555
+ };
556
+
557
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
558
+ <Suspense fallback={<SuspenseFallback />}>{children}</Suspense>
559
+ );
560
+
561
+ const { result } = renderHook(
562
+ () => useSuspenseCoState(TestMap, map.$jazz.id),
563
+ {
564
+ account,
565
+ wrapper,
566
+ },
567
+ );
568
+
569
+ // Wait for parent value to load
570
+ await waitFor(() => {
571
+ expect(result.current).toBeDefined();
572
+ });
573
+
574
+ // Verify parent value is loaded
575
+ assertLoaded(result.current);
576
+ expect(result.current.value).toBe("123");
577
+
578
+ // Access nested value - it should load automatically
579
+ await waitFor(() => {
580
+ assertLoaded(result.current.nested);
581
+ expect(result.current.nested.value).toBe("456");
582
+ });
583
+
584
+ // Verify Suspense was not triggered during the autoload
585
+ expect(suspenseTriggered).toBe(false);
586
+ });
587
+
588
+ it("should load deeply nested structures", async () => {
589
+ const Message = co.map({
590
+ content: co.plainText(),
591
+ });
592
+ const Messages = co.list(Message);
593
+ const Thread = co.map({
594
+ messages: Messages,
595
+ });
596
+
597
+ const account = await createJazzTestAccount({
598
+ isCurrentActiveAccount: true,
599
+ });
600
+
601
+ const thread = Thread.create({
602
+ messages: Messages.create([
603
+ Message.create({
604
+ content: "Hello man!",
605
+ }),
606
+ Message.create({
607
+ content: "The temperature is high today",
608
+ }),
609
+ Message.create({
610
+ content: "Shall we go to the beach?",
611
+ }),
612
+ ]),
613
+ });
614
+
615
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
616
+ <Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
617
+ );
618
+
619
+ const { result } = renderHook(
620
+ () =>
621
+ useSuspenseCoState(Thread, thread.$jazz.id, {
622
+ resolve: {
623
+ messages: {
624
+ $each: {
625
+ content: true,
626
+ },
627
+ },
628
+ },
629
+ }),
630
+ {
631
+ account,
632
+ wrapper,
633
+ },
634
+ );
635
+
636
+ // Wait for all nested levels to load
637
+ await waitFor(() => {
638
+ expect(result.current).toBeDefined();
639
+ });
640
+
641
+ // Verify all nested levels are loaded
642
+ assertLoaded(result.current);
643
+ expect(result.current.messages.length).toBe(3);
644
+
645
+ // Verify each message and its content are loaded
646
+ const message0 = result.current.messages[0];
647
+ expect(message0).toBeDefined();
648
+ assertLoaded(message0!);
649
+ expect(message0!.content.toString()).toBe("Hello man!");
650
+
651
+ const message1 = result.current.messages[1];
652
+ expect(message1).toBeDefined();
653
+ assertLoaded(message1!);
654
+ expect(message1!.content.toString()).toBe("The temperature is high today");
655
+
656
+ const message2 = result.current.messages[2];
657
+ expect(message2).toBeDefined();
658
+ assertLoaded(message2!);
659
+ expect(message2!.content.toString()).toBe("Shall we go to the beach?");
660
+ });
661
+
662
+ it("should work with selector function", async () => {
663
+ const TestMap = co.map({
664
+ value: z.string(),
665
+ count: z.number(),
666
+ metadata: z.string(),
667
+ });
668
+
669
+ const account = await createJazzTestAccount({
670
+ isCurrentActiveAccount: true,
671
+ });
672
+
673
+ const map = TestMap.create({
674
+ value: "test",
675
+ count: 42,
676
+ metadata: "extra",
677
+ });
678
+
679
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
680
+ <Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
681
+ );
682
+
683
+ // Selector that transforms data - returns only value and count as an object
684
+ const { result } = renderHook(
685
+ () =>
686
+ useSuspenseCoState(TestMap, map.$jazz.id, {
687
+ select: (value) => ({
688
+ value: value.value,
689
+ count: value.count,
690
+ }),
691
+ }),
692
+ {
693
+ account,
694
+ wrapper,
695
+ },
696
+ );
697
+
698
+ // Wait for data to load
699
+ await waitFor(() => {
700
+ expect(result.current).toBeDefined();
701
+ });
702
+
703
+ // Verify returned value is the transformed result
704
+ expect(result.current).toEqual({
705
+ value: "test",
706
+ count: 42,
707
+ });
708
+
709
+ // Verify metadata is not included (selector filtered it out)
710
+ expect(result.current).not.toHaveProperty("metadata");
711
+
712
+ // Verify return type matches selector output type
713
+ expectTypeOf(result.current).toEqualTypeOf<{
714
+ value: string;
715
+ count: number;
716
+ }>();
717
+ });
718
+
719
+ it("should maintain type safety with selector", async () => {
720
+ const TestMap = co.map({
721
+ value: z.string(),
722
+ count: z.number(),
723
+ });
724
+
725
+ const account = await createJazzTestAccount({
726
+ isCurrentActiveAccount: true,
727
+ });
728
+
729
+ const map = TestMap.create({
730
+ value: "hello",
731
+ count: 10,
732
+ });
733
+
734
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
735
+ <Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
736
+ );
737
+
738
+ // Selector that returns a string
739
+ const { result } = renderHook(
740
+ () =>
741
+ useSuspenseCoState(TestMap, map.$jazz.id, {
742
+ select: (value) => `${value.value}: ${value.count}`,
743
+ }),
744
+ {
745
+ account,
746
+ wrapper,
747
+ },
748
+ );
749
+
750
+ // Wait for data to load
751
+ await waitFor(() => {
752
+ expect(result.current).toBeDefined();
753
+ });
754
+
755
+ // Verify returned value is the transformed result
756
+ expect(result.current).toBe("hello: 10");
757
+
758
+ // Verify return type matches selector output type (string)
759
+ expectTypeOf(result.current).toEqualTypeOf<string>();
760
+ });
761
+
762
+ it("should update selector result when CoValue changes", async () => {
763
+ const TestMap = co.map({
764
+ value: z.string(),
765
+ count: z.number(),
766
+ });
767
+
768
+ const account = await createJazzTestAccount({
769
+ isCurrentActiveAccount: true,
770
+ });
771
+
772
+ const map = TestMap.create({
773
+ value: "initial",
774
+ count: 0,
775
+ });
776
+
777
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
778
+ <Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
779
+ );
780
+
781
+ // Selector that combines value and count
782
+ const { result } = renderHook(
783
+ () =>
784
+ useSuspenseCoState(TestMap, map.$jazz.id, {
785
+ select: (value) => `${value.value}-${value.count}`,
786
+ }),
787
+ {
788
+ account,
789
+ wrapper,
790
+ },
791
+ );
792
+
793
+ // Wait for initial load
794
+ await waitFor(() => {
795
+ expect(result.current).toBeDefined();
796
+ });
797
+
798
+ expect(result.current).toBe("initial-0");
799
+
800
+ // Update the CoValue
801
+ act(() => {
802
+ map.$jazz.set("value", "updated");
803
+ map.$jazz.set("count", 100);
804
+ });
805
+
806
+ // Verify selector result updates
807
+ await waitFor(() => {
808
+ expect(result.current).toBe("updated-100");
809
+ });
810
+ });
811
+
812
+ it("should respect custom equality function", async () => {
813
+ const TestMap = co.map({
814
+ count: z.number(),
815
+ metadata: z.string(),
816
+ });
817
+
818
+ const account = await createJazzTestAccount({
819
+ isCurrentActiveAccount: true,
820
+ });
821
+
822
+ const map = TestMap.create({
823
+ count: 0,
824
+ metadata: "initial",
825
+ });
826
+
827
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
828
+ <Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
829
+ );
830
+
831
+ // Custom equality function that compares count only
832
+ const { result } = renderHook(
833
+ () =>
834
+ useRenderCount(() =>
835
+ useSuspenseCoState(TestMap, map.$jazz.id, {
836
+ select: (value) => ({
837
+ count: value.count,
838
+ metadata: value.metadata,
839
+ }),
840
+ equalityFn: (a, b) => a.count === b.count,
841
+ }),
842
+ ),
843
+ {
844
+ account,
845
+ wrapper,
846
+ },
847
+ );
848
+
849
+ // Wait for initial load
850
+ await waitFor(() => {
851
+ expect(result.current.result).toBeDefined();
852
+ });
853
+
854
+ // Verify initial render
855
+ expect(result.current.renderCount).toBe(1);
856
+ expect(result.current.result.count).toBe(0);
857
+ expect(result.current.result.metadata).toBe("initial");
858
+
859
+ const initialRenderCount = result.current.renderCount;
860
+
861
+ // Update metadata field (equality should return true - count unchanged)
862
+ act(() => {
863
+ map.$jazz.set("metadata", "updated");
864
+ });
865
+
866
+ // Wait a bit to ensure no re-render occurred
867
+ await new Promise((resolve) => setTimeout(resolve, 50));
868
+
869
+ // Verify no re-render occurred (equality function returned true)
870
+ expect(result.current.renderCount).toBe(initialRenderCount);
871
+ // Note: The result might still show old metadata since no re-render occurred
872
+ // But the underlying data has changed
873
+
874
+ // Update count field (equality should return false - count changed)
875
+ act(() => {
876
+ map.$jazz.set("count", 42);
877
+ });
878
+
879
+ // Verify re-render occurred
880
+ await waitFor(() => {
881
+ expect(result.current.renderCount).toBe(initialRenderCount + 1);
882
+ expect(result.current.result.count).toBe(42);
883
+ });
884
+ });
885
+
886
+ it("should prevent re-renders when equality returns true", async () => {
887
+ const TestMap = co.map({
888
+ value: z.string(),
889
+ count: z.number(),
890
+ });
891
+
892
+ const account = await createJazzTestAccount({
893
+ isCurrentActiveAccount: true,
894
+ });
895
+
896
+ const map = TestMap.create({
897
+ value: "test",
898
+ count: 10,
899
+ });
900
+
901
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
902
+ <Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
903
+ );
904
+
905
+ // Equality function that always returns true (prevents all re-renders)
906
+ const { result } = renderHook(
907
+ () =>
908
+ useRenderCount(() =>
909
+ useSuspenseCoState(TestMap, map.$jazz.id, {
910
+ select: (value) => ({
911
+ value: value.value,
912
+ count: value.count,
913
+ }),
914
+ equalityFn: () => true,
915
+ }),
916
+ ),
917
+ {
918
+ account,
919
+ wrapper,
920
+ },
921
+ );
922
+
923
+ // Wait for initial load
924
+ await waitFor(() => {
925
+ expect(result.current.result).toBeDefined();
926
+ });
927
+
928
+ const initialRenderCount = result.current.renderCount;
929
+ const initialValue = result.current.result.value;
930
+ const initialCount = result.current.result.count;
931
+
932
+ // Update both fields multiple times
933
+ for (let i = 1; i <= 10; i++) {
934
+ act(() => {
935
+ map.$jazz.set("value", `updated-${i}`);
936
+ map.$jazz.set("count", 10 + i);
937
+ });
938
+ await new Promise((resolve) => setTimeout(resolve, 10));
939
+ }
940
+
941
+ // Verify no re-renders occurred (equality always returns true)
942
+ expect(result.current.renderCount).toBe(initialRenderCount);
943
+ // Result should still show initial values since no re-render occurred
944
+ expect(result.current.result.value).toBe(initialValue);
945
+ expect(result.current.result.count).toBe(initialCount);
946
+ });
947
+
948
+ it("should trigger re-render when equality returns false", async () => {
949
+ const TestMap = co.map({
950
+ value: z.string(),
951
+ count: z.number(),
952
+ });
953
+
954
+ const account = await createJazzTestAccount({
955
+ isCurrentActiveAccount: true,
956
+ });
957
+
958
+ const map = TestMap.create({
959
+ value: "initial",
960
+ count: 0,
961
+ });
962
+
963
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
964
+ <Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
965
+ );
966
+
967
+ // Equality function that compares both value and count
968
+ const { result } = renderHook(
969
+ () =>
970
+ useRenderCount(() =>
971
+ useSuspenseCoState(TestMap, map.$jazz.id, {
972
+ select: (value) => ({
973
+ value: value.value,
974
+ count: value.count,
975
+ }),
976
+ equalityFn: (a, b) => a.value === b.value && a.count === b.count,
977
+ }),
978
+ ),
979
+ {
980
+ account,
981
+ wrapper,
982
+ },
983
+ );
984
+
985
+ // Wait for initial load
986
+ await waitFor(() => {
987
+ expect(result.current.result).toBeDefined();
988
+ });
989
+
990
+ const initialRenderCount = result.current.renderCount;
991
+
992
+ // Update count field (equality should return false)
993
+ act(() => {
994
+ map.$jazz.set("count", 100);
995
+ });
996
+
997
+ // Verify re-render occurred
998
+ await waitFor(() => {
999
+ expect(result.current.renderCount).toBe(initialRenderCount + 1);
1000
+ expect(result.current.result.count).toBe(100);
1001
+ });
1002
+ });
1003
+
1004
+ it("should work with branches - create, edit, and merge", async () => {
1005
+ const Person = co.map({
1006
+ name: z.string(),
1007
+ age: z.number(),
1008
+ email: z.string(),
1009
+ });
1010
+
1011
+ const group = Group.create();
1012
+ group.addMember("everyone", "writer");
1013
+
1014
+ const originalPerson = Person.create(
1015
+ {
1016
+ name: "John Doe",
1017
+ age: 30,
1018
+ email: "john@example.com",
1019
+ },
1020
+ group,
1021
+ );
1022
+
1023
+ const account = await createJazzTestAccount({
1024
+ isCurrentActiveAccount: true,
1025
+ });
1026
+
1027
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
1028
+ <Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
1029
+ );
1030
+
1031
+ // Render useSuspenseCoState twice: once for branch, once for main
1032
+ const { result } = await act(async () => {
1033
+ return renderHook(
1034
+ () => {
1035
+ const branch = useSuspenseCoState(Person, originalPerson.$jazz.id, {
1036
+ unstable_branch: { name: "feature-branch" },
1037
+ });
1038
+
1039
+ const main = useSuspenseCoState(Person, originalPerson.$jazz.id);
1040
+
1041
+ return { branch, main };
1042
+ },
1043
+ {
1044
+ account,
1045
+ wrapper,
1046
+ },
1047
+ );
1048
+ });
1049
+
1050
+ // Wait for both to load
1051
+ await waitFor(() => {
1052
+ expect(result.current).not.toBeNull();
1053
+ expect(result.current.branch).toBeDefined();
1054
+ expect(result.current.main).toBeDefined();
1055
+ });
1056
+
1057
+ // Verify both return loaded data
1058
+ assertLoaded(result.current.branch);
1059
+ assertLoaded(result.current.main);
1060
+
1061
+ expect(result.current.branch.name).toBe("John Doe");
1062
+ expect(result.current.branch.age).toBe(30);
1063
+ expect(result.current.branch.email).toBe("john@example.com");
1064
+
1065
+ expect(result.current.main.name).toBe("John Doe");
1066
+ expect(result.current.main.age).toBe(30);
1067
+ expect(result.current.main.email).toBe("john@example.com");
1068
+
1069
+ // Use act() to modify branch CoValue
1070
+ act(() => {
1071
+ result.current.branch.$jazz.applyDiff({
1072
+ name: "John Smith",
1073
+ age: 31,
1074
+ email: "john.smith@example.com",
1075
+ });
1076
+ });
1077
+
1078
+ // Wait for updates
1079
+ await waitFor(() => {
1080
+ expect(result.current.branch.name).toBe("John Smith");
1081
+ });
1082
+
1083
+ // Verify branch has changes
1084
+ expect(result.current.branch.name).toBe("John Smith");
1085
+ expect(result.current.branch.age).toBe(31);
1086
+ expect(result.current.branch.email).toBe("john.smith@example.com");
1087
+
1088
+ // Verify main is unchanged
1089
+ expect(result.current.main.name).toBe("John Doe");
1090
+ expect(result.current.main.age).toBe(30);
1091
+ expect(result.current.main.email).toBe("john@example.com");
1092
+
1093
+ // Merge branch
1094
+ await act(async () => {
1095
+ await result.current.branch.$jazz.unstable_merge();
1096
+ });
1097
+
1098
+ // Wait for merge to propagate
1099
+ await waitFor(() => {
1100
+ expect(result.current.main.name).toBe("John Smith");
1101
+ });
1102
+
1103
+ // Verify main now has the changes
1104
+ expect(result.current.main.name).toBe("John Smith");
1105
+ expect(result.current.main.age).toBe(31);
1106
+ expect(result.current.main.email).toBe("john.smith@example.com");
1107
+ });
1108
+
1109
+ it("should preload value when provided", async () => {
1110
+ disableJazzTestSync();
1111
+
1112
+ const Person = co.map({
1113
+ name: z.string(),
1114
+ age: z.number(),
1115
+ email: z.string(),
1116
+ });
1117
+
1118
+ const group = Group.create();
1119
+ group.addMember("everyone", "writer");
1120
+
1121
+ const originalPerson = Person.create(
1122
+ {
1123
+ name: "John Doe",
1124
+ age: 30,
1125
+ email: "john@example.com",
1126
+ },
1127
+ group,
1128
+ );
1129
+
1130
+ // Create a test account (different from creator)
1131
+ const bob = await createJazzTestAccount({
1132
+ isCurrentActiveAccount: true,
1133
+ });
1134
+
1135
+ // Export the CoValue
1136
+ const exportedPerson = originalPerson.$jazz.export();
1137
+
1138
+ // Track render count
1139
+ let renderCount = 0;
1140
+ let suspenseTriggered = false;
1141
+
1142
+ const SuspenseFallback = () => {
1143
+ suspenseTriggered = true;
1144
+ return <div>Loading...</div>;
1145
+ };
1146
+
1147
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
1148
+ <Suspense fallback={<SuspenseFallback />}>{children}</Suspense>
1149
+ );
1150
+
1151
+ // Render useSuspenseCoState with preloaded data
1152
+ const { result } = renderHook(
1153
+ () => {
1154
+ renderCount++;
1155
+ return useSuspenseCoState(Person, originalPerson.$jazz.id, {
1156
+ preloaded: exportedPerson,
1157
+ });
1158
+ },
1159
+ {
1160
+ account: bob,
1161
+ wrapper,
1162
+ },
1163
+ );
1164
+
1165
+ // Wait for any async operations
1166
+ await waitFor(() => {
1167
+ expect(result.current).toBeDefined();
1168
+ });
1169
+
1170
+ // Verify only one render occurred
1171
+ expect(renderCount).toBe(1);
1172
+
1173
+ // Verify Suspense was not triggered (preloaded data enables immediate rendering)
1174
+ expect(suspenseTriggered).toBe(false);
1175
+
1176
+ // Verify data is immediately accessible
1177
+ assertLoaded(result.current);
1178
+ expect(result.current.name).toBe("John Doe");
1179
+ expect(result.current.age).toBe(30);
1180
+ expect(result.current.email).toBe("john@example.com");
1181
+ });
1182
+ });