@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,63 @@
1
+ import {type Organizations, organizations, type StateSource} from '@sanity/sdk'
2
+ import {type FetcherSnapshot} from '@sanity/sdk/_internal'
3
+ import {type Observable} from 'rxjs'
4
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
5
+
6
+ import {renderHook} from '../../../test/test-utils'
7
+ import {useOrganizations} from './useOrganizations'
8
+
9
+ vi.mock('@sanity/sdk', async (importOriginal) => {
10
+ const original = await importOriginal<typeof import('@sanity/sdk')>()
11
+ return {...original, organizations: {getState: vi.fn(), resolveState: vi.fn(), refetch: vi.fn()}}
12
+ })
13
+
14
+ const stateSource = (
15
+ current: Organizations | undefined,
16
+ ): StateSource<FetcherSnapshot<Organizations>> => {
17
+ const snapshot = current
18
+ ? {status: 'success', data: current, error: undefined, isFetching: false, dataUpdatedAt: 1}
19
+ : {
20
+ status: 'pending',
21
+ data: undefined,
22
+ error: undefined,
23
+ isFetching: true,
24
+ dataUpdatedAt: undefined,
25
+ }
26
+ return {
27
+ getCurrent: vi.fn(() => snapshot),
28
+ subscribe: vi.fn(() => () => {}),
29
+ get observable(): Observable<unknown> {
30
+ throw new Error('Not implemented')
31
+ },
32
+ } as unknown as StateSource<FetcherSnapshot<Organizations>>
33
+ }
34
+
35
+ const sanityInstance = expect.objectContaining({config: expect.any(Object)})
36
+
37
+ describe('useOrganizations', () => {
38
+ beforeEach(() => {
39
+ vi.clearAllMocks()
40
+ vi.mocked(organizations.getState).mockReturnValue(
41
+ stateSource([{id: 'org_1'}] as unknown as Organizations),
42
+ )
43
+ })
44
+
45
+ it('reads the organizations fetcher with the passed options', () => {
46
+ renderHook(() => useOrganizations({includeMembers: true}))
47
+ expect(organizations.getState).toHaveBeenCalledWith(sanityInstance, {includeMembers: true})
48
+ })
49
+
50
+ it('returns the fetcher data in the result envelope', () => {
51
+ const {result} = renderHook(() => useOrganizations())
52
+ expect(result.current.data).toEqual([{id: 'org_1'}])
53
+ expect(result.current.isFetching).toBe(false)
54
+ expect(typeof result.current.refetch).toBe('function')
55
+ })
56
+
57
+ it('suspends via the organizations fetcher until data is available', () => {
58
+ vi.mocked(organizations.getState).mockReturnValue(stateSource(undefined))
59
+ vi.mocked(organizations.resolveState).mockReturnValue(new Promise(() => {}))
60
+ renderHook(() => useOrganizations())
61
+ expect(organizations.resolveState).toHaveBeenCalled()
62
+ })
63
+ })
@@ -0,0 +1,39 @@
1
+ import {type Organizations, organizations, type OrganizationsOptions} from '@sanity/sdk'
2
+
3
+ import {createFetcherHook, type FetcherHookResult} from '../helpers/createFetcherHook'
4
+
5
+ /**
6
+ * Returns metadata for each organisation the current user has access to.
7
+ *
8
+ * @category Organizations
9
+ * @param options - Configuration options
10
+ * @returns A {@link FetcherHookResult} whose `data` is an array of organisation
11
+ * metadata. `members` is included only when `includeMembers: true`; `features`
12
+ * is included only when `includeFeatures: true`.
13
+ * @example
14
+ * ```tsx
15
+ * const {data: organizations} = useOrganizations()
16
+ *
17
+ * return (
18
+ * <select>
19
+ * {organizations.map((organization) => (
20
+ * <option key={organization.id}>{organization.name}</option>
21
+ * ))}
22
+ * </select>
23
+ * )
24
+ * ```
25
+ * @example
26
+ * ```tsx
27
+ * const {data: organizationsWithMembers} = useOrganizations({includeMembers: true})
28
+ * const {data: organizationsWithFeatures} = useOrganizations({includeFeatures: true})
29
+ * const {data: organizationsIncludingImplicit} = useOrganizations({includeImplicitMemberships: true})
30
+ * ```
31
+ * @public
32
+ * @function
33
+ */
34
+ export const useOrganizations = createFetcherHook(organizations) as <
35
+ IncludeMembers extends boolean = false,
36
+ IncludeFeatures extends boolean = false,
37
+ >(
38
+ options?: OrganizationsOptions<IncludeMembers, IncludeFeatures>,
39
+ ) => FetcherHookResult<Organizations<IncludeMembers, IncludeFeatures>>
@@ -1,14 +1,20 @@
1
- import {type DatasetResource} from '@sanity/sdk'
1
+ import {act, renderHook} from '@testing-library/react'
2
2
  import {evaluateSync, parse, toJS} from 'groq-js'
3
3
  import {describe, vi} from 'vitest'
4
4
 
5
- import {act, renderHook} from '../../../test/test-utils'
5
+ import {ResourceProvider} from '../../context/ResourceProvider'
6
6
  import {useQuery} from '../query/useQuery'
7
7
  import {usePaginatedDocuments} from './usePaginatedDocuments'
8
8
 
9
9
  vi.mock('../query/useQuery')
10
10
 
11
11
  describe('usePaginatedDocuments', () => {
12
+ const wrapper = ({children}: {children: React.ReactNode}) => (
13
+ <ResourceProvider projectId="p" dataset="d" fallback={null}>
14
+ {children}
15
+ </ResourceProvider>
16
+ )
17
+
12
18
  beforeEach(() => {
13
19
  const dataset = [
14
20
  {
@@ -77,14 +83,16 @@ describe('usePaginatedDocuments', () => {
77
83
 
78
84
  it('should respect custom page size', () => {
79
85
  const customPageSize = 2
80
- const {result} = renderHook(() => usePaginatedDocuments({pageSize: customPageSize}))
86
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize: customPageSize}), {wrapper})
81
87
 
82
88
  expect(result.current.pageSize).toBe(customPageSize)
83
89
  expect(result.current.data.length).toBeLessThanOrEqual(customPageSize)
84
90
  })
85
91
 
86
92
  it('should filter by document type', () => {
87
- const {result} = renderHook(() => usePaginatedDocuments({filter: '_type == "movie"'}))
93
+ const {result} = renderHook(() => usePaginatedDocuments({filter: '_type == "movie"'}), {
94
+ wrapper,
95
+ })
88
96
 
89
97
  expect(result.current.data.every((doc) => doc.documentType === 'movie')).toBe(true)
90
98
  expect(result.current.count).toBe(5) // 5 movies in the dataset
@@ -92,18 +100,20 @@ describe('usePaginatedDocuments', () => {
92
100
 
93
101
  // groq-js doesn't support search filters yet
94
102
  it.skip('should apply search filter', () => {
95
- const {result} = renderHook(() => usePaginatedDocuments({search: 'inter'}))
103
+ const {result} = renderHook(() => usePaginatedDocuments({search: 'inter'}), {wrapper})
96
104
 
97
105
  // Should match "Interstellar"
98
106
  expect(result.current.data.some((doc) => doc.documentId === 'movie3')).toBe(true)
99
107
  })
100
108
 
101
109
  it('should apply ordering', () => {
102
- const {result} = renderHook(() =>
103
- usePaginatedDocuments({
104
- filter: '_type == "movie"',
105
- orderings: [{field: 'releaseYear', direction: 'desc'}],
106
- }),
110
+ const {result} = renderHook(
111
+ () =>
112
+ usePaginatedDocuments({
113
+ filter: '_type == "movie"',
114
+ orderings: [{field: 'releaseYear', direction: 'desc'}],
115
+ }),
116
+ {wrapper},
107
117
  )
108
118
 
109
119
  // First item should be the most recent movie (Interstellar, 2014)
@@ -112,7 +122,7 @@ describe('usePaginatedDocuments', () => {
112
122
 
113
123
  it('should calculate pagination values correctly', () => {
114
124
  const pageSize = 2
115
- const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
125
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}), {wrapper})
116
126
 
117
127
  expect(result.current.currentPage).toBe(1)
118
128
  expect(result.current.totalPages).toBe(3) // 6 items with page size 2
@@ -123,7 +133,7 @@ describe('usePaginatedDocuments', () => {
123
133
 
124
134
  it('should navigate to next page', () => {
125
135
  const pageSize = 2
126
- const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
136
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}), {wrapper})
127
137
 
128
138
  expect(result.current.currentPage).toBe(1)
129
139
  expect(result.current.data.length).toBe(pageSize)
@@ -139,7 +149,7 @@ describe('usePaginatedDocuments', () => {
139
149
 
140
150
  it('should navigate to previous page', () => {
141
151
  const pageSize = 2
142
- const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
152
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}), {wrapper})
143
153
 
144
154
  // Go to page 2 first
145
155
  act(() => {
@@ -159,7 +169,7 @@ describe('usePaginatedDocuments', () => {
159
169
 
160
170
  it('should navigate to first page', () => {
161
171
  const pageSize = 2
162
- const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
172
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}), {wrapper})
163
173
 
164
174
  // Go to last page first
165
175
  act(() => {
@@ -179,7 +189,7 @@ describe('usePaginatedDocuments', () => {
179
189
 
180
190
  it('should navigate to last page', () => {
181
191
  const pageSize = 2
182
- const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
192
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}), {wrapper})
183
193
 
184
194
  act(() => {
185
195
  result.current.lastPage()
@@ -191,7 +201,7 @@ describe('usePaginatedDocuments', () => {
191
201
 
192
202
  it('should navigate to specific page', () => {
193
203
  const pageSize = 2
194
- const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
204
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}), {wrapper})
195
205
 
196
206
  act(() => {
197
207
  result.current.goToPage(2) // Go to page 2
@@ -216,7 +226,7 @@ describe('usePaginatedDocuments', () => {
216
226
 
217
227
  it('should set page availability flags correctly', () => {
218
228
  const pageSize = 2
219
- const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
229
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}), {wrapper})
220
230
  // On first page
221
231
  expect(result.current.hasFirstPage).toBe(false)
222
232
  expect(result.current.hasPreviousPage).toBe(false)
@@ -244,6 +254,7 @@ describe('usePaginatedDocuments', () => {
244
254
  it('should reset current page when filter changes', () => {
245
255
  const {result, rerender} = renderHook((props) => usePaginatedDocuments(props), {
246
256
  initialProps: {pageSize: 2, filter: ''},
257
+ wrapper,
247
258
  })
248
259
  // Initially, current page should be 1
249
260
  expect(result.current.currentPage).toBe(1)
@@ -259,19 +270,15 @@ describe('usePaginatedDocuments', () => {
259
270
  })
260
271
 
261
272
  it('should add projectId and dataset to document handles', () => {
262
- const {result} = renderHook(() => usePaginatedDocuments({}))
273
+ const {result} = renderHook(() => usePaginatedDocuments({}), {wrapper})
263
274
 
264
275
  // Check that the first document handle has the projectId and dataset
265
- expect((result.current.data[0].resource as DatasetResource).projectId).toBe('test')
266
- expect((result.current.data[0].resource as DatasetResource).dataset).toBe('test')
276
+ expect(result.current.data[0].projectId).toBe('p')
277
+ expect(result.current.data[0].dataset).toBe('d')
267
278
 
268
279
  // Verify all document handles have these properties
269
- expect(
270
- result.current.data.every(
271
- (doc) =>
272
- (doc.resource as DatasetResource).projectId === 'test' &&
273
- (doc.resource as DatasetResource).dataset === 'test',
274
- ),
275
- ).toBe(true)
280
+ expect(result.current.data.every((doc) => doc.projectId === 'p' && doc.dataset === 'd')).toBe(
281
+ true,
282
+ )
276
283
  })
277
284
  })
@@ -1,10 +1,13 @@
1
- import {createGroqSearchFilter, type QueryOptions} from '@sanity/sdk'
1
+ import {type DocumentHandle, isDatasetResource, type QueryOptions} from '@sanity/sdk'
2
+ import {createGroqSearchFilter, pickProperties} from '@sanity/sdk/_internal'
2
3
  import {type SortOrderingItem} from '@sanity/types'
3
- import {pick} from 'lodash-es'
4
- import {useCallback, useEffect, useMemo, useState} from 'react'
4
+ import {useCallback, useMemo, useState} from 'react'
5
5
 
6
- import {type DocumentHandle, type ResourceHandle} from '../../config/handles'
7
- import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
6
+ import {
7
+ useNormalizedResourceOptions,
8
+ type WithResourceNameSupport,
9
+ } from '../helpers/useNormalizedResourceOptions'
10
+ import {useTrackHookUsage} from '../helpers/useTrackHookUsage'
8
11
  import {useQuery} from '../query/useQuery'
9
12
 
10
13
  /**
@@ -17,10 +20,9 @@ export interface PaginatedDocumentsOptions<
17
20
  TDocumentType extends string = string,
18
21
  TDataset extends string = string,
19
22
  TProjectId extends string = string,
20
- >
21
- extends
22
- ResourceHandle<TProjectId, TDataset>,
23
- Pick<QueryOptions<TDocumentType, TDataset, TProjectId>, 'params'> {
23
+ > extends WithResourceNameSupport<
24
+ Omit<QueryOptions<TDocumentType, TDataset, TProjectId>, 'query'>
25
+ > {
24
26
  documentType?: TDocumentType | TDocumentType[]
25
27
  /**
26
28
  * GROQ filter expression to apply to the query
@@ -141,16 +143,18 @@ export interface PaginatedDocumentsResponse<
141
143
  * @returns An object containing the list of document handles, pagination details, and functions to navigate between pages
142
144
  *
143
145
  * @remarks
144
- * - The returned document handles include resource information from the current Sanity instance
146
+ * - The returned document handles include projectId and dataset information from the current Sanity instance
145
147
  * - This makes them ready to use with document operations and other document hooks
146
- * - The hook automatically uses the correct Sanity instance based on the resource in the options
148
+ * - The hook automatically uses the correct Sanity instance based on the projectId and dataset in the options
147
149
  *
148
150
  * @example Paginated list of documents with navigation
149
151
  * ```tsx
150
152
  * import {
151
153
  * usePaginatedDocuments,
154
+ * createDatasetHandle,
155
+ * type DatasetHandle,
152
156
  * type DocumentHandle,
153
- * type DocumentResource,
157
+ * type SortOrderingItem,
154
158
  * useDocumentProjection
155
159
  * } from '@sanity/sdk-react'
156
160
  * import {Suspense} from 'react'
@@ -173,10 +177,10 @@ export interface PaginatedDocumentsResponse<
173
177
  * // Define props for the list component
174
178
  * interface PaginatedDocumentListProps {
175
179
  * documentType: string
176
- * resource?: DocumentResource
180
+ * dataset?: DatasetHandle
177
181
  * }
178
182
  *
179
- * function PaginatedDocumentList({documentType, resource}: PaginatedDocumentListProps) {
183
+ * function PaginatedDocumentList({documentType, dataset}: PaginatedDocumentListProps) {
180
184
  * const {
181
185
  * data,
182
186
  * isPending,
@@ -187,7 +191,7 @@ export interface PaginatedDocumentsResponse<
187
191
  * hasNextPage,
188
192
  * hasPreviousPage
189
193
  * } = usePaginatedDocuments({
190
- * resource,
194
+ * ...dataset,
191
195
  * documentType,
192
196
  * pageSize: 10,
193
197
  * orderings: [{field: '_createdAt', direction: 'desc'}],
@@ -219,7 +223,8 @@ export interface PaginatedDocumentsResponse<
219
223
  * }
220
224
  *
221
225
  * // Usage:
222
- * // <PaginatedDocumentList resource={{projectId: 'p1', dataset: 'production'}} documentType="post" />
226
+ * // const myDatasetHandle = createDatasetHandle({ projectId: 'p1', dataset: 'production' })
227
+ * // <PaginatedDocumentList dataset={myDatasetHandle} documentType="post" />
223
228
  * ```
224
229
  */
225
230
  export function usePaginatedDocuments<
@@ -239,14 +244,16 @@ export function usePaginatedDocuments<
239
244
  TDataset,
240
245
  TProjectId
241
246
  > {
247
+ useTrackHookUsage('usePaginatedDocuments')
242
248
  const options = useNormalizedResourceOptions(rawOptions)
243
249
  const [pageIndex, setPageIndex] = useState(0)
244
250
  const key = JSON.stringify({filter, search, params, orderings, pageSize, ...options})
245
- // Reset the pageIndex to 0 whenever any query parameters (filter, search,
246
- // params, orderings) or pageSize changes
247
- useEffect(() => {
251
+ // Reset pageIndex to 0 whenever any query parameter changes.
252
+ const [prevKey, setPrevKey] = useState(key)
253
+ if (prevKey !== key) {
254
+ setPrevKey(key)
248
255
  setPageIndex(0)
249
- }, [key])
256
+ }
250
257
 
251
258
  const startIndex = pageIndex * pageSize
252
259
  const endIndex = (pageIndex + 1) * pageSize
@@ -301,8 +308,14 @@ export function usePaginatedDocuments<
301
308
  params: {
302
309
  ...params,
303
310
  __types: documentTypes,
304
- // these are passed back to the user as part of each document handle
305
- __handle: pick(options, ['resource', 'perspective']),
311
+ __handle: {
312
+ // keep projectId/dataset for backward compat until v4; resource is added
313
+ // intentionally so that hook consumers can resolve the correct resource
314
+ ...(options.resource && isDatasetResource(options.resource)
315
+ ? pickProperties(options.resource, ['projectId', 'dataset'])
316
+ : {}),
317
+ ...pickProperties(options, ['perspective', 'resource']),
318
+ },
306
319
  },
307
320
  })
308
321
 
@@ -102,21 +102,6 @@ describe('usePresence', () => {
102
102
  }).toThrow('usePresence() does not support media library resources')
103
103
  })
104
104
 
105
- it('should throw an error when used with a canvas resource', () => {
106
- expect(() => {
107
- renderHook(() => usePresence({resource: {canvasId: 'canvas123'}}), {
108
- wrapper: ({children}) => (
109
- <ResourceProvider
110
- resource={{projectId: 'test-project', dataset: 'test-dataset'}}
111
- fallback={null}
112
- >
113
- {children}
114
- </ResourceProvider>
115
- ),
116
- })
117
- }).toThrow('usePresence() does not support canvas resources')
118
- })
119
-
120
105
  it('should work with a dataset resource', () => {
121
106
  const mockPresenceSource = {
122
107
  getCurrent: vi.fn().mockReturnValue([]),
@@ -1,38 +1,40 @@
1
- import {getPresence, isCanvasResource, isMediaLibraryResource, type UserPresence} from '@sanity/sdk'
1
+ import {getPresence, isMediaLibraryResource, type UserPresence} from '@sanity/sdk'
2
2
  import {useCallback, useMemo, useSyncExternalStore} from 'react'
3
3
 
4
4
  import {type ResourceHandle} from '../../config/handles'
5
5
  import {useSanityInstance} from '../context/useSanityInstance'
6
6
  import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
7
+ import {trackHookUsage} from '../helpers/useTrackHookUsage'
7
8
 
8
9
  /**
9
- * A hook for subscribing to presence information for the current project.
10
+ * Every participant in the current project and dataset, or Canvas.
11
+ *
12
+ * Reading presence never announces anything. Call `useReportPresence` to make the
13
+ * current user visible to others, including to the Studio, which shares the same
14
+ * presence room.
15
+ *
16
+ * This returns everyone in the whole resource and leaves the filtering to you.
17
+ * Prefer `usePresenceForDocument` when you care about one document: it scopes and
18
+ * flattens the result for rendering. Note that participants are counted by session,
19
+ * so one person in two tabs appears twice.
20
+ *
21
+ * Presence is scoped to a single project and dataset. It is not a list of everyone
22
+ * signed in to your organization.
23
+ *
10
24
  * @public
11
25
  */
12
26
  export function usePresence(options: ResourceHandle = {}): {
13
27
  locations: UserPresence[]
14
28
  } {
15
29
  const normalizedOptions = useNormalizedResourceOptions(options)
16
- const sanityInstance = useSanityInstance()
17
-
18
- // Validate resource type before attempting to create the presence store
19
- // This provides immediate, clear feedback instead of hanging
20
- if (normalizedOptions.resource) {
21
- if (isMediaLibraryResource(normalizedOptions.resource)) {
22
- throw new Error(
23
- 'usePresence() does not support media library resources. Presence tracking requires a dataset resource. ' +
24
- 'Either remove the resourceName parameter or use a dataset resource instead.',
25
- )
26
- }
27
-
28
- if (isCanvasResource(normalizedOptions.resource)) {
29
- throw new Error(
30
- 'usePresence() does not support canvas resources. Presence tracking requires a dataset resource. ' +
31
- 'Either remove the resourceName parameter or use a dataset resource instead.',
32
- )
33
- }
30
+ if (normalizedOptions.resource && isMediaLibraryResource(normalizedOptions.resource)) {
31
+ throw new Error(
32
+ 'usePresence() does not support media library resources. Presence tracking requires a canvas or dataset resource.',
33
+ )
34
34
  }
35
35
 
36
+ const sanityInstance = useSanityInstance()
37
+ trackHookUsage(sanityInstance, 'usePresence')
36
38
  const source = useMemo(
37
39
  () => getPresence(sanityInstance, normalizedOptions),
38
40
  [sanityInstance, normalizedOptions],
@@ -0,0 +1,141 @@
1
+ import {type DocumentPresence, getDocumentPresence} from '@sanity/sdk'
2
+ import {act, renderHook} from '@testing-library/react'
3
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
4
+
5
+ import {ResourceProvider} from '../../context/ResourceProvider'
6
+ import {usePresenceForDocument} from './usePresenceForDocument'
7
+
8
+ vi.mock('@sanity/sdk', async (importOriginal) => {
9
+ const actual = await importOriginal<typeof import('@sanity/sdk')>()
10
+ return {...actual, getDocumentPresence: vi.fn()}
11
+ })
12
+
13
+ const entry = (sessionId: string): DocumentPresence =>
14
+ ({
15
+ sessionId,
16
+ documentId: 'movie-1',
17
+ path: ['title'],
18
+ lastActiveAt: '2026-07-30T12:00:00Z',
19
+ user: {sanityUserId: 'u1', profile: {id: 'u1'}, memberships: []},
20
+ }) as unknown as DocumentPresence
21
+
22
+ /** A minimal StateSource stand-in whose value can be pushed. */
23
+ function createSource(initial: DocumentPresence[]) {
24
+ let current = initial
25
+ const listeners = new Set<() => void>()
26
+ return {
27
+ source: {
28
+ getCurrent: () => current,
29
+ subscribe: (cb: () => void) => {
30
+ listeners.add(cb)
31
+ return () => listeners.delete(cb)
32
+ },
33
+ },
34
+ push: (next: DocumentPresence[]) => {
35
+ current = next
36
+ listeners.forEach((cb) => cb())
37
+ },
38
+ }
39
+ }
40
+
41
+ const wrapper = ({children}: {children: React.ReactNode}) => (
42
+ <ResourceProvider projectId="p" dataset="d" fallback={null}>
43
+ {children}
44
+ </ResourceProvider>
45
+ )
46
+
47
+ describe('usePresenceForDocument', () => {
48
+ beforeEach(() => {
49
+ // Without this, `mock.calls[0]` is whatever the previous test did.
50
+ vi.clearAllMocks()
51
+ })
52
+
53
+ it('returns presence and updates when the store changes', () => {
54
+ const {source, push} = createSource([entry('s1')])
55
+ vi.mocked(getDocumentPresence).mockReturnValue(source as never)
56
+
57
+ const {result} = renderHook(
58
+ () => usePresenceForDocument({documentId: 'movie-1', documentType: 'movie'}),
59
+ {wrapper},
60
+ )
61
+
62
+ expect(result.current.presence).toHaveLength(1)
63
+
64
+ act(() => push([entry('s1'), entry('s2')]))
65
+ expect(result.current.presence).toHaveLength(2)
66
+ })
67
+
68
+ it('passes the document, path, and excludeVersions through', () => {
69
+ const {source} = createSource([])
70
+ vi.mocked(getDocumentPresence).mockReturnValue(source as never)
71
+
72
+ renderHook(
73
+ () =>
74
+ usePresenceForDocument({
75
+ documentId: 'drafts.movie-1',
76
+ documentType: 'movie',
77
+ path: ['cast', {_key: 'm1'}],
78
+ excludeVersions: true,
79
+ }),
80
+ {wrapper},
81
+ )
82
+
83
+ // Already a draft id, so resolution leaves it alone.
84
+ expect(vi.mocked(getDocumentPresence).mock.calls[0][1]).toMatchObject({
85
+ documentId: 'drafts.movie-1',
86
+ path: ['cast', {_key: 'm1'}],
87
+ excludeVersions: true,
88
+ })
89
+ })
90
+
91
+ it('reads the same id useReportPresence writes, or reads never match writes', () => {
92
+ const {source} = createSource([])
93
+ vi.mocked(getDocumentPresence).mockReturnValue(source as never)
94
+
95
+ // Forwarded unresolved, exactly as `useReportPresence` forwards it, so core
96
+ // resolves both the same way. If these diverged, field-level presence would
97
+ // silently never match.
98
+ renderHook(
99
+ () =>
100
+ usePresenceForDocument({
101
+ documentId: 'movie-1',
102
+ documentType: 'movie',
103
+ perspective: 'published',
104
+ }),
105
+ {wrapper},
106
+ )
107
+
108
+ expect(vi.mocked(getDocumentPresence).mock.calls[0][1]).toMatchObject({
109
+ documentId: 'movie-1',
110
+ perspective: 'published',
111
+ })
112
+ })
113
+
114
+ it('does not rebuild the source when a caller passes a fresh path each render', () => {
115
+ const {source} = createSource([])
116
+ vi.mocked(getDocumentPresence).mockReturnValue(source as never)
117
+
118
+ const {rerender} = renderHook(
119
+ // A new array identity every render, which is what real callers write.
120
+ () => usePresenceForDocument({documentId: 'movie-1', documentType: 'movie', path: ['title']}),
121
+ {wrapper},
122
+ )
123
+
124
+ rerender()
125
+ rerender()
126
+
127
+ expect(vi.mocked(getDocumentPresence)).toHaveBeenCalledTimes(1)
128
+ })
129
+
130
+ it('returns an empty array rather than undefined before anything arrives', () => {
131
+ const {source} = createSource(undefined as unknown as DocumentPresence[])
132
+ vi.mocked(getDocumentPresence).mockReturnValue(source as never)
133
+
134
+ const {result} = renderHook(
135
+ () => usePresenceForDocument({documentId: 'movie-1', documentType: 'movie'}),
136
+ {wrapper},
137
+ )
138
+
139
+ expect(result.current.presence).toEqual([])
140
+ })
141
+ })