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,272 @@
1
+ import { LocalNode } from "cojson";
2
+ import type {
3
+ CoValue,
4
+ CoValueClassOrSchema,
5
+ RefEncoded,
6
+ RefsToResolve,
7
+ ResolveQuery,
8
+ } from "../internal.js";
9
+ import { coValueClassFromCoValueClassOrSchema } from "../internal.js";
10
+ import { SubscriptionScope } from "./SubscriptionScope.js";
11
+ import type { BranchDefinition } from "./types.js";
12
+ import { isEqualRefsToResolve } from "./utils.js";
13
+
14
+ interface CacheEntry {
15
+ subscriptionScope: SubscriptionScope<any>;
16
+ schema: CoValueClassOrSchema;
17
+ resolve: RefsToResolve<any>;
18
+ branch?: BranchDefinition;
19
+ subscriberCount: number;
20
+ cleanupTimeoutId?: ReturnType<typeof setTimeout>;
21
+ unsubscribeFromScope: () => void;
22
+ }
23
+
24
+ export class SubscriptionCache {
25
+ // Nested cache: outer map keyed by id, inner set of CacheEntry
26
+ private cache: Map<string, Set<CacheEntry>>;
27
+ private cleanupTimeout: number;
28
+
29
+ constructor(cleanupTimeout: number = 5000) {
30
+ this.cache = new Map();
31
+ this.cleanupTimeout = cleanupTimeout;
32
+ }
33
+
34
+ /**
35
+ * Get the inner set for a given id (read-only access)
36
+ */
37
+ private getIdSet(id: string): Set<CacheEntry> | undefined {
38
+ return this.cache.get(id);
39
+ }
40
+
41
+ /**
42
+ * Get the inner set for a given id, creating it if it doesn't exist
43
+ */
44
+ private getIdSetOrCreate(id: string): Set<CacheEntry> {
45
+ let idSet = this.cache.get(id);
46
+ if (!idSet) {
47
+ idSet = new Set();
48
+ this.cache.set(id, idSet);
49
+ }
50
+ return idSet;
51
+ }
52
+
53
+ /**
54
+ * Check if an entry matches the provided parameters
55
+ */
56
+ private matchesEntry(
57
+ entry: CacheEntry,
58
+ schema: CoValueClassOrSchema,
59
+ resolve: RefsToResolve<any>,
60
+ branch?: BranchDefinition,
61
+ ): boolean {
62
+ // Compare schema by object identity
63
+ if (entry.schema !== schema) {
64
+ return false;
65
+ }
66
+
67
+ // Compare resolve queries using isEqualRefsToResolve
68
+ if (!isEqualRefsToResolve(entry.resolve, resolve)) {
69
+ return false;
70
+ }
71
+
72
+ // Compare branch names by string equality
73
+ const branchName = branch?.name;
74
+ if (entry.branch?.name !== branchName) {
75
+ return false;
76
+ }
77
+
78
+ // Compare branch owner ids by string equality
79
+ const branchOwnerId = branch?.owner?.$jazz.id;
80
+ if (entry.branch?.owner?.$jazz.id !== branchOwnerId) {
81
+ return false;
82
+ }
83
+
84
+ return true;
85
+ }
86
+
87
+ /**
88
+ * Find a matching cache entry by comparing against entry properties
89
+ * Uses id-based nesting to quickly filter candidates
90
+ */
91
+ private findMatchingEntry(
92
+ schema: CoValueClassOrSchema,
93
+ id: string,
94
+ resolve: RefsToResolve<any>,
95
+ branch?: BranchDefinition,
96
+ ): CacheEntry | undefined {
97
+ // Get the inner set for this id (quick filter)
98
+ const idSet = this.getIdSet(id);
99
+ if (!idSet) {
100
+ return undefined;
101
+ }
102
+
103
+ // Search only within entries for this id
104
+ for (const entry of idSet) {
105
+ if (this.matchesEntry(entry, schema, resolve, branch)) {
106
+ return entry;
107
+ }
108
+ }
109
+
110
+ return undefined;
111
+ }
112
+
113
+ /**
114
+ * Handle subscriber count changes from SubscriptionScope
115
+ */
116
+ private handleSubscriberChange(entry: CacheEntry, count: number): void {
117
+ entry.subscriberCount = count;
118
+
119
+ if (count === 0) {
120
+ // Schedule cleanup when subscriber count reaches zero
121
+ this.scheduleCleanup(entry);
122
+ } else {
123
+ // Cancel cleanup if count increases from zero
124
+ this.cancelCleanup(entry);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Schedule cleanup timeout for an entry
130
+ */
131
+ private scheduleCleanup(entry: CacheEntry): void {
132
+ // Cancel any existing cleanup timeout
133
+ this.cancelCleanup(entry);
134
+
135
+ entry.cleanupTimeoutId = setTimeout(() => {
136
+ this.destroyEntry(entry);
137
+ }, this.cleanupTimeout);
138
+ }
139
+
140
+ /**
141
+ * Cancel pending cleanup timeout for an entry
142
+ */
143
+ private cancelCleanup(entry: CacheEntry): void {
144
+ if (entry.cleanupTimeoutId !== undefined) {
145
+ clearTimeout(entry.cleanupTimeoutId);
146
+ entry.cleanupTimeoutId = undefined;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Destroy a cache entry and its SubscriptionScope
152
+ */
153
+ private destroyEntry(entry: CacheEntry): void {
154
+ // Cancel any pending cleanup
155
+ this.cancelCleanup(entry);
156
+
157
+ // Unsubscribe from subscriber changes
158
+ entry.unsubscribeFromScope();
159
+
160
+ // Destroy the SubscriptionScope
161
+ try {
162
+ entry.subscriptionScope.destroy();
163
+ } catch (error) {
164
+ // Log error but don't throw - we still want to remove the entry
165
+ console.error("Error destroying SubscriptionScope:", error);
166
+ }
167
+
168
+ // Remove from nested cache structure
169
+ const id = entry.subscriptionScope.id;
170
+ const idSet = this.getIdSet(id);
171
+ if (idSet) {
172
+ idSet.delete(entry);
173
+ // Clean up empty inner set to prevent memory leaks
174
+ if (idSet.size === 0) {
175
+ this.cache.delete(id);
176
+ }
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Get or create a SubscriptionScope from the cache
182
+ */
183
+ getOrCreate<S extends CoValueClassOrSchema>(
184
+ node: LocalNode,
185
+ schema: S,
186
+ id: string,
187
+ resolve: ResolveQuery<S>,
188
+ skipRetry?: boolean,
189
+ bestEffortResolution?: boolean,
190
+ branch?: BranchDefinition,
191
+ ): SubscriptionScope<CoValue> {
192
+ // Handle undefined/null id case
193
+ if (!id) {
194
+ throw new Error("Cannot create subscription with undefined or null id");
195
+ }
196
+
197
+ // Search for matching entry
198
+ const matchingEntry = this.findMatchingEntry(schema, id, resolve, branch);
199
+
200
+ if (matchingEntry) {
201
+ // Found existing entry - cancel any pending cleanup since we're reusing it
202
+ this.cancelCleanup(matchingEntry);
203
+
204
+ return matchingEntry.subscriptionScope as SubscriptionScope<CoValue>;
205
+ }
206
+
207
+ // Create new SubscriptionScope
208
+ // Transform schema to RefEncoded format
209
+ const refEncoded: RefEncoded<CoValue> = {
210
+ ref: coValueClassFromCoValueClassOrSchema(schema) as any,
211
+ optional: true,
212
+ };
213
+
214
+ // Create new SubscriptionScope with all required parameters
215
+ const subscriptionScope = new SubscriptionScope<CoValue>(
216
+ node,
217
+ // @ts-expect-error the SubscriptionScope is too generic for TS to infer its instances are CoValues
218
+ resolve,
219
+ id,
220
+ refEncoded,
221
+ skipRetry ?? false,
222
+ bestEffortResolution ?? false,
223
+ branch,
224
+ );
225
+
226
+ const handleSubscriberChange = (count: number) => {
227
+ const idSet = this.getIdSet(id);
228
+ if (idSet && idSet.has(entry)) {
229
+ this.handleSubscriberChange(entry, count);
230
+ }
231
+ };
232
+
233
+ // Create cache entry with initial subscriber count (starts at 0)
234
+ const entry: CacheEntry = {
235
+ subscriptionScope,
236
+ schema,
237
+ resolve,
238
+ branch,
239
+ subscriberCount: subscriptionScope.subscribers.size,
240
+ unsubscribeFromScope: subscriptionScope.onSubscriberChange(
241
+ handleSubscriberChange,
242
+ ),
243
+ };
244
+
245
+ // Store in nested cache structure
246
+ const idSet = this.getIdSetOrCreate(id);
247
+ idSet.add(entry);
248
+
249
+ return subscriptionScope;
250
+ }
251
+
252
+ /**
253
+ * Clear all cache entries and destroy all SubscriptionScope instances
254
+ */
255
+ clear(): void {
256
+ // Collect all entries first to avoid iteration issues during deletion
257
+ const entriesToDestroy: CacheEntry[] = [];
258
+ for (const idSet of this.cache.values()) {
259
+ for (const entry of idSet) {
260
+ entriesToDestroy.push(entry);
261
+ }
262
+ }
263
+
264
+ // Destroy all entries
265
+ for (const entry of entriesToDestroy) {
266
+ this.destroyEntry(entry);
267
+ }
268
+
269
+ // Clear the cache map (should already be empty, but ensure it)
270
+ this.cache.clear();
271
+ }
272
+ }
@@ -1,4 +1,4 @@
1
- import type { LocalNode, RawCoValue } from "cojson";
1
+ import { LocalNode, RawCoValue } from "cojson";
2
2
  import {
3
3
  CoFeed,
4
4
  CoList,
@@ -23,11 +23,18 @@ import type {
23
23
  SubscriptionValueLoading,
24
24
  } from "./types.js";
25
25
  import { CoValueLoadingState, NotLoadedCoValueState } from "./types.js";
26
- import { createCoValue, myRoleForRawValue } from "./utils.js";
27
26
  import {
28
27
  captureError,
29
28
  isCustomErrorReportingEnabled,
30
29
  } from "./errorReporting.js";
30
+ import {
31
+ createCoValue,
32
+ isEqualRefsToResolve,
33
+ myRoleForRawValue,
34
+ PromiseWithStatus,
35
+ rejectedPromise,
36
+ resolvedPromise,
37
+ } from "./utils.js";
31
38
 
32
39
  export class SubscriptionScope<D extends CoValue> {
33
40
  childNodes = new Map<string, SubscriptionScope<CoValue>>();
@@ -88,6 +95,7 @@ export class SubscriptionScope<D extends CoValue> {
88
95
  | RawCoValue
89
96
  | typeof CoValueLoadingState.UNAVAILABLE
90
97
  | undefined;
98
+
91
99
  this.subscription = new CoValueCoreSubscription(
92
100
  node,
93
101
  id,
@@ -301,6 +309,66 @@ export class SubscriptionScope<D extends CoValue> {
301
309
 
302
310
  unloadedValue: NotLoaded<D> | undefined;
303
311
 
312
+ lastPromise:
313
+ | {
314
+ value: MaybeLoaded<D> | undefined;
315
+ promise: PromiseWithStatus<MaybeLoaded<D>>;
316
+ }
317
+ | undefined;
318
+
319
+ cachePromise(value: MaybeLoaded<D>, callback: () => PromiseWithStatus<D>) {
320
+ if (this.lastPromise?.value === value) {
321
+ return this.lastPromise.promise;
322
+ }
323
+
324
+ const promise = callback();
325
+ this.lastPromise = { value, promise };
326
+
327
+ return promise;
328
+ }
329
+
330
+ getPromise() {
331
+ const currentValue = this.getCurrentValue();
332
+
333
+ if (currentValue.$isLoaded) {
334
+ return resolvedPromise(currentValue);
335
+ }
336
+
337
+ if (currentValue.$jazz.loadingState !== CoValueLoadingState.LOADING) {
338
+ const error = this.getError();
339
+ return rejectedPromise(
340
+ new Error(error?.toString() ?? "Unknown error", {
341
+ cause: this.callerStack,
342
+ }),
343
+ );
344
+ }
345
+
346
+ // We need to cache rejected promises to make React Suspense happy
347
+ return this.cachePromise(currentValue, () => {
348
+ return new Promise<D>((resolve, reject) => {
349
+ const unsubscribe = this.subscribe(() => {
350
+ const currentValue = this.getCurrentValue();
351
+
352
+ if (currentValue.$jazz.loadingState === CoValueLoadingState.LOADING) {
353
+ return;
354
+ }
355
+
356
+ if (currentValue.$isLoaded) {
357
+ resolve(currentValue);
358
+ } else {
359
+ reject(
360
+ new Error(this.getError()?.toString() ?? "Unknown error", {
361
+ cause: this.callerStack,
362
+ }),
363
+ );
364
+ }
365
+
366
+ unsubscribe();
367
+ });
368
+ });
369
+ });
370
+ }
371
+
304
372
  private getUnloadedValue(reason: NotLoadedCoValueState): NotLoaded<D> {
305
373
  if (this.unloadedValue?.$jazz.loadingState === reason) {
306
374
  return this.unloadedValue;
@@ -384,19 +452,21 @@ export class SubscriptionScope<D extends CoValue> {
384
452
  return result;
385
453
  }
386
454
 
387
- logError() {
388
- let error: JazzError | undefined;
389
-
455
+ getError() {
390
456
  if (
391
457
  this.value.type === CoValueLoadingState.UNAUTHORIZED ||
392
458
  this.value.type === CoValueLoadingState.UNAVAILABLE
393
459
  ) {
394
- error = this.value;
460
+ return this.value;
395
461
  }
396
462
 
397
463
  if (this.errorFromChildren) {
398
- error = this.errorFromChildren;
464
+ return this.errorFromChildren;
399
465
  }
466
+ }
467
+
468
+ logError() {
469
+ const error = this.getError();
400
470
 
401
471
  if (!error || this.lastErrorLogged === error) {
402
472
  return;
@@ -437,16 +507,45 @@ export class SubscriptionScope<D extends CoValue> {
437
507
  }
438
508
 
439
509
  subscribers = new Set<(value: SubscriptionValue<D, any>) => void>();
510
+ subscriberChangeCallbacks = new Set<(count: number) => void>();
511
+
512
+ /**
513
+ * Subscribe to subscriber count changes
514
+ * Callback receives the total number of subscribers
515
+ * Returns an unsubscribe function
516
+ */
517
+ onSubscriberChange(callback: (count: number) => void): () => void {
518
+ this.subscriberChangeCallbacks.add(callback);
519
+
520
+ return () => {
521
+ this.subscriberChangeCallbacks.delete(callback);
522
+ };
523
+ }
524
+
525
+ private notifySubscriberChange() {
526
+ const count = this.subscribers.size;
527
+ this.subscriberChangeCallbacks.forEach((callback) => {
528
+ callback(count);
529
+ });
530
+ }
531
+
440
532
  subscribe(listener: (value: SubscriptionValue<D, any>) => void) {
441
533
  this.subscribers.add(listener);
534
+ this.notifySubscriberChange();
442
535
 
443
536
  return () => {
444
537
  this.subscribers.delete(listener);
538
+ this.notifySubscriberChange();
445
539
  };
446
540
  }
447
541
 
448
542
  setListener(listener: (value: SubscriptionValue<D, any>) => void) {
543
+ const hadListener = this.subscribers.has(listener);
449
544
  this.subscribers.add(listener);
545
+ // Only notify if this is a new listener (count actually changed)
546
+ if (!hadListener) {
547
+ this.notifySubscriberChange();
548
+ }
450
549
  this.triggerUpdate();
451
550
  }
452
551
 
@@ -835,7 +934,14 @@ export class SubscriptionScope<D extends CoValue> {
835
934
  this.closed = true;
836
935
 
837
936
  this.subscription.unsubscribe();
937
+ const hadSubscribers = this.subscribers.size > 0;
838
938
  this.subscribers.clear();
939
+ // Notify callbacks that subscriber count is now 0 if there were subscribers before
940
+ if (hadSubscribers) {
941
+ this.notifySubscriberChange();
942
+ }
943
+ // Clear subscriber change callbacks to prevent memory leaks
944
+ this.subscriberChangeCallbacks.clear();
839
945
  this.childNodes.forEach((child) => child.destroy());
840
946
  }
841
947
  }
@@ -3,6 +3,7 @@ import { RegisteredSchemas } from "../coValues/registeredSchemas.js";
3
3
  import {
4
4
  CoValue,
5
5
  RefEncoded,
6
+ RefsToResolve,
6
7
  accountOrGroupToGroup,
7
8
  instantiateRefEncodedFromRaw,
8
9
  } from "../internal.js";
@@ -42,3 +43,79 @@ export function createCoValue<D extends CoValue>(
42
43
  id: subscriptionScope.id,
43
44
  };
44
45
  }
46
+
47
+ export type PromiseWithStatus<T> = PromiseLike<T> & {
48
+ status?: "pending" | "fulfilled" | "rejected";
49
+ value?: T;
50
+ reason?: unknown;
51
+ };
52
+
53
+ export function resolvedPromise<T>(value: T): PromiseWithStatus<T> {
54
+ const promise = Promise.resolve(value) as PromiseWithStatus<T>;
55
+ promise.status = "fulfilled";
56
+ promise.value = value;
57
+ return promise;
58
+ }
59
+
60
+ export function rejectedPromise<T>(reason: unknown): PromiseWithStatus<T> {
61
+ const promise = Promise.reject(reason) as PromiseWithStatus<T>;
62
+ promise.status = "rejected";
63
+ promise.reason = reason;
64
+ return promise;
65
+ }
66
+
67
+ export function isEqualRefsToResolve(
68
+ a: RefsToResolve<any>,
69
+ b: RefsToResolve<any>,
70
+ ) {
71
+ // Fast path: same reference
72
+ if (a === b) {
73
+ return true;
74
+ }
75
+
76
+ // Fast path: both are boolean
77
+ if (typeof a === "boolean" && typeof b === "boolean") {
78
+ return a === b;
79
+ }
80
+
81
+ // One is boolean, the other is not
82
+ if (typeof a === "boolean" || typeof b === "boolean") {
83
+ return false;
84
+ }
85
+
86
+ // Both must be objects at this point
87
+ if (
88
+ typeof a !== "object" ||
89
+ typeof b !== "object" ||
90
+ a === null ||
91
+ b === null
92
+ ) {
93
+ return false;
94
+ }
95
+
96
+ // Get all keys from both objects
97
+ const keysA = Object.keys(a);
98
+ const keysB = Object.keys(b);
99
+
100
+ // Different number of keys means not equal
101
+ if (keysA.length !== keysB.length) {
102
+ return false;
103
+ }
104
+
105
+ // Check each key
106
+ for (const key of keysA) {
107
+ if (!(key in b)) {
108
+ return false;
109
+ }
110
+
111
+ const valueA = (a as any)[key];
112
+ const valueB = (b as any)[key];
113
+
114
+ // Recursively compare nested RefsToResolve values
115
+ if (!isEqualRefsToResolve(valueA, valueB)) {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ return true;
121
+ }
@@ -16,9 +16,6 @@ import {
16
16
  coValueClassFromCoValueClassOrSchema,
17
17
  createAnonymousJazzContext,
18
18
  createJazzContext,
19
- CoValue,
20
- LoadedAndRequired,
21
- MaybeLoaded,
22
19
  randomSessionProvider,
23
20
  } from "./internal.js";
24
21
 
@@ -14,6 +14,7 @@ import {
14
14
  setupJazzTestSync,
15
15
  } from "../testing.js";
16
16
  import { waitFor } from "../tests/utils.js";
17
+ import { cojsonInternals } from "cojson";
17
18
 
18
19
  beforeEach(async () => {
19
20
  await setupJazzTestSync();
@@ -25,6 +26,8 @@ beforeEach(async () => {
25
26
  });
26
27
  });
27
28
 
29
+ cojsonInternals.setCoValueLoadingRetryDelay(10);
30
+
28
31
  describe("CoValueCoreSubscription", async () => {
29
32
  /**
30
33
  * Tests scenarios where the CoValue is immediately available
@@ -444,7 +447,7 @@ describe("CoValueCoreSubscription", async () => {
444
447
  });
445
448
 
446
449
  describe("error handling scenarios", () => {
447
- test("should handle return unavailable when the id is invalid", async () => {
450
+ test("should synchronously emit unavailable when an invalid id is provided", async () => {
448
451
  const bob = await createJazzTestAccount();
449
452
  const invalidId = "invalid-co-value-id";
450
453
 
@@ -461,13 +464,50 @@ describe("CoValueCoreSubscription", async () => {
461
464
  },
462
465
  );
463
466
 
464
- // Should not call listener immediately since ID is invalid
465
- expect(listener).not.toHaveBeenCalled();
467
+ // Should call listener synchronously since ID is invalid (doesn't start with "co_z")
468
+ expect(listener).toHaveBeenCalledTimes(1);
469
+ expect(lastResult).toBe(CoValueLoadingState.UNAVAILABLE);
470
+
471
+ subscription.unsubscribe();
472
+ });
473
+
474
+ test("should emit unavailable when subscribing to a CoValue that has already been marked as unavailable", async () => {
475
+ const Person = co.map({
476
+ name: z.string(),
477
+ age: z.number(),
478
+ });
479
+
480
+ // Create a person that bob can access
481
+ const person = Person.create(
482
+ { name: "John", age: 30 },
483
+ Group.create().makePublic("writer"),
484
+ );
485
+
486
+ // Create a new sync server, this way the CoValue is not available for new accounts
487
+ await setupJazzTestSync();
488
+ const bob = await createJazzTestAccount();
489
+
490
+ // Try to load it first, to mark the value as unavailable
491
+ await Person.load(person.$jazz.id, {
492
+ loadAs: bob,
493
+ });
494
+
495
+ let lastResult: any = null;
496
+ const listener = vi.fn();
497
+
498
+ // Subscribe to the unavailable CoValue
499
+ const subscription = new CoValueCoreSubscription(
500
+ bob.$jazz.localNode,
501
+ person.$jazz.id,
502
+ (result) => {
503
+ lastResult = result;
504
+ listener(result);
505
+ },
506
+ );
466
507
 
467
- // Wait for the error handling to complete
468
508
  await waitFor(() => expect(listener).toHaveBeenCalled());
469
509
 
470
- // Should report unavailable when loading fails
510
+ expect(listener).toHaveBeenCalledTimes(1);
471
511
  expect(lastResult).toBe(CoValueLoadingState.UNAVAILABLE);
472
512
 
473
513
  subscription.unsubscribe();
@@ -492,13 +532,7 @@ describe("CoValueCoreSubscription", async () => {
492
532
  { name: "main", owner: bob },
493
533
  );
494
534
 
495
- // Should not call listener immediately since ID is invalid
496
- expect(listener).not.toHaveBeenCalled();
497
-
498
- // Wait for the error handling to complete
499
- await waitFor(() => expect(listener).toHaveBeenCalled());
500
-
501
- // Should report unavailable when loading fails
535
+ expect(listener).toHaveBeenCalledTimes(1);
502
536
  expect(lastResult).toBe(CoValueLoadingState.UNAVAILABLE);
503
537
 
504
538
  subscription.unsubscribe();