jazz-tools 0.18.8 → 0.18.10

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 (37) hide show
  1. package/.turbo/turbo-build.log +42 -42
  2. package/CHANGELOG.md +20 -0
  3. package/dist/better-auth/auth/client.d.ts +1 -1
  4. package/dist/better-auth/auth/client.d.ts.map +1 -1
  5. package/dist/better-auth/auth/client.js.map +1 -1
  6. package/dist/{chunk-QF3R3C4N.js → chunk-RQHJFPIB.js} +56 -25
  7. package/dist/{chunk-QF3R3C4N.js.map → chunk-RQHJFPIB.js.map} +1 -1
  8. package/dist/index.js +1 -1
  9. package/dist/react/hooks.d.ts +1 -1
  10. package/dist/react/hooks.d.ts.map +1 -1
  11. package/dist/react/index.d.ts +1 -1
  12. package/dist/react/index.d.ts.map +1 -1
  13. package/dist/react/index.js +3 -1
  14. package/dist/react/index.js.map +1 -1
  15. package/dist/react-core/hooks.d.ts +56 -0
  16. package/dist/react-core/hooks.d.ts.map +1 -1
  17. package/dist/react-core/index.js +20 -0
  18. package/dist/react-core/index.js.map +1 -1
  19. package/dist/react-core/tests/useAccountWithSelector.test.d.ts +2 -0
  20. package/dist/react-core/tests/useAccountWithSelector.test.d.ts.map +1 -0
  21. package/dist/react-native-core/hooks.d.ts +1 -1
  22. package/dist/react-native-core/hooks.d.ts.map +1 -1
  23. package/dist/react-native-core/index.js +3 -1
  24. package/dist/react-native-core/index.js.map +1 -1
  25. package/dist/testing.js +1 -1
  26. package/dist/tools/implementation/ContextManager.d.ts +2 -0
  27. package/dist/tools/implementation/ContextManager.d.ts.map +1 -1
  28. package/package.json +4 -4
  29. package/src/better-auth/auth/client.ts +1 -1
  30. package/src/better-auth/auth/tests/client.test.ts +229 -0
  31. package/src/react/hooks.tsx +1 -0
  32. package/src/react/index.ts +1 -0
  33. package/src/react-core/hooks.ts +84 -0
  34. package/src/react-core/tests/useAccountWithSelector.test.ts +411 -0
  35. package/src/react-native-core/hooks.tsx +1 -0
  36. package/src/tools/implementation/ContextManager.ts +75 -32
  37. package/src/tools/tests/ContextManager.test.ts +252 -0
@@ -580,6 +580,90 @@ export function useAccount<
580
580
  };
581
581
  }
582
582
 
583
+ /**
584
+ * React hook for accessing the current user's account with selective data extraction and custom equality checking.
585
+ *
586
+ * This hook extends `useAccount` by allowing you to select only specific parts of the account data
587
+ * through a selector function, which helps reduce unnecessary re-renders by narrowing down the
588
+ * returned data. Additionally, you can provide a custom equality function to further optimize
589
+ * performance by controlling when the component should re-render based on the selected data.
590
+ *
591
+ * The hook automatically handles the subscription lifecycle and supports deep loading of nested
592
+ * CoValues through resolve queries, just like `useAccount`.
593
+ *
594
+ * @returns The result of the selector function applied to the loaded account data
595
+ *
596
+ * @example
597
+ * ```tsx
598
+ * // Select only specific fields to reduce re-renders
599
+ * const MyAppAccount = co.account({
600
+ * profile: co.profile(),
601
+ * root: co.map({
602
+ * name: z.string(),
603
+ * email: z.string(),
604
+ * lastLogin: z.date(),
605
+ * }),
606
+ * });
607
+ *
608
+ * function UserProfile({ accountId }: { accountId: string }) {
609
+ * // Only re-render when the profile name changes, not other fields
610
+ * const profileName = useAccountWithSelector(
611
+ * MyAppAccount,
612
+ * {
613
+ * resolve: {
614
+ * profile: true,
615
+ * root: true,
616
+ * },
617
+ * select: (account) => account?.profile?.name ?? "Loading...",
618
+ * }
619
+ * );
620
+ *
621
+ * return <h1>{profileName}</h1>;
622
+ * }
623
+ * ```
624
+ *
625
+ * For more examples, see the [subscription and deep loading](https://jazz.tools/docs/react/using-covalues/subscription-and-loading) documentation.
626
+ */
627
+ export function useAccountWithSelector<
628
+ A extends AccountClass<Account> | AnyAccountSchema,
629
+ TSelectorReturn,
630
+ R extends ResolveQuery<A> = true,
631
+ >(
632
+ /** The account schema to use. Defaults to the base Account schema */
633
+ AccountSchema: A = Account as unknown as A,
634
+ /** Configuration for the subscription and selection */
635
+ options: {
636
+ /** Resolve query to specify which nested CoValues to load from the account */
637
+ resolve?: ResolveQueryStrict<A, R>;
638
+ /** Select which value to return from the account data */
639
+ select: (account: Loaded<A, R> | undefined | null) => TSelectorReturn;
640
+ /** Equality function to determine if the selected value has changed, defaults to `Object.is` */
641
+ equalityFn?: (a: TSelectorReturn, b: TSelectorReturn) => boolean;
642
+ },
643
+ ): TSelectorReturn {
644
+ const subscription = useAccountSubscription(AccountSchema, options);
645
+
646
+ return useSyncExternalStoreWithSelector<
647
+ Loaded<A, R> | undefined | null,
648
+ TSelectorReturn
649
+ >(
650
+ React.useCallback(
651
+ (callback) => {
652
+ if (!subscription) {
653
+ return () => {};
654
+ }
655
+
656
+ return subscription.subscribe(callback);
657
+ },
658
+ [subscription],
659
+ ),
660
+ () => (subscription ? subscription.getCurrentValue() : null),
661
+ () => (subscription ? subscription.getCurrentValue() : null),
662
+ options.select,
663
+ options.equalityFn ?? Object.is,
664
+ );
665
+ }
666
+
583
667
  export function experimental_useInboxSender<
584
668
  I extends CoValue,
585
669
  O extends CoValue | undefined,
@@ -0,0 +1,411 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { Account, RefsToResolve, co, z } from "jazz-tools";
4
+ import { beforeEach, describe, expect, it } from "vitest";
5
+ import { useAccountWithSelector, useJazzContextManager } from "../hooks.js";
6
+ import { useIsAuthenticated } from "../index.js";
7
+ import {
8
+ createJazzTestAccount,
9
+ createJazzTestGuest,
10
+ setupJazzTestSync,
11
+ } from "../testing.js";
12
+ import { act, renderHook } from "./testUtils.js";
13
+ import { useRef } from "react";
14
+
15
+ beforeEach(async () => {
16
+ await setupJazzTestSync();
17
+ });
18
+
19
+ const useRenderCount = <T>(hook: () => T) => {
20
+ const renderCountRef = useRef(0);
21
+ const result = hook();
22
+ renderCountRef.current = renderCountRef.current + 1;
23
+ return {
24
+ renderCount: renderCountRef.current,
25
+ result,
26
+ };
27
+ };
28
+
29
+ describe("useAccountWithSelector", () => {
30
+ it("should return the correct selected value", async () => {
31
+ const AccountRoot = co.map({
32
+ value: z.string(),
33
+ });
34
+
35
+ const AccountSchema = co
36
+ .account({
37
+ root: AccountRoot,
38
+ profile: co.profile(),
39
+ })
40
+ .withMigration((account, creationProps) => {
41
+ if (!account.$jazz.refs.root) {
42
+ account.$jazz.set("root", { value: "123" });
43
+ }
44
+ });
45
+
46
+ const account = await createJazzTestAccount({
47
+ AccountSchema,
48
+ });
49
+
50
+ const { result } = renderHook(
51
+ () =>
52
+ useAccountWithSelector(AccountSchema, {
53
+ resolve: {
54
+ root: true,
55
+ },
56
+ select: (account) => account?.root.value ?? "Loading...",
57
+ }),
58
+ {
59
+ account,
60
+ },
61
+ );
62
+
63
+ expect(result.current).toBe("123");
64
+ });
65
+
66
+ it("should load nested values if requested", async () => {
67
+ const AccountRoot = co.map({
68
+ value: z.string(),
69
+ nested: co.map({
70
+ nestedValue: z.string(),
71
+ }),
72
+ });
73
+
74
+ const AccountSchema = co
75
+ .account({
76
+ root: AccountRoot,
77
+ profile: co.profile(),
78
+ })
79
+ .withMigration((account, creationProps) => {
80
+ if (!account.$jazz.refs.root) {
81
+ const root = AccountRoot.create({
82
+ value: "123",
83
+ nested: co
84
+ .map({
85
+ nestedValue: z.string(),
86
+ })
87
+ .create({
88
+ nestedValue: "456",
89
+ }),
90
+ });
91
+ account.$jazz.set("root", root);
92
+ }
93
+ });
94
+
95
+ const account = await createJazzTestAccount({
96
+ AccountSchema,
97
+ });
98
+
99
+ const { result } = renderHook(
100
+ () =>
101
+ useAccountWithSelector(AccountSchema, {
102
+ resolve: {
103
+ root: {
104
+ nested: true,
105
+ },
106
+ },
107
+ select: (account) => account?.root.nested.nestedValue ?? "Loading...",
108
+ }),
109
+ {
110
+ account,
111
+ },
112
+ );
113
+
114
+ expect(result.current).toBe("456");
115
+ });
116
+
117
+ it("should not re-render when a nested coValue is updated and not selected", async () => {
118
+ const AccountRoot = co.map({
119
+ value: z.string(),
120
+ get nested() {
121
+ return co
122
+ .map({
123
+ nestedValue: z.string(),
124
+ })
125
+ .optional();
126
+ },
127
+ });
128
+
129
+ const AccountSchema = co
130
+ .account({
131
+ root: AccountRoot,
132
+ profile: co.profile(),
133
+ })
134
+ .withMigration((account, creationProps) => {
135
+ if (!account.$jazz.refs.root) {
136
+ const root = AccountRoot.create({
137
+ value: "1",
138
+ nested: co
139
+ .map({
140
+ nestedValue: z.string(),
141
+ })
142
+ .create({
143
+ nestedValue: "1",
144
+ }),
145
+ });
146
+ account.$jazz.set("root", root);
147
+ }
148
+ });
149
+
150
+ const account = await createJazzTestAccount({
151
+ AccountSchema,
152
+ });
153
+
154
+ const { result } = renderHook(
155
+ () =>
156
+ useRenderCount(() =>
157
+ useAccountWithSelector(AccountSchema, {
158
+ resolve: {
159
+ root: {
160
+ nested: true,
161
+ },
162
+ },
163
+ select: (account) => account?.root.value ?? "Loading...",
164
+ }),
165
+ ),
166
+ {
167
+ account,
168
+ },
169
+ );
170
+
171
+ await act(async () => {
172
+ // Update nested value that is not selected
173
+ account.root.nested?.$jazz.set("nestedValue", "100");
174
+ await account.$jazz.waitForAllCoValuesSync();
175
+ });
176
+
177
+ expect(result.current.result).toEqual("1");
178
+ expect(result.current.renderCount).toEqual(1);
179
+ });
180
+
181
+ it("should re-render when a nested coValue is updated and selected", async () => {
182
+ const AccountRoot = co.map({
183
+ value: z.string(),
184
+ get nested() {
185
+ return co
186
+ .map({
187
+ nestedValue: z.string(),
188
+ })
189
+ .optional();
190
+ },
191
+ });
192
+
193
+ const AccountSchema = co
194
+ .account({
195
+ root: AccountRoot,
196
+ profile: co.profile(),
197
+ })
198
+ .withMigration((account, creationProps) => {
199
+ if (!account.$jazz.refs.root) {
200
+ const root = AccountRoot.create({
201
+ value: "1",
202
+ nested: co
203
+ .map({
204
+ nestedValue: z.string(),
205
+ })
206
+ .create({
207
+ nestedValue: "1",
208
+ }),
209
+ });
210
+ account.$jazz.set("root", root);
211
+ }
212
+ });
213
+
214
+ const account = await createJazzTestAccount({
215
+ AccountSchema,
216
+ });
217
+
218
+ const { result } = renderHook(
219
+ () =>
220
+ useRenderCount(() =>
221
+ useAccountWithSelector(AccountSchema, {
222
+ resolve: {
223
+ root: {
224
+ nested: true,
225
+ },
226
+ },
227
+ select: (account) =>
228
+ account?.root?.nested?.nestedValue ?? "Loading...",
229
+ }),
230
+ ),
231
+ {
232
+ account,
233
+ },
234
+ );
235
+
236
+ await act(async () => {
237
+ // Update nested value that is selected
238
+ account.root?.nested?.$jazz.set("nestedValue", "100");
239
+ await account.$jazz.waitForAllCoValuesSync();
240
+ });
241
+
242
+ expect(result.current.result).toEqual("100");
243
+ expect(result.current.renderCount).toEqual(2); // Initial render + update
244
+ });
245
+
246
+ it("should not re-render when equalityFn always returns true", async () => {
247
+ const AccountRoot = co.map({
248
+ value: z.string(),
249
+ get nested() {
250
+ return co
251
+ .map({
252
+ nestedValue: z.string(),
253
+ })
254
+ .optional();
255
+ },
256
+ });
257
+
258
+ const AccountSchema = co
259
+ .account({
260
+ root: AccountRoot,
261
+ profile: co.profile(),
262
+ })
263
+ .withMigration((account, creationProps) => {
264
+ if (!account.$jazz.refs.root) {
265
+ const root = AccountRoot.create({
266
+ value: "1",
267
+ nested: co
268
+ .map({
269
+ nestedValue: z.string(),
270
+ })
271
+ .create({
272
+ nestedValue: "1",
273
+ }),
274
+ });
275
+ account.$jazz.set("root", root);
276
+ }
277
+ });
278
+
279
+ const account = await createJazzTestAccount({
280
+ AccountSchema,
281
+ });
282
+
283
+ const { result } = renderHook(
284
+ () =>
285
+ useRenderCount(() =>
286
+ useAccountWithSelector(AccountSchema, {
287
+ resolve: {
288
+ root: {
289
+ nested: true,
290
+ },
291
+ },
292
+ select: (account) =>
293
+ account?.root?.nested?.nestedValue ?? "Loading...",
294
+ equalityFn: () => true, // Always return true to prevent re-renders
295
+ }),
296
+ ),
297
+ {
298
+ account,
299
+ },
300
+ );
301
+
302
+ await act(async () => {
303
+ // Update nested value that is selected
304
+ account.root?.nested?.$jazz.set("nestedValue", "100");
305
+ await account.$jazz.waitForAllCoValuesSync();
306
+ });
307
+
308
+ expect(result.current.result).toEqual("1"); // Should still be "1" due to equalityFn
309
+ expect(result.current.renderCount).toEqual(1); // Should not re-render
310
+ });
311
+
312
+ it("should not load nested values if the account is a guest", async () => {
313
+ const AccountRoot = co.map({
314
+ value: z.string(),
315
+ });
316
+
317
+ const AccountSchema = co
318
+ .account({
319
+ root: AccountRoot,
320
+ profile: co.profile(),
321
+ })
322
+ .withMigration((account, creationProps) => {
323
+ if (!account.$jazz.refs.root) {
324
+ account.$jazz.set("root", { value: "123" });
325
+ }
326
+ });
327
+
328
+ const account = await createJazzTestGuest();
329
+
330
+ const { result } = renderHook(
331
+ () =>
332
+ useAccountWithSelector(AccountSchema, {
333
+ resolve: {
334
+ root: true,
335
+ },
336
+ select: (account) => account?.root?.value ?? "Guest",
337
+ }),
338
+ {
339
+ account,
340
+ },
341
+ );
342
+
343
+ expect(result.current).toBe("Guest");
344
+ });
345
+
346
+ it("should handle undefined account gracefully", async () => {
347
+ const account = await createJazzTestGuest();
348
+
349
+ const { result } = renderHook(
350
+ () =>
351
+ useAccountWithSelector(Account, {
352
+ select: (account) => account?.$jazz.id ?? "No account",
353
+ }),
354
+ {
355
+ account,
356
+ },
357
+ );
358
+
359
+ expect(result.current).toBe("No account");
360
+ });
361
+
362
+ it("should re-render when selector result changes due to external prop changes", async () => {
363
+ const AccountRoot = co.map({
364
+ value: z.string(),
365
+ });
366
+
367
+ const AccountSchema = co
368
+ .account({
369
+ root: AccountRoot,
370
+ profile: co.profile(),
371
+ })
372
+ .withMigration((account, creationProps) => {
373
+ if (!account.$jazz.refs.root) {
374
+ account.$jazz.set("root", { value: "initial" });
375
+ }
376
+ });
377
+
378
+ const account = await createJazzTestAccount({
379
+ AccountSchema,
380
+ });
381
+
382
+ let externalProp = "suffix1";
383
+
384
+ const { result, rerender } = renderHook(
385
+ () =>
386
+ useRenderCount(() =>
387
+ useAccountWithSelector(AccountSchema, {
388
+ resolve: {
389
+ root: true,
390
+ },
391
+ select: (account) => {
392
+ const baseValue = account?.root?.value ?? "loading";
393
+ return `${baseValue}-${externalProp}`;
394
+ },
395
+ }),
396
+ ),
397
+ {
398
+ account,
399
+ },
400
+ );
401
+
402
+ expect(result.current.result).toEqual("initial-suffix1");
403
+ expect(result.current.renderCount).toEqual(1);
404
+
405
+ // Change external prop and rerender
406
+ externalProp = "suffix2";
407
+ rerender();
408
+
409
+ expect(result.current.result).toEqual("initial-suffix2");
410
+ });
411
+ });
@@ -14,6 +14,7 @@ export {
14
14
  useIsAuthenticated,
15
15
  useAccount,
16
16
  useCoStateWithSelector,
17
+ useAccountWithSelector,
17
18
  } from "jazz-tools/react-core";
18
19
 
19
20
  export function useAcceptInviteNative<S extends CoValueClassOrSchema>({
@@ -68,10 +68,9 @@ export class JazzContextManager<
68
68
  protected authSecretStorage = new AuthSecretStorage();
69
69
  protected keepContextOpen = false;
70
70
  contextPromise: Promise<void> | undefined;
71
+ protected authenticatingAccountID: string | null = null;
71
72
 
72
- constructor(opts?: {
73
- useAnonymousFallback?: boolean;
74
- }) {
73
+ constructor(opts?: { useAnonymousFallback?: boolean }) {
75
74
  KvStoreContext.getInstance().initialize(this.getKvStore());
76
75
 
77
76
  if (opts?.useAnonymousFallback) {
@@ -163,11 +162,17 @@ export class JazzContextManager<
163
162
  return this.authSecretStorage;
164
163
  }
165
164
 
165
+ getAuthenticatingAccountID() {
166
+ return this.authenticatingAccountID;
167
+ }
168
+
166
169
  logOut = async () => {
167
170
  if (!this.context || !this.props) {
168
171
  return;
169
172
  }
170
173
 
174
+ this.authenticatingAccountID = null;
175
+
171
176
  await this.props.onLogOut?.();
172
177
 
173
178
  if (this.props.logOutReplacement) {
@@ -206,17 +211,44 @@ export class JazzContextManager<
206
211
  throw new Error("Props required");
207
212
  }
208
213
 
209
- const prevContext = this.context;
210
- const migratingAnonymousAccount =
211
- await this.shouldMigrateAnonymousAccount();
214
+ if (
215
+ this.authenticatingAccountID &&
216
+ this.authenticatingAccountID === credentials.accountID
217
+ ) {
218
+ console.info(
219
+ "Authentication already in progress for account",
220
+ credentials.accountID,
221
+ "skipping duplicate request",
222
+ );
223
+ return;
224
+ }
225
+
226
+ if (
227
+ this.authenticatingAccountID &&
228
+ this.authenticatingAccountID !== credentials.accountID
229
+ ) {
230
+ throw new Error(
231
+ `Authentication already in progress for different account (${this.authenticatingAccountID}), cannot authenticate ${credentials.accountID}`,
232
+ );
233
+ }
234
+
235
+ this.authenticatingAccountID = credentials.accountID;
236
+
237
+ try {
238
+ const prevContext = this.context;
239
+ const migratingAnonymousAccount =
240
+ await this.shouldMigrateAnonymousAccount();
212
241
 
213
- this.keepContextOpen = migratingAnonymousAccount;
214
- await this.createContext(this.props, { credentials }).finally(() => {
215
- this.keepContextOpen = false;
216
- });
242
+ this.keepContextOpen = migratingAnonymousAccount;
243
+ await this.createContext(this.props, { credentials }).finally(() => {
244
+ this.keepContextOpen = false;
245
+ });
217
246
 
218
- if (migratingAnonymousAccount) {
219
- await this.handleAnonymousAccountMigration(prevContext);
247
+ if (migratingAnonymousAccount) {
248
+ await this.handleAnonymousAccountMigration(prevContext);
249
+ }
250
+ } finally {
251
+ this.authenticatingAccountID = null;
220
252
  }
221
253
  };
222
254
 
@@ -228,29 +260,40 @@ export class JazzContextManager<
228
260
  throw new Error("Props required");
229
261
  }
230
262
 
231
- const prevContext = this.context;
232
- const migratingAnonymousAccount =
233
- await this.shouldMigrateAnonymousAccount();
234
-
235
- this.keepContextOpen = migratingAnonymousAccount;
236
- await this.createContext(this.props, {
237
- newAccountProps: {
238
- secret: accountSecret,
239
- creationProps,
240
- },
241
- }).finally(() => {
242
- this.keepContextOpen = false;
243
- });
244
-
245
- if (migratingAnonymousAccount) {
246
- await this.handleAnonymousAccountMigration(prevContext);
263
+ if (this.authenticatingAccountID) {
264
+ throw new Error("Authentication already in progress");
247
265
  }
248
266
 
249
- if (this.context && "me" in this.context) {
250
- return this.context.me.$jazz.id;
251
- }
267
+ // For registration, we don't know the account ID yet, so we'll set it to "register"
268
+ this.authenticatingAccountID = "register";
252
269
 
253
- throw new Error("The registration hasn't created a new account");
270
+ try {
271
+ const prevContext = this.context;
272
+ const migratingAnonymousAccount =
273
+ await this.shouldMigrateAnonymousAccount();
274
+
275
+ this.keepContextOpen = migratingAnonymousAccount;
276
+ await this.createContext(this.props, {
277
+ newAccountProps: {
278
+ secret: accountSecret,
279
+ creationProps,
280
+ },
281
+ }).finally(() => {
282
+ this.keepContextOpen = false;
283
+ });
284
+
285
+ if (migratingAnonymousAccount) {
286
+ await this.handleAnonymousAccountMigration(prevContext);
287
+ }
288
+
289
+ if (this.context && "me" in this.context) {
290
+ return this.context.me.$jazz.id;
291
+ }
292
+
293
+ throw new Error("The registration hasn't created a new account");
294
+ } finally {
295
+ this.authenticatingAccountID = null;
296
+ }
254
297
  };
255
298
 
256
299
  private async handleAnonymousAccountMigration(