@sanity/sdk-react 3.0.0-rc.0 → 3.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/README.md +103 -155
  2. package/dist/index.d.ts +2488 -2019
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +4435 -1529
  5. package/dist/index.js.map +1 -1
  6. package/package.json +55 -59
  7. package/src/_exports/index.ts +2 -4
  8. package/src/_exports/sdk-react.ts +30 -18
  9. package/src/components/SDKProvider.test.tsx +173 -32
  10. package/src/components/SDKProvider.tsx +87 -28
  11. package/src/components/SanityApp.test.tsx +68 -12
  12. package/src/components/SanityApp.tsx +93 -87
  13. package/src/components/auth/AuthBoundary.recovery.test.tsx +86 -0
  14. package/src/components/auth/AuthBoundary.test.tsx +7 -7
  15. package/src/components/auth/AuthBoundary.tsx +29 -6
  16. package/src/components/auth/DashboardAccessRequest.tsx +37 -0
  17. package/src/components/auth/LoginError.test.tsx +191 -5
  18. package/src/components/auth/LoginError.tsx +104 -61
  19. package/src/components/errors/ChunkLoadError.test.tsx +59 -0
  20. package/src/components/errors/ChunkLoadError.tsx +56 -0
  21. package/src/components/errors/CorsErrorComponent.tsx +2 -2
  22. package/src/components/errors/chunkReloadStorage.ts +57 -0
  23. package/src/config/handles.ts +29 -27
  24. package/src/constants.ts +5 -0
  25. package/src/context/ComlinkTokenRefresh.test.tsx +10 -27
  26. package/src/context/ComlinkTokenRefresh.tsx +3 -5
  27. package/src/context/DefaultResourceContext.ts +3 -3
  28. package/src/context/OrganizationResourcesProvider.test.tsx +189 -0
  29. package/src/context/OrganizationResourcesProvider.tsx +111 -0
  30. package/src/context/PerspectiveContext.ts +3 -3
  31. package/src/context/ProjectContext.ts +15 -0
  32. package/src/context/ResourceProvider.test.tsx +93 -90
  33. package/src/context/ResourceProvider.tsx +62 -93
  34. package/src/context/SDKStudioContext.test.tsx +28 -33
  35. package/src/context/SanityInstanceProvider.test.tsx +100 -0
  36. package/src/context/SanityInstanceProvider.tsx +71 -0
  37. package/src/context/WorkbenchTokenRefresh.test.tsx +106 -0
  38. package/src/context/WorkbenchTokenRefresh.tsx +61 -0
  39. package/src/context/renderSanityApp.test.tsx +151 -49
  40. package/src/context/renderSanityApp.tsx +12 -8
  41. package/src/context/workbenchToken.ts +63 -0
  42. package/src/hooks/access/useCheckPermissions.test-d.ts +15 -0
  43. package/src/hooks/access/useCheckPermissions.test.tsx +53 -0
  44. package/src/hooks/access/useCheckPermissions.ts +24 -0
  45. package/src/hooks/agent/agentActions.test.tsx +2 -2
  46. package/src/hooks/agent/agentActions.ts +52 -72
  47. package/src/hooks/agent/useAgentResourceContext.ts +1 -1
  48. package/src/hooks/applications/useApplication.test-d.ts +30 -0
  49. package/src/hooks/applications/useApplication.ts +22 -0
  50. package/src/hooks/applications/useApplications.test-d.ts +31 -0
  51. package/src/hooks/applications/useApplications.ts +25 -0
  52. package/src/hooks/applications/useDeleteApplication.test-d.ts +12 -0
  53. package/src/hooks/applications/useDeleteApplication.ts +11 -0
  54. package/src/hooks/applications/useUpdateApplication.test-d.ts +12 -0
  55. package/src/hooks/applications/useUpdateApplication.ts +11 -0
  56. package/src/hooks/auth/useDashboardOrganizationId.test.tsx +2 -8
  57. package/src/hooks/auth/useVerifyOrgProjects.test.tsx +8 -32
  58. package/src/hooks/auth/useVerifyOrgProjects.tsx +13 -6
  59. package/src/hooks/client/useClient.test.tsx +1 -4
  60. package/src/hooks/client/useClient.ts +1 -0
  61. package/src/hooks/comlink/useFrameConnection.test.tsx +3 -2
  62. package/src/hooks/comlink/useFrameConnection.ts +1 -1
  63. package/src/hooks/comlink/useWindowConnection.test.tsx +4 -3
  64. package/src/hooks/comlink/useWindowConnection.ts +2 -3
  65. package/src/hooks/comments/useCommentActions.test.tsx +150 -0
  66. package/src/hooks/comments/useCommentActions.ts +109 -0
  67. package/src/hooks/comments/useCommentList.ts +79 -0
  68. package/src/hooks/comments/useCommentThreads.test.tsx +107 -0
  69. package/src/hooks/comments/useCommentThreads.ts +73 -0
  70. package/src/hooks/comments/useComments.test.tsx +242 -0
  71. package/src/hooks/comments/useComments.ts +59 -0
  72. package/src/hooks/context/useResource.test.tsx +32 -0
  73. package/src/hooks/context/useResource.ts +24 -0
  74. package/src/hooks/context/useSanityInstance.test.tsx +18 -1
  75. package/src/hooks/context/useSanityInstance.ts +6 -6
  76. package/src/hooks/dashboard/useManageFavorite.test.tsx +110 -46
  77. package/src/hooks/dashboard/useManageFavorite.ts +11 -17
  78. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +5 -7
  79. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +2 -6
  80. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.test.ts +0 -2
  81. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.ts +2 -2
  82. package/src/hooks/dashboard/useWindowTitle.test.ts +244 -0
  83. package/src/hooks/dashboard/useWindowTitle.ts +114 -0
  84. package/src/hooks/datasets/useDatasets.test.tsx +116 -0
  85. package/src/hooks/datasets/useDatasets.ts +33 -13
  86. package/src/hooks/document/useApplyDocumentActions.test.tsx +48 -0
  87. package/src/hooks/document/useApplyDocumentActions.ts +66 -48
  88. package/src/hooks/document/useCreateDocument.test.tsx +83 -0
  89. package/src/hooks/document/useCreateDocument.ts +117 -0
  90. package/src/hooks/document/useDocument.test.tsx +37 -8
  91. package/src/hooks/document/useDocument.ts +131 -66
  92. package/src/hooks/document/useDocumentEvent.test.tsx +16 -4
  93. package/src/hooks/document/useDocumentEvent.ts +16 -10
  94. package/src/hooks/document/useDocumentPermissions.test.tsx +8 -7
  95. package/src/hooks/document/useDocumentPermissions.ts +71 -39
  96. package/src/hooks/document/useDocumentSyncStatus.test.ts +1 -0
  97. package/src/hooks/document/useDocumentSyncStatus.ts +13 -9
  98. package/src/hooks/document/useEditDocument.test.tsx +21 -14
  99. package/src/hooks/document/useEditDocument.ts +163 -17
  100. package/src/hooks/documents/useDocuments.test.tsx +64 -44
  101. package/src/hooks/documents/useDocuments.ts +31 -20
  102. package/src/hooks/helpers/createCallbackHook.test.tsx +13 -19
  103. package/src/hooks/helpers/createFetcherHook.test.tsx +180 -0
  104. package/src/hooks/helpers/createFetcherHook.ts +69 -0
  105. package/src/hooks/helpers/createMutationHook.test.tsx +125 -0
  106. package/src/hooks/helpers/createMutationHook.tsx +93 -0
  107. package/src/hooks/helpers/createStateSourceHook.test.tsx +10 -10
  108. package/src/hooks/helpers/createStateSourceHook.tsx +2 -1
  109. package/src/hooks/{document/useApplyDocumentActions.test.ts → helpers/useApplyActions.test.tsx} +92 -53
  110. package/src/hooks/helpers/useApplyActions.ts +68 -0
  111. package/src/hooks/helpers/useNormalizedResourceOptions.test.tsx +226 -0
  112. package/src/hooks/helpers/useNormalizedResourceOptions.ts +79 -48
  113. package/src/hooks/helpers/useResolvedProjectId.test.tsx +59 -0
  114. package/src/hooks/helpers/useResolvedProjectId.ts +35 -0
  115. package/src/hooks/helpers/useTrackHookUsage.ts +37 -0
  116. package/src/hooks/installations/useInstallation.test-d.ts +19 -0
  117. package/src/hooks/installations/useInstallation.ts +22 -0
  118. package/src/hooks/installations/useInstallations.test-d.ts +26 -0
  119. package/src/hooks/installations/useInstallations.ts +25 -0
  120. package/src/hooks/organizations/useOrganization.test-d.ts +60 -0
  121. package/src/hooks/organizations/useOrganization.test.ts +63 -0
  122. package/src/hooks/organizations/useOrganization.ts +34 -0
  123. package/src/hooks/organizations/useOrganizations.test-d.ts +68 -0
  124. package/src/hooks/organizations/useOrganizations.test.ts +63 -0
  125. package/src/hooks/organizations/useOrganizations.ts +39 -0
  126. package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +34 -27
  127. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +35 -22
  128. package/src/hooks/presence/usePresence.test.tsx +0 -15
  129. package/src/hooks/presence/usePresence.ts +22 -20
  130. package/src/hooks/presence/usePresenceForDocument.test.tsx +141 -0
  131. package/src/hooks/presence/usePresenceForDocument.ts +104 -0
  132. package/src/hooks/presence/useReportPresence.test.tsx +202 -0
  133. package/src/hooks/presence/useReportPresence.ts +170 -0
  134. package/src/hooks/preview/useDocumentPreview.test.tsx +0 -1
  135. package/src/hooks/preview/useDocumentPreview.tsx +12 -14
  136. package/src/hooks/projection/useDocumentProjection.test.tsx +78 -9
  137. package/src/hooks/projection/useDocumentProjection.ts +81 -2
  138. package/src/hooks/projects/useProject.test-d.ts +56 -0
  139. package/src/hooks/projects/useProject.test.tsx +120 -0
  140. package/src/hooks/projects/useProject.ts +42 -43
  141. package/src/hooks/projects/useProjects.test-d.ts +60 -0
  142. package/src/hooks/projects/useProjects.test.ts +45 -98
  143. package/src/hooks/projects/useProjects.ts +17 -32
  144. package/src/hooks/query/useQuery.test.tsx +6 -5
  145. package/src/hooks/query/useQuery.ts +94 -17
  146. package/src/hooks/releases/useActiveReleases.test.tsx +13 -7
  147. package/src/hooks/releases/useActiveReleases.ts +28 -24
  148. package/src/hooks/releases/useAllReleases.test.tsx +93 -0
  149. package/src/hooks/releases/useAllReleases.ts +62 -0
  150. package/src/hooks/releases/useApplyReleaseActions.test.tsx +66 -0
  151. package/src/hooks/releases/useApplyReleaseActions.ts +82 -0
  152. package/src/hooks/releases/usePerspective.test.tsx +18 -10
  153. package/src/hooks/releases/usePerspective.ts +13 -17
  154. package/src/hooks/users/useUser.test.tsx +3 -9
  155. package/src/hooks/users/useUser.ts +4 -8
  156. package/src/hooks/users/useUsers.test.tsx +103 -7
  157. package/src/hooks/users/useUsers.ts +38 -5
  158. package/src/utils/resolveOrgResources.test.ts +111 -0
  159. package/src/utils/resolveOrgResources.ts +69 -0
  160. package/src/hooks/context/useDefaultResource.test.tsx +0 -25
  161. package/src/hooks/context/useDefaultResource.ts +0 -30
  162. package/src/hooks/dashboard/useDispatchIntent.test.ts +0 -254
  163. package/src/hooks/dashboard/useDispatchIntent.ts +0 -157
  164. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.test.ts +0 -107
  165. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +0 -39
  166. package/src/hooks/datasets/useDatasets.test.ts +0 -66
  167. package/src/hooks/helpers/useNormalizedResourceOptions.test.ts +0 -65
  168. package/src/hooks/projects/useProject.test.ts +0 -79
@@ -0,0 +1,104 @@
1
+ import {type DocumentPresence, getDocumentPresence, isMediaLibraryResource} from '@sanity/sdk'
2
+ import {type Path} from '@sanity/types'
3
+ import {useCallback, useMemo, useSyncExternalStore} from 'react'
4
+
5
+ import {type DocumentHandle} from '../../config/handles'
6
+ import {useSanityInstance} from '../context/useSanityInstance'
7
+ import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
8
+ import {trackHookUsage} from '../helpers/useTrackHookUsage'
9
+
10
+ /** @beta */
11
+ export interface UsePresenceForDocumentOptions extends DocumentHandle {
12
+ /**
13
+ * Narrows to participants at or below this field path, which is what a field
14
+ * indicator wants. Omit it for everyone in the document.
15
+ */
16
+ path?: Path
17
+
18
+ /**
19
+ * By default a draft, its published version, and any release versions count as
20
+ * the same document, which is what document lists want. Set this to compare ids
21
+ * exactly, so that a draft and a release version do not bleed into each other.
22
+ */
23
+ excludeVersions?: boolean
24
+ }
25
+
26
+ /**
27
+ * Who else is in a document, flattened to one entry per participant per location
28
+ * so it can be rendered straight against a field.
29
+ *
30
+ * Reading presence never announces anything. Use `useReportPresence` to make the
31
+ * current user visible to others.
32
+ *
33
+ * Prefer this over `usePresence` when you care about one document: `usePresence`
34
+ * returns every participant in the whole project and dataset, leaving the filtering
35
+ * to you.
36
+ *
37
+ * Resolves the document through its perspective exactly as `useReportPresence` does,
38
+ * so reads match writes. Participants are counted by session, so one person in two
39
+ * tabs appears twice.
40
+ *
41
+ * @example Avatars on a document
42
+ * ```tsx
43
+ * const {presence} = usePresenceForDocument({documentId, documentType})
44
+ * return presence.map((p) => <Avatar key={p.sessionId} user={p.user} />)
45
+ * ```
46
+ *
47
+ * @example Avatars on a single field
48
+ * ```tsx
49
+ * const {presence} = usePresenceForDocument({
50
+ * documentId,
51
+ * documentType,
52
+ * path: ['title'],
53
+ * excludeVersions: true,
54
+ * })
55
+ * ```
56
+ *
57
+ * @beta
58
+ */
59
+ export function usePresenceForDocument(options: UsePresenceForDocumentOptions): {
60
+ presence: DocumentPresence[]
61
+ } {
62
+ const {path, excludeVersions, ...handle} = options
63
+
64
+ const normalizedOptions = useNormalizedResourceOptions(handle)
65
+ if (normalizedOptions.resource && isMediaLibraryResource(normalizedOptions.resource)) {
66
+ throw new Error(
67
+ 'usePresenceForDocument() does not support media library resources. Presence tracking requires a canvas or dataset resource.',
68
+ )
69
+ }
70
+
71
+ const sanityInstance = useSanityInstance()
72
+ trackHookUsage(sanityInstance, 'usePresenceForDocument')
73
+
74
+ const {resource, perspective} = normalizedOptions
75
+ const {documentId, liveEdit} = options
76
+
77
+ // Serialized so a caller passing a fresh `path` array each render does not
78
+ // rebuild the state source every render.
79
+ const pathKey = useMemo(() => JSON.stringify(path ?? null), [path])
80
+
81
+ const source = useMemo(
82
+ () =>
83
+ getDocumentPresence(sanityInstance, {
84
+ ...(resource ? {resource} : {}),
85
+ documentId,
86
+ // Forwarded, not resolved here, so this matches what `useReportPresence`
87
+ // sends. Core owns turning a perspective into a specific document id.
88
+ ...(perspective ? {perspective} : {}),
89
+ ...(liveEdit ? {liveEdit} : {}),
90
+ ...(pathKey === 'null' ? {} : {path: JSON.parse(pathKey) as Path}),
91
+ ...(excludeVersions === undefined ? {} : {excludeVersions}),
92
+ }),
93
+ [sanityInstance, resource, documentId, perspective, liveEdit, pathKey, excludeVersions],
94
+ )
95
+
96
+ const subscribe = useCallback((callback: () => void) => source.subscribe(callback), [source])
97
+ const presence = useSyncExternalStore(
98
+ subscribe,
99
+ () => source.getCurrent(),
100
+ () => source.getCurrent(),
101
+ )
102
+
103
+ return {presence: presence || []}
104
+ }
@@ -0,0 +1,202 @@
1
+ import {reportPresence} from '@sanity/sdk'
2
+ import {act, renderHook} from '@testing-library/react'
3
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
4
+
5
+ import {ResourceProvider} from '../../context/ResourceProvider'
6
+ import {useReportPresence} from './useReportPresence'
7
+
8
+ vi.mock('@sanity/sdk', async (importOriginal) => {
9
+ const actual = await importOriginal<typeof import('@sanity/sdk')>()
10
+ return {
11
+ ...actual,
12
+ reportPresence: vi.fn(),
13
+ }
14
+ })
15
+
16
+ const reported = () => vi.mocked(reportPresence).mock.calls.map(([, params]) => params.locations)
17
+
18
+ const wrapper = ({children}: {children: React.ReactNode}) => (
19
+ <ResourceProvider projectId="p" dataset="d" fallback={null}>
20
+ {children}
21
+ </ResourceProvider>
22
+ )
23
+
24
+ describe('useReportPresence', () => {
25
+ beforeEach(() => {
26
+ vi.clearAllMocks()
27
+ vi.useFakeTimers()
28
+ })
29
+
30
+ afterEach(() => {
31
+ vi.useRealTimers()
32
+ })
33
+
34
+ it('reports the document with no path or selection keys when neither was given', () => {
35
+ renderHook(() => useReportPresence({documentId: 'doc-1', documentType: 'movie'}), {wrapper})
36
+
37
+ // Keys absent rather than set to `undefined`. The id stays unresolved here:
38
+ // core turns a perspective into a specific document id, so the read and write
39
+ // sides cannot drift apart.
40
+ expect(reported()).toEqual([[{documentId: 'doc-1'}]])
41
+ })
42
+
43
+ it('forwards an explicit perspective for core to resolve', () => {
44
+ renderHook(
45
+ () =>
46
+ useReportPresence({
47
+ documentId: 'doc-1',
48
+ documentType: 'movie',
49
+ perspective: {releaseName: 'autumn'},
50
+ }),
51
+ {wrapper},
52
+ )
53
+
54
+ expect(reported()[0][0]).toMatchObject({
55
+ documentId: 'doc-1',
56
+ perspective: {releaseName: 'autumn'},
57
+ })
58
+ })
59
+
60
+ it('forwards liveEdit, which core resolves to the published document', () => {
61
+ renderHook(
62
+ () => useReportPresence({documentId: 'doc-1', documentType: 'movie', liveEdit: true}),
63
+ {wrapper},
64
+ )
65
+
66
+ expect(reported()[0][0]).toMatchObject({documentId: 'doc-1', liveEdit: true})
67
+ })
68
+
69
+ it('picks up an ambient perspective from the provider', () => {
70
+ // The whole point of resolving after normalization: a perspective set once on
71
+ // `ResourceProvider` has to reach presence without every call site passing it.
72
+ const ambient = ({children}: {children: React.ReactNode}) => (
73
+ <ResourceProvider projectId="p" dataset="d" perspective="published" fallback={null}>
74
+ {children}
75
+ </ResourceProvider>
76
+ )
77
+
78
+ renderHook(() => useReportPresence({documentId: 'doc-1', documentType: 'movie'}), {
79
+ wrapper: ambient,
80
+ })
81
+
82
+ expect(reported()[0][0]).toMatchObject({documentId: 'doc-1', perspective: 'published'})
83
+ })
84
+
85
+ it('announces a field path', () => {
86
+ renderHook(
87
+ () => useReportPresence({documentId: 'doc-1', documentType: 'movie', path: ['title']}),
88
+ {wrapper},
89
+ )
90
+
91
+ expect(reported()[0][0].path).toEqual(['title'])
92
+ })
93
+
94
+ it('carries keyed segments and a selection', () => {
95
+ const path = ['body', {_key: 'b1'}, 'children', {_key: 's1'}, 'text']
96
+ const selection = {
97
+ anchor: {path: [{_key: 'b1'}, 'children', {_key: 's1'}], offset: 1},
98
+ focus: {path: [{_key: 'b1'}, 'children', {_key: 's1'}], offset: 4},
99
+ }
100
+
101
+ renderHook(
102
+ () => useReportPresence({documentId: 'doc-1', documentType: 'movie', path, selection}),
103
+ {wrapper},
104
+ )
105
+
106
+ expect(reported()[0][0].path).toEqual(path)
107
+ expect(reported()[0][0].selection).toEqual(selection)
108
+ })
109
+
110
+ it('does not re-announce when a caller passes fresh literals each render', () => {
111
+ const {rerender} = renderHook(
112
+ () =>
113
+ useReportPresence({
114
+ documentId: 'doc-1',
115
+ documentType: 'movie',
116
+ // A new array identity on every render, which is what real callers do.
117
+ path: ['title'],
118
+ }),
119
+ {wrapper},
120
+ )
121
+
122
+ rerender()
123
+ rerender()
124
+
125
+ expect(reported()).toHaveLength(1)
126
+ })
127
+
128
+ it('throttles a burst and announces the final position', () => {
129
+ const {rerender} = renderHook(
130
+ ({path}: {path: string[]}) =>
131
+ useReportPresence({documentId: 'doc-1', documentType: 'movie', path}),
132
+ {wrapper, initialProps: {path: ['a']}},
133
+ )
134
+
135
+ expect(reported()).toHaveLength(1)
136
+
137
+ rerender({path: ['b']})
138
+ rerender({path: ['c']})
139
+ expect(reported()).toHaveLength(1)
140
+
141
+ // Trailing edge, so the position the user actually settled on is the one sent.
142
+ act(() => {
143
+ vi.advanceTimersByTime(1000)
144
+ })
145
+ expect(reported()).toHaveLength(2)
146
+ expect(reported()[1][0].path).toEqual(['c'])
147
+ })
148
+
149
+ it('announces selections more often than field focus', () => {
150
+ const selectionAt = (offset: number) => ({
151
+ anchor: {path: [{_key: 'b1'}], offset},
152
+ focus: {path: [{_key: 'b1'}], offset},
153
+ })
154
+
155
+ const {rerender} = renderHook(
156
+ ({offset}: {offset: number}) =>
157
+ useReportPresence({
158
+ documentId: 'doc-1',
159
+ documentType: 'movie',
160
+ path: ['body'],
161
+ selection: selectionAt(offset),
162
+ }),
163
+ {wrapper, initialProps: {offset: 1}},
164
+ )
165
+
166
+ expect(reported()).toHaveLength(1)
167
+ rerender({offset: 2})
168
+
169
+ // Would still be pending at the 1000ms focus interval. A caret that lags a
170
+ // full second reads as broken, so selections use 250ms.
171
+ act(() => {
172
+ vi.advanceTimersByTime(250)
173
+ })
174
+ expect(reported()).toHaveLength(2)
175
+ })
176
+
177
+ it('clears its location on unmount, staying present but nowhere', () => {
178
+ const {unmount} = renderHook(
179
+ () => useReportPresence({documentId: 'doc-1', documentType: 'movie'}),
180
+ {wrapper},
181
+ )
182
+
183
+ unmount()
184
+
185
+ expect(reported().at(-1)).toEqual([])
186
+ })
187
+
188
+ it('does not clear on every location change, only on unmount', () => {
189
+ const {rerender} = renderHook(
190
+ ({path}: {path: string[]}) =>
191
+ useReportPresence({documentId: 'doc-1', documentType: 'movie', path}),
192
+ {wrapper, initialProps: {path: ['a']}},
193
+ )
194
+
195
+ rerender({path: ['b']})
196
+ act(() => {
197
+ vi.advanceTimersByTime(1000)
198
+ })
199
+
200
+ expect(reported().every((locations) => locations.length === 1)).toBe(true)
201
+ })
202
+ })
@@ -0,0 +1,170 @@
1
+ import {
2
+ isMediaLibraryResource,
3
+ type PresenceSelection,
4
+ reportPresence,
5
+ type ReportPresenceOptions,
6
+ } from '@sanity/sdk'
7
+ import {type Path} from '@sanity/types'
8
+ import {useEffect, useMemo, useRef} from 'react'
9
+
10
+ import {type DocumentHandle} from '../../config/handles'
11
+ import {useSanityInstance} from '../context/useSanityInstance'
12
+ import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
13
+ import {trackHookUsage} from '../helpers/useTrackHookUsage'
14
+
15
+ /**
16
+ * Field focus moves at human speed, so a second between announcements is plenty.
17
+ */
18
+ const FOCUS_THROTTLE_MS = 1000
19
+
20
+ /**
21
+ * A caret that trails a second behind reads as broken rather than as latency, so
22
+ * reporting a `selection` announces more often. The Studio uses a flat 1000ms for
23
+ * both, which is why its remote carets feel sluggish.
24
+ */
25
+ const SELECTION_THROTTLE_MS = 250
26
+
27
+ /** @beta */
28
+ export interface UseReportPresenceOptions extends DocumentHandle {
29
+ /**
30
+ * The focused field path. Omit it for document-level presence. Keyed and numeric
31
+ * segments are supported, so array items and Portable Text spans can be
32
+ * addressed.
33
+ */
34
+ path?: Path
35
+
36
+ /** The Portable Text caret, when the focused field is a Portable Text field. */
37
+ selection?: PresenceSelection
38
+
39
+ /** Overrides the throttle interval. Mainly useful in tests. */
40
+ throttleMs?: number
41
+ }
42
+
43
+ /**
44
+ * Announces that the current user is in a document, so that other clients in the
45
+ * same project and dataset can show them.
46
+ *
47
+ * Writing presence is opt-in. Reading it with `usePresenceForDocument` or
48
+ * `usePresence` never announces anything, and this hook is the only thing that
49
+ * makes an app visible to others. That includes the Studio, which shares the same
50
+ * presence room and will show these users in its navbar and field indicators.
51
+ *
52
+ * Announcements are throttled, collapsed over a short window, and then repeated
53
+ * every 30 seconds while the user is idle. That repeat is what tells peers the
54
+ * session is still alive, so the intended usage is to mount this hook for as long
55
+ * as the user is in the document. On unmount the location is cleared, leaving the
56
+ * user present in the app but not in any particular document.
57
+ *
58
+ * The perspective decides which specific document is reported: the draft under
59
+ * `drafts`, the published document under `published`, a version under a release.
60
+ * It is taken from `ResourceProvider` unless you pass one on the handle. That
61
+ * matters for interoperability, because the Studio's field indicators compare the
62
+ * exact id its form is on, so a mismatch shows your user at document level while
63
+ * never lighting up a field.
64
+ *
65
+ * Presence is scoped to a single project and dataset. It is not a list of everyone
66
+ * signed in to your organization.
67
+ *
68
+ * @example Document-level presence
69
+ * ```tsx
70
+ * function DocumentEditor({documentId, documentType}: DocumentHandle) {
71
+ * useReportPresence({documentId, documentType})
72
+ * return <Editor />
73
+ * }
74
+ * ```
75
+ *
76
+ * @example Presence in a release version
77
+ * ```tsx
78
+ * // The document id stays plain; the perspective selects the version.
79
+ * useReportPresence({documentId, documentType, perspective: {releaseName: 'autumn'}})
80
+ * ```
81
+ *
82
+ * @example Field-level presence
83
+ * ```tsx
84
+ * function TitleField({documentId, documentType}: DocumentHandle) {
85
+ * const [focused, setFocused] = useState(false)
86
+ * useReportPresence({documentId, documentType, path: focused ? ['title'] : undefined})
87
+ * return <input onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} />
88
+ * }
89
+ * ```
90
+ *
91
+ * @beta
92
+ */
93
+ export function useReportPresence(options: UseReportPresenceOptions): void {
94
+ const {path, selection, throttleMs, ...handle} = options
95
+
96
+ const normalizedOptions = useNormalizedResourceOptions(handle)
97
+ if (normalizedOptions.resource && isMediaLibraryResource(normalizedOptions.resource)) {
98
+ throw new Error(
99
+ 'useReportPresence() does not support media library resources. Presence tracking requires a canvas or dataset resource.',
100
+ )
101
+ }
102
+
103
+ const sanityInstance = useSanityInstance()
104
+ trackHookUsage(sanityInstance, 'useReportPresence')
105
+
106
+ const {resource, perspective} = normalizedOptions
107
+ const {documentId, liveEdit} = options
108
+
109
+ const interval = throttleMs ?? (selection ? SELECTION_THROTTLE_MS : FOCUS_THROTTLE_MS)
110
+
111
+ // Compared by value, because callers write `path={['title']}` inline and a fresh
112
+ // array identity every render must not mean a fresh announcement every render.
113
+ const locationKey = useMemo(
114
+ () =>
115
+ JSON.stringify([documentId, path ?? [], selection ?? null, perspective ?? null, liveEdit]),
116
+ [documentId, path, selection, perspective, liveEdit],
117
+ )
118
+
119
+ // Rebuilt from the key rather than from the props, so the effect below can
120
+ // depend on it honestly instead of suppressing the exhaustive-deps rule.
121
+ const location = useMemo<ReportPresenceOptions>(() => {
122
+ const [id, parsedPath, parsedSelection, parsedPerspective, parsedLiveEdit] = JSON.parse(
123
+ locationKey,
124
+ ) as [string, Path, PresenceSelection, ReportPresenceOptions['perspective'] | null, boolean?]
125
+ return {
126
+ documentId: id,
127
+ // Forwarded rather than resolved here: core turns the perspective into the
128
+ // specific document id, so the read and write sides cannot drift apart.
129
+ ...(parsedPerspective ? {perspective: parsedPerspective} : {}),
130
+ ...(parsedLiveEdit ? {liveEdit: parsedLiveEdit} : {}),
131
+ ...(parsedPath.length > 0 ? {path: parsedPath} : {}),
132
+ ...(parsedSelection ? {selection: parsedSelection} : {}),
133
+ }
134
+ }, [locationKey])
135
+
136
+ const lastSentAt = useRef(0)
137
+ const pending = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
138
+
139
+ useEffect(() => {
140
+ // Captured in this effect's closure rather than read from a ref, so nothing is
141
+ // written during render. A newer location re-runs the effect, which clears the
142
+ // pending timeout below and schedules the newer value instead.
143
+ const send = () => {
144
+ lastSentAt.current = Date.now()
145
+ reportPresence(sanityInstance, {
146
+ ...(resource ? {resource} : {}),
147
+ locations: [location],
148
+ })
149
+ }
150
+
151
+ const elapsed = Date.now() - lastSentAt.current
152
+ if (elapsed >= interval) {
153
+ send()
154
+ } else {
155
+ // Trailing edge, so the position the user settled on is the one announced.
156
+ clearTimeout(pending.current)
157
+ pending.current = setTimeout(send, interval - elapsed)
158
+ }
159
+
160
+ return () => clearTimeout(pending.current)
161
+ }, [location, interval, sanityInstance, resource])
162
+
163
+ // Kept separate from the throttled effect so it runs on unmount only, rather
164
+ // than every time the reported location changes.
165
+ useEffect(() => {
166
+ return () => {
167
+ reportPresence(sanityInstance, {...(resource ? {resource} : {}), locations: []})
168
+ }
169
+ }, [sanityInstance, resource])
170
+ }
@@ -11,7 +11,6 @@ vi.mock('../projection/useDocumentProjection')
11
11
  const mockDocument: DocumentHandle = {
12
12
  documentId: 'doc1',
13
13
  documentType: 'exampleType',
14
- resource: {projectId: 'p', dataset: 'd'},
15
14
  }
16
15
 
17
16
  describe('useDocumentPreview', () => {
@@ -1,14 +1,11 @@
1
- import {
2
- PREVIEW_PROJECTION,
3
- type PreviewQueryResult,
4
- type PreviewValue,
5
- transformProjectionToPreview,
6
- } from '@sanity/sdk'
1
+ import {type PreviewQueryResult, type PreviewValue} from '@sanity/sdk'
2
+ import {PREVIEW_PROJECTION, transformProjectionToPreview} from '@sanity/sdk/_internal'
7
3
  import {useMemo} from 'react'
8
4
 
9
5
  import {type DocumentHandle} from '../../config/handles'
10
6
  import {useSanityInstance} from '../context/useSanityInstance'
11
7
  import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
8
+ import {trackHookUsage} from '../helpers/useTrackHookUsage'
12
9
  import {useDocumentProjection} from '../projection/useDocumentProjection'
13
10
 
14
11
  /**
@@ -60,9 +57,9 @@ export interface useDocumentPreviewResults {
60
57
  *
61
58
  * @example Combining with useDocuments to render a collection of document previews
62
59
  * ```
63
- * // PreviewComponent.tsx
64
- * export default function PreviewComponent(docHandle: DocumentHandle) {
65
- * const { data: { title, subtitle, media }, isPending } = useDocumentPreview(docHandle)
60
+ * // PreviewComponent.jsx
61
+ * export default function PreviewComponent({ document }) {
62
+ * const { data: { title, subtitle, media }, isPending } = useDocumentPreview({ document })
66
63
  * return (
67
64
  * <article style={{ opacity: isPending ? 0.5 : 1}}>
68
65
  * {media?.type === 'image-asset' ? <img src={media.url} alt='' /> : ''}
@@ -72,16 +69,16 @@ export interface useDocumentPreviewResults {
72
69
  * )
73
70
  * }
74
71
  *
75
- * // DocumentList.tsx
76
- * const { data } = useDocuments({ documentType: 'movie' })
72
+ * // DocumentList.jsx
73
+ * const { data } = useDocuments({ filter: '_type == "movie"' })
77
74
  * return (
78
75
  * <div>
79
76
  * <h1>Movies</h1>
80
77
  * <ul>
81
78
  * {data.map(movie => (
82
- * <li key={movie.documentId}>
79
+ * <li key={movie._id}>
83
80
  * <Suspense fallback='Loading…'>
84
- * <PreviewComponent {...movie} />
81
+ * <PreviewComponent document={movie} />
85
82
  * </Suspense>
86
83
  * </li>
87
84
  * ))}
@@ -95,6 +92,7 @@ export function useDocumentPreview({
95
92
  ...docHandle
96
93
  }: useDocumentPreviewOptions): useDocumentPreviewResults {
97
94
  const instance = useSanityInstance()
95
+ trackHookUsage(instance, 'useDocumentPreview')
98
96
  const normalizedDocHandle = useNormalizedResourceOptions(docHandle)
99
97
 
100
98
  // Use the projection hook with the fixed preview projection
@@ -108,7 +106,7 @@ export function useDocumentPreview({
108
106
  // Keep this non-null assumption aligned with useDocumentPreviewResults.data.
109
107
  const previewValue = useMemo(
110
108
  () =>
111
- transformProjectionToPreview(instance, normalizedDocHandle.resource, projectionResult.data),
109
+ transformProjectionToPreview(instance, projectionResult.data, normalizedDocHandle.resource),
112
110
  [projectionResult.data, instance, normalizedDocHandle.resource],
113
111
  )
114
112