jazz-tools 0.18.6 → 0.18.7

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 (84) hide show
  1. package/.turbo/turbo-build.log +49 -49
  2. package/CHANGELOG.md +17 -0
  3. package/dist/better-auth/auth/server.d.ts.map +1 -1
  4. package/dist/better-auth/auth/server.js +8 -4
  5. package/dist/better-auth/auth/server.js.map +1 -1
  6. package/dist/{chunk-45VKEOXG.js → chunk-CFAY3FMQ.js} +70 -21
  7. package/dist/chunk-CFAY3FMQ.js.map +1 -0
  8. package/dist/index.js +1 -1
  9. package/dist/inspector/{custom-element-IBHKHN27.js → custom-element-G6SPZEBR.js} +292 -31
  10. package/dist/inspector/custom-element-G6SPZEBR.js.map +1 -0
  11. package/dist/inspector/index.d.ts +1 -1
  12. package/dist/inspector/index.js +302 -41
  13. package/dist/inspector/index.js.map +1 -1
  14. package/dist/inspector/register-custom-element.js +1 -1
  15. package/dist/inspector/ui/button.d.ts +1 -1
  16. package/dist/inspector/ui/button.d.ts.map +1 -1
  17. package/dist/inspector/ui/heading.d.ts +2 -1
  18. package/dist/inspector/ui/heading.d.ts.map +1 -1
  19. package/dist/inspector/ui/input.d.ts.map +1 -1
  20. package/dist/inspector/ui/modal.d.ts +16 -0
  21. package/dist/inspector/ui/modal.d.ts.map +1 -0
  22. package/dist/inspector/viewer/delete-local-data.d.ts +2 -0
  23. package/dist/inspector/viewer/delete-local-data.d.ts.map +1 -0
  24. package/dist/inspector/viewer/{inpsector-button.d.ts → inspector-button.d.ts} +1 -1
  25. package/dist/inspector/viewer/{inpsector-button.d.ts.map → inspector-button.d.ts.map} +1 -1
  26. package/dist/inspector/viewer/new-app.d.ts +1 -1
  27. package/dist/inspector/viewer/new-app.d.ts.map +1 -1
  28. package/dist/react/hooks.d.ts +1 -1
  29. package/dist/react/hooks.d.ts.map +1 -1
  30. package/dist/react/index.d.ts +1 -1
  31. package/dist/react/index.d.ts.map +1 -1
  32. package/dist/react/index.js +3 -1
  33. package/dist/react/index.js.map +1 -1
  34. package/dist/react-core/hooks.d.ts +133 -0
  35. package/dist/react-core/hooks.d.ts.map +1 -1
  36. package/dist/react-core/index.js +80 -16
  37. package/dist/react-core/index.js.map +1 -1
  38. package/dist/react-core/tests/useCoStateWithSelector.test.d.ts +2 -0
  39. package/dist/react-core/tests/useCoStateWithSelector.test.d.ts.map +1 -0
  40. package/dist/react-native-core/hooks.d.ts +1 -1
  41. package/dist/react-native-core/hooks.d.ts.map +1 -1
  42. package/dist/react-native-core/index.js +3 -1
  43. package/dist/react-native-core/index.js.map +1 -1
  44. package/dist/testing.js +1 -1
  45. package/dist/tools/coValues/CoValueBase.d.ts +14 -0
  46. package/dist/tools/coValues/CoValueBase.d.ts.map +1 -1
  47. package/dist/tools/coValues/coMap.d.ts +0 -12
  48. package/dist/tools/coValues/coMap.d.ts.map +1 -1
  49. package/dist/tools/implementation/createContext.d.ts +2 -1
  50. package/dist/tools/implementation/createContext.d.ts.map +1 -1
  51. package/dist/tools/tests/utils.d.ts.map +1 -1
  52. package/dist/worker/index.d.ts +4 -0
  53. package/dist/worker/index.d.ts.map +1 -1
  54. package/dist/worker/index.js +4 -2
  55. package/dist/worker/index.js.map +1 -1
  56. package/package.json +6 -4
  57. package/src/better-auth/auth/server.ts +8 -4
  58. package/src/better-auth/auth/tests/server.test.ts +2 -2
  59. package/src/inspector/index.tsx +1 -1
  60. package/src/inspector/ui/button.tsx +15 -1
  61. package/src/inspector/ui/heading.tsx +7 -2
  62. package/src/inspector/ui/input.tsx +6 -2
  63. package/src/inspector/ui/modal.tsx +158 -0
  64. package/src/inspector/viewer/delete-local-data.tsx +101 -0
  65. package/src/inspector/viewer/new-app.tsx +3 -1
  66. package/src/react/hooks.tsx +1 -0
  67. package/src/react/index.ts +1 -0
  68. package/src/react-core/hooks.ts +162 -0
  69. package/src/react-core/tests/useCoStateWithSelector.test.ts +149 -0
  70. package/src/react-native-core/hooks.tsx +1 -0
  71. package/src/tools/coValues/CoValueBase.ts +32 -0
  72. package/src/tools/coValues/coList.ts +35 -0
  73. package/src/tools/coValues/coMap.ts +0 -18
  74. package/src/tools/implementation/createContext.ts +9 -2
  75. package/src/tools/tests/coList.test.ts +41 -0
  76. package/src/tools/tests/coPlainText.test.ts +24 -0
  77. package/src/tools/tests/createContext.test.ts +24 -0
  78. package/src/tools/tests/deepLoading.test.ts +2 -0
  79. package/src/tools/tests/patterns/requestToJoin.test.ts +14 -6
  80. package/src/tools/tests/utils.ts +1 -0
  81. package/src/worker/index.ts +6 -0
  82. package/dist/chunk-45VKEOXG.js.map +0 -1
  83. package/dist/inspector/custom-element-IBHKHN27.js.map +0 -1
  84. /package/src/inspector/viewer/{inpsector-button.tsx → inspector-button.tsx} +0 -0
@@ -10,6 +10,11 @@ const StyledHeading = styled("h1")<{ className?: string }>`
10
10
  export function Heading({
11
11
  children,
12
12
  className,
13
- }: React.PropsWithChildren<{ className?: string }>) {
14
- return <StyledHeading className={className}>{children}</StyledHeading>;
13
+ id,
14
+ }: React.PropsWithChildren<{ className?: string; id?: string }>) {
15
+ return (
16
+ <StyledHeading className={className} id={id}>
17
+ {children}
18
+ </StyledHeading>
19
+ );
15
20
  }
@@ -27,7 +27,7 @@ const StyledInput = styled("input")`
27
27
  box-shadow: var(--j-shadow-sm);
28
28
  font-weight: 500;
29
29
  background-color: white;
30
- color: var(--text-color-strong);
30
+ color: var(--j-text-color-strong);
31
31
 
32
32
  @media (prefers-color-scheme: dark) {
33
33
  background-color: var(--j-foreground);
@@ -41,7 +41,11 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
41
41
 
42
42
  return (
43
43
  <Container className={className}>
44
- <label htmlFor={id} className={hideLabel ? "j-sr-only" : ""}>
44
+ <label
45
+ htmlFor={id}
46
+ className={hideLabel ? "j-sr-only" : ""}
47
+ style={{ color: "var(--j-text-color)" }}
48
+ >
45
49
  {label}
46
50
  </label>
47
51
  <StyledInput ref={ref} {...inputProps} id={id} />
@@ -0,0 +1,158 @@
1
+ import { styled } from "goober";
2
+ import { forwardRef, useEffect, useRef } from "react";
3
+ import { Button } from "./button.js";
4
+ import { Heading } from "./heading.js";
5
+
6
+ interface ModalProps {
7
+ isOpen: boolean;
8
+ onClose: () => void;
9
+ heading: string;
10
+ text?: string;
11
+ children?: React.ReactNode;
12
+ confirmText?: string;
13
+ cancelText?: string;
14
+ onConfirm?: () => void;
15
+ onCancel?: () => void;
16
+ showButtons?: boolean;
17
+ className?: string;
18
+ }
19
+
20
+ const ModalContent = styled("dialog")`
21
+ background-color: var(--j-background);
22
+ border-radius: var(--j-radius-lg);
23
+ box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
24
+ border: 1px solid var(--j-border-color);
25
+ max-width: 32rem;
26
+ margin-block: auto;
27
+ margin-inline: auto;
28
+ &::backdrop {
29
+ background-color: rgba(0, 0, 0, 0.7);
30
+ }
31
+
32
+ `;
33
+
34
+ const ModalHeader = styled("div")`
35
+ display: flex;
36
+ justify-content: space-between;
37
+ align-items: flex-start;
38
+ padding: 1.5rem 1.5rem 0 1.5rem;
39
+ gap: 1rem;
40
+ `;
41
+
42
+ const ModalBody = styled("div")`
43
+ padding: 1rem 1.5rem;
44
+ flex: 1;
45
+ `;
46
+
47
+ const ModalFooter = styled("div")`
48
+ display: flex;
49
+ justify-content: flex-end;
50
+ gap: 0.75rem;
51
+ padding: 0 1.5rem 1.5rem 1.5rem;
52
+ `;
53
+
54
+ const CloseButton = styled("button")`
55
+ background: none;
56
+ border: none;
57
+ cursor: pointer;
58
+ padding: 0.25rem;
59
+ border-radius: var(--j-radius-sm);
60
+ color: var(--j-text-color);
61
+ font-size: 1.25rem;
62
+ line-height: 1;
63
+ display: flex;
64
+ align-items: center;
65
+ justify-content: center;
66
+ min-width: 2rem;
67
+ min-height: 2rem;
68
+
69
+ &:hover {
70
+ background-color: var(--j-foreground);
71
+ }
72
+
73
+ &:focus-visible {
74
+ outline: 2px solid var(--j-border-focus);
75
+ outline-offset: 2px;
76
+ }
77
+ `;
78
+
79
+ export const Modal = forwardRef<HTMLDialogElement, ModalProps>(
80
+ (
81
+ {
82
+ isOpen,
83
+ onClose,
84
+ heading,
85
+ text,
86
+ children,
87
+ confirmText = "Confirm",
88
+ cancelText = "Cancel",
89
+ onConfirm,
90
+ onCancel,
91
+ showButtons = true,
92
+ className,
93
+ },
94
+ ref,
95
+ ) => {
96
+ const modalRef = useRef<HTMLDialogElement>(null);
97
+
98
+ useEffect(() => {
99
+ if (isOpen) {
100
+ modalRef.current?.showModal();
101
+ } else {
102
+ onClose();
103
+ modalRef.current?.close();
104
+ }
105
+ }, [isOpen, onClose]);
106
+
107
+ const handleConfirm = () => {
108
+ onConfirm?.();
109
+ onClose();
110
+ };
111
+
112
+ const handleCancel = () => {
113
+ onCancel?.();
114
+ onClose();
115
+ };
116
+
117
+ if (!isOpen) return null;
118
+
119
+ return (
120
+ <ModalContent
121
+ ref={ref || modalRef}
122
+ className={className}
123
+ role="dialog"
124
+ aria-labelledby="modal-heading"
125
+ onClose={onClose}
126
+ >
127
+ <ModalHeader>
128
+ <Heading id="modal-heading">{heading}</Heading>
129
+ <CloseButton onClick={onClose} aria-label="Close modal" type="button">
130
+ ×
131
+ </CloseButton>
132
+ </ModalHeader>
133
+
134
+ <ModalBody>
135
+ {text && (
136
+ <p style={{ margin: "0 0 1rem 0", color: "var(--j-text-color)" }}>
137
+ {text}
138
+ </p>
139
+ )}
140
+ {children}
141
+ </ModalBody>
142
+
143
+ {showButtons && (
144
+ <ModalFooter>
145
+ <Button variant="secondary" onClick={handleCancel}>
146
+ {cancelText}
147
+ </Button>
148
+ <Button variant="primary" onClick={handleConfirm}>
149
+ {confirmText}
150
+ </Button>
151
+ </ModalFooter>
152
+ )}
153
+ </ModalContent>
154
+ );
155
+ },
156
+ );
157
+
158
+ Modal.displayName = "Modal";
@@ -0,0 +1,101 @@
1
+ import { Button } from "../ui/button.js";
2
+ import { Modal } from "../ui/modal.js";
3
+ import { Input } from "../ui/input.js";
4
+ import { useState } from "react";
5
+
6
+ const DELETE_LOCAL_DATA_STRING = "delete my local data";
7
+
8
+ export function DeleteLocalData() {
9
+ const [showDeleteModal, setShowDeleteModal] = useState(false);
10
+ const [confirmDeleteString, setConfirmDeleteString] = useState("");
11
+
12
+ return (
13
+ <>
14
+ <Button variant="destructive" onClick={() => setShowDeleteModal(true)}>
15
+ Delete my local data
16
+ </Button>
17
+ <Modal
18
+ isOpen={showDeleteModal}
19
+ onClose={() => setShowDeleteModal(false)}
20
+ heading="Delete Local Data"
21
+ showButtons={false}
22
+ >
23
+ <div
24
+ style={{
25
+ margin: "0 0 1rem 0",
26
+ color: "var(--j-text-color)",
27
+ display: "flex",
28
+ flexDirection: "column",
29
+ gap: "0.5rem",
30
+ }}
31
+ >
32
+ <p>
33
+ This action <strong>cannot</strong> be undone.
34
+ </p>
35
+ <p>
36
+ Be aware that the following data will be{" "}
37
+ <strong>permanently</strong> deleted:
38
+ </p>
39
+ <ul style={{ listStyleType: "disc", paddingLeft: "1rem" }}>
40
+ <li>
41
+ Unsynced data for <strong>all apps</strong> on{" "}
42
+ <code>{window.location.origin}</code>
43
+ </li>
44
+ <li>Accounts</li>
45
+ <li>Logged in sessions</li>
46
+ </ul>
47
+ <p></p>
48
+ </div>
49
+ <Input
50
+ label={`Type "${DELETE_LOCAL_DATA_STRING}" to confirm`}
51
+ placeholder={DELETE_LOCAL_DATA_STRING}
52
+ value={confirmDeleteString}
53
+ onChange={(e) => {
54
+ setConfirmDeleteString(e.target.value);
55
+ }}
56
+ />
57
+ <p
58
+ style={{
59
+ margin: "0 0 1rem 0",
60
+ color: "var(--j-text-color)",
61
+ display: "flex",
62
+ flexDirection: "column",
63
+ gap: "0.5rem",
64
+ }}
65
+ >
66
+ <small>
67
+ Data synced to a sync server will <strong>not</strong> be deleted,
68
+ and will be synced when you log in again.
69
+ </small>
70
+ </p>
71
+ <div
72
+ style={{
73
+ display: "flex",
74
+ marginTop: "0.5rem",
75
+ justifyContent: "flex-end",
76
+ gap: "0.5rem",
77
+ }}
78
+ >
79
+ <Button variant="secondary" onClick={() => setShowDeleteModal(false)}>
80
+ Cancel
81
+ </Button>
82
+ <Button
83
+ variant="destructive"
84
+ disabled={confirmDeleteString !== DELETE_LOCAL_DATA_STRING}
85
+ onClick={() => {
86
+ const jazzKeys = Object.keys(localStorage).filter(
87
+ (key) => key.startsWith("jazz-") || key.startsWith("co_z"),
88
+ );
89
+ jazzKeys.forEach((key) => localStorage.removeItem(key));
90
+ indexedDB.deleteDatabase("jazz-storage");
91
+ window.location.reload();
92
+ setShowDeleteModal(false);
93
+ }}
94
+ >
95
+ I'm sure, delete my local data
96
+ </Button>
97
+ </div>
98
+ </Modal>
99
+ </>
100
+ );
101
+ }
@@ -9,8 +9,9 @@ import { usePagePath } from "./use-page-path.js";
9
9
 
10
10
  import { GlobalStyles } from "../ui/global-styles.js";
11
11
  import { Heading } from "../ui/heading.js";
12
- import { InspectorButton, type Position } from "./inpsector-button.js";
12
+ import { InspectorButton, type Position } from "./inspector-button.js";
13
13
  import { useOpenInspector } from "./use-open-inspector.js";
14
+ import { DeleteLocalData } from "./delete-local-data.js";
14
15
 
15
16
  const InspectorContainer = styled("div")`
16
17
  position: fixed;
@@ -102,6 +103,7 @@ export function JazzInspectorInternal({
102
103
  />
103
104
  )}
104
105
  </Form>
106
+ <DeleteLocalData />
105
107
  <Button variant="plain" type="button" onClick={() => setOpen(false)}>
106
108
  Close
107
109
  </Button>
@@ -50,4 +50,5 @@ export {
50
50
  experimental_useInboxSender,
51
51
  useJazzContext,
52
52
  useAccount,
53
+ useCoStateWithSelector,
53
54
  } from "jazz-tools/react-core";
@@ -7,6 +7,7 @@ export {
7
7
  experimental_useInboxSender,
8
8
  useJazzContext,
9
9
  useAuthSecretStorage,
10
+ useCoStateWithSelector,
10
11
  } from "./hooks.js";
11
12
 
12
13
  export { createInviteLink, parseInviteLink } from "jazz-tools/browser";
@@ -1,3 +1,4 @@
1
+ import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
1
2
  import React, {
2
3
  useCallback,
3
4
  useContext,
@@ -261,6 +262,167 @@ export function useCoState<
261
262
  return value;
262
263
  }
263
264
 
265
+ /**
266
+ * React hook for subscribing to CoValues with selective data extraction and custom equality checking.
267
+ *
268
+ * This hook extends `useCoState` by allowing you to select only specific parts of the CoValue data
269
+ * through a selector function, which helps reduce unnecessary re-renders by narrowing down the
270
+ * returned data. Additionally, you can provide a custom equality function to further optimize
271
+ * performance by controlling when the component should re-render based on the selected data.
272
+ *
273
+ * The hook automatically handles the subscription lifecycle and supports deep loading of nested
274
+ * CoValues through resolve queries, just like `useCoState`.
275
+ *
276
+ * @returns The result of the selector function applied to the loaded CoValue data
277
+ *
278
+ * @example
279
+ * ```tsx
280
+ * // Select only specific fields to reduce re-renders
281
+ * const Project = co.map({
282
+ * name: z.string(),
283
+ * description: z.string(),
284
+ * tasks: co.list(Task),
285
+ * lastModified: z.date(),
286
+ * });
287
+ *
288
+ * function ProjectTitle({ projectId }: { projectId: string }) {
289
+ * // Only re-render when the project name changes, not other fields
290
+ * const projectName = useCoStateWithSelector(
291
+ * Project,
292
+ * projectId,
293
+ * {
294
+ * select: (project) => project?.name ?? "Loading...",
295
+ * }
296
+ * );
297
+ *
298
+ * return <h1>{projectName}</h1>;
299
+ * }
300
+ * ```
301
+ *
302
+ * @example
303
+ * ```tsx
304
+ * // Use custom equality function for complex data structures
305
+ * const TaskList = co.list(Task);
306
+ *
307
+ * function TaskCount({ listId }: { listId: string }) {
308
+ * const taskStats = useCoStateWithSelector(
309
+ * TaskList,
310
+ * listId,
311
+ * {
312
+ * resolve: { $each: true },
313
+ * select: (tasks) => {
314
+ * if (!tasks) return { total: 0, completed: 0 };
315
+ * return {
316
+ * total: tasks.length,
317
+ * completed: tasks.filter(task => task.completed).length,
318
+ * };
319
+ * },
320
+ * // Custom equality to prevent re-renders when stats haven't changed
321
+ * equalityFn: (a, b) => a.total === b.total && a.completed === b.completed,
322
+ * }
323
+ * );
324
+ *
325
+ * return (
326
+ * <div>
327
+ * {taskStats.completed} of {taskStats.total} tasks completed
328
+ * </div>
329
+ * );
330
+ * }
331
+ * ```
332
+ *
333
+ * @example
334
+ * ```tsx
335
+ * // Combine with deep loading and complex selectors
336
+ * const Team = co.map({
337
+ * name: z.string(),
338
+ * members: co.list(TeamMember),
339
+ * projects: co.list(Project),
340
+ * });
341
+ *
342
+ * function TeamSummary({ teamId }: { teamId: string }) {
343
+ * const summary = useCoStateWithSelector(
344
+ * Team,
345
+ * teamId,
346
+ * {
347
+ * resolve: {
348
+ * members: { $each: true },
349
+ * projects: { $each: { tasks: { $each: true } } },
350
+ * },
351
+ * select: (team) => {
352
+ * if (!team) return null;
353
+ *
354
+ * const totalTasks = team.projects.reduce(
355
+ * (sum, project) => sum + project.tasks.length,
356
+ * 0
357
+ * );
358
+ *
359
+ * return {
360
+ * teamName: team.name,
361
+ * memberCount: team.members.length,
362
+ * projectCount: team.projects.length,
363
+ * totalTasks,
364
+ * };
365
+ * },
366
+ * }
367
+ * );
368
+ *
369
+ * if (!summary) return <div>Loading team summary...</div>;
370
+ *
371
+ * return (
372
+ * <div>
373
+ * <h2>{summary.teamName}</h2>
374
+ * <p>{summary.memberCount} members</p>
375
+ * <p>{summary.projectCount} projects</p>
376
+ * <p>{summary.totalTasks} total tasks</p>
377
+ * </div>
378
+ * );
379
+ * }
380
+ * ```
381
+ *
382
+ * For more examples, see the [subscription and deep loading](https://jazz.tools/docs/react/using-covalues/subscription-and-loading) documentation.
383
+ */
384
+ export function useCoStateWithSelector<
385
+ S extends CoValueClassOrSchema,
386
+ TSelectorReturn,
387
+ const R extends ResolveQuery<S> = true,
388
+ >(
389
+ /** The CoValue schema or class constructor */
390
+ Schema: S,
391
+ /** The ID of the CoValue to subscribe to. If `undefined`, returns the result of selector called with `null` */
392
+ id: string | undefined,
393
+ /** Optional configuration for the subscription */
394
+ options: {
395
+ /** Resolve query to specify which nested CoValues to load */
396
+ resolve?: ResolveQueryStrict<S, R>;
397
+ /** Select which value to return */
398
+ select: (value: Loaded<S, R> | undefined | null) => TSelectorReturn;
399
+ /** Equality function to determine if the selected value has changed, defaults to `Object.is` */
400
+ equalityFn?: (a: TSelectorReturn, b: TSelectorReturn) => boolean;
401
+ },
402
+ ): TSelectorReturn {
403
+ const subscription = useCoValueSubscription(Schema, id, options);
404
+
405
+ return useSyncExternalStoreWithSelector<
406
+ Loaded<S, R> | undefined | null,
407
+ TSelectorReturn
408
+ >(
409
+ React.useCallback(
410
+ (callback) => {
411
+ if (!subscription) {
412
+ return () => {};
413
+ }
414
+
415
+ return subscription.subscribe(callback);
416
+ },
417
+ [subscription],
418
+ ),
419
+ () => (subscription ? subscription.getCurrentValue() : null),
420
+ () => (subscription ? subscription.getCurrentValue() : null),
421
+ options.select,
422
+ options.equalityFn ?? Object.is,
423
+ );
424
+ }
425
+
264
426
  function useAccountSubscription<
265
427
  S extends AccountClass<Account> | AnyAccountSchema,
266
428
  const R extends ResolveQuery<S>,
@@ -0,0 +1,149 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { cojsonInternals } from "cojson";
4
+ import { Account, co, Loaded, z } from "jazz-tools";
5
+ import { beforeEach, describe, expect, expectTypeOf, it } from "vitest";
6
+ import { useCoStateWithSelector } from "../index.js";
7
+ import { createJazzTestAccount, setupJazzTestSync } from "../testing.js";
8
+ import { renderHook, waitFor } from "./testUtils.js";
9
+ import { useRef } from "react";
10
+
11
+ beforeEach(async () => {
12
+ await setupJazzTestSync();
13
+
14
+ await createJazzTestAccount({
15
+ isCurrentActiveAccount: true,
16
+ });
17
+ });
18
+
19
+ cojsonInternals.setCoValueLoadingRetryDelay(300);
20
+
21
+ const useRenderCount = <T>(hook: () => T) => {
22
+ const renderCountRef = useRef(0);
23
+ const result = hook();
24
+ renderCountRef.current = renderCountRef.current + 1;
25
+ return {
26
+ renderCount: renderCountRef.current,
27
+ result,
28
+ };
29
+ };
30
+
31
+ describe("useCoStateWithSelector", () => {
32
+ it("should not re-render when a nested coValue is updated and not selected", async () => {
33
+ const TestMap = co.map({
34
+ value: z.string(),
35
+ get nested() {
36
+ return TestMap.optional();
37
+ },
38
+ });
39
+
40
+ const map = TestMap.create({
41
+ value: "1",
42
+ nested: TestMap.create({
43
+ value: "1",
44
+ }),
45
+ });
46
+
47
+ const { result } = renderHook(() =>
48
+ useRenderCount(() =>
49
+ useCoStateWithSelector(TestMap, map.$jazz.id, {
50
+ resolve: {
51
+ nested: true,
52
+ },
53
+ select: (v) => v?.value,
54
+ }),
55
+ ),
56
+ );
57
+
58
+ await waitFor(() => {
59
+ expect(result.current.result).not.toBeUndefined();
60
+ });
61
+
62
+ for (let i = 0; i < 100; i++) {
63
+ map.nested!.$jazz.set("value", `${i}`);
64
+ await Account.getMe().$jazz.waitForAllCoValuesSync();
65
+ }
66
+
67
+ expect(result.current.result).toEqual("1");
68
+ expect(result.current.renderCount).toEqual(1);
69
+ });
70
+
71
+ it("should re-render when a nested coValue is updated and selected", async () => {
72
+ const TestMap = co.map({
73
+ value: z.string(),
74
+ get nested() {
75
+ return TestMap.optional();
76
+ },
77
+ });
78
+
79
+ const map = TestMap.create({
80
+ value: "1",
81
+ nested: TestMap.create({
82
+ value: "1",
83
+ }),
84
+ });
85
+
86
+ const { result } = renderHook(() =>
87
+ useRenderCount(() =>
88
+ useCoStateWithSelector(TestMap, map.$jazz.id, {
89
+ resolve: {
90
+ nested: true,
91
+ },
92
+ select: (v) => v?.nested?.value,
93
+ }),
94
+ ),
95
+ );
96
+
97
+ await waitFor(() => {
98
+ expect(result.current.result).not.toBeUndefined();
99
+ });
100
+
101
+ for (let i = 1; i <= 100; i++) {
102
+ map.nested!.$jazz.set("value", `${i}`);
103
+ await Account.getMe().$jazz.waitForAllCoValuesSync();
104
+ }
105
+
106
+ expect(result.current.result).toEqual("100");
107
+
108
+ // skips re-render on i = 1, only re-renders on i = [2,100], so initial render + 99 renders = 100
109
+ expect(result.current.renderCount).toEqual(100);
110
+
111
+ expectTypeOf(result.current.result).toEqualTypeOf<string | undefined>();
112
+ });
113
+
114
+ it("should not re-render when equalityFn always returns true", async () => {
115
+ const TestMap = co.map({
116
+ value: z.string(),
117
+ get nested() {
118
+ return TestMap.optional();
119
+ },
120
+ });
121
+
122
+ const map = TestMap.create({
123
+ value: "1",
124
+ nested: TestMap.create({
125
+ value: "1",
126
+ }),
127
+ });
128
+
129
+ const { result } = renderHook(() =>
130
+ useRenderCount(() =>
131
+ useCoStateWithSelector(TestMap, map.$jazz.id, {
132
+ resolve: {
133
+ nested: true,
134
+ },
135
+ select: (v) => v?.nested?.value,
136
+ equalityFn: () => true,
137
+ }),
138
+ ),
139
+ );
140
+
141
+ for (let i = 1; i <= 100; i++) {
142
+ map.nested!.$jazz.set("value", `${i}`);
143
+ await Account.getMe().$jazz.waitForAllCoValuesSync();
144
+ }
145
+
146
+ expect(result.current.result).toEqual("1");
147
+ expect(result.current.renderCount).toEqual(1);
148
+ });
149
+ });
@@ -13,6 +13,7 @@ export {
13
13
  useAuthSecretStorage,
14
14
  useIsAuthenticated,
15
15
  useAccount,
16
+ useCoStateWithSelector,
16
17
  } from "jazz-tools/react-core";
17
18
 
18
19
  export function useAcceptInviteNative<S extends CoValueClassOrSchema>({
@@ -77,4 +77,36 @@ export abstract class CoValueJazzApi<V extends CoValue> {
77
77
 
78
78
  return new AnonymousJazzAgent(this.localNode);
79
79
  }
80
+
81
+ /**
82
+ * The timestamp of the creation time of the CoValue
83
+ *
84
+ * @category Content
85
+ */
86
+ get createdAt(): number {
87
+ const createdAt = this.raw.core.verified.header.meta?.createdAt;
88
+
89
+ if (typeof createdAt === "string") {
90
+ return new Date(createdAt).getTime();
91
+ }
92
+
93
+ return this.raw.core.earliestTxMadeAt;
94
+ }
95
+
96
+ /**
97
+ * The timestamp of the last updated time of the CoValue
98
+ *
99
+ * Returns the creation time if there are no updates.
100
+ *
101
+ * @category Content
102
+ */
103
+ get lastUpdatedAt(): number {
104
+ const value = this.raw.core.latestTxMadeAt;
105
+
106
+ if (value === 0) {
107
+ return this.createdAt;
108
+ }
109
+
110
+ return value;
111
+ }
80
112
  }