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

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 (160) hide show
  1. package/README.md +103 -155
  2. package/dist/index.d.ts +1564 -682
  3. package/dist/index.js +1118 -612
  4. package/dist/index.js.map +1 -1
  5. package/package.json +51 -54
  6. package/src/_exports/index.ts +2 -4
  7. package/src/_exports/sdk-react.ts +26 -17
  8. package/src/components/SDKProvider.test.tsx +173 -32
  9. package/src/components/SDKProvider.tsx +87 -28
  10. package/src/components/SanityApp.test.tsx +68 -12
  11. package/src/components/SanityApp.tsx +94 -87
  12. package/src/components/auth/AuthBoundary.recovery.test.tsx +86 -0
  13. package/src/components/auth/AuthBoundary.test.tsx +26 -8
  14. package/src/components/auth/AuthBoundary.tsx +28 -6
  15. package/src/components/auth/DashboardAccessRequest.tsx +37 -0
  16. package/src/components/auth/LoginError.test.tsx +191 -5
  17. package/src/components/auth/LoginError.tsx +103 -59
  18. package/src/components/errors/ChunkLoadError.test.tsx +59 -0
  19. package/src/components/errors/ChunkLoadError.tsx +56 -0
  20. package/src/components/errors/CorsErrorComponent.tsx +2 -2
  21. package/src/components/errors/chunkReloadStorage.ts +57 -0
  22. package/src/config/handles.ts +29 -27
  23. package/src/constants.ts +5 -0
  24. package/src/context/ComlinkTokenRefresh.test.tsx +10 -27
  25. package/src/context/DefaultResourceContext.ts +3 -3
  26. package/src/context/OrganizationResourcesProvider.test.tsx +189 -0
  27. package/src/context/OrganizationResourcesProvider.tsx +111 -0
  28. package/src/context/PerspectiveContext.ts +3 -3
  29. package/src/context/ProjectContext.ts +15 -0
  30. package/src/context/ResourceProvider.test.tsx +72 -90
  31. package/src/context/ResourceProvider.tsx +59 -93
  32. package/src/context/SDKStudioContext.test.tsx +28 -33
  33. package/src/context/SanityInstanceProvider.test.tsx +100 -0
  34. package/src/context/SanityInstanceProvider.tsx +71 -0
  35. package/src/context/WorkbenchTokenRefresh.test.tsx +106 -0
  36. package/src/context/WorkbenchTokenRefresh.tsx +61 -0
  37. package/src/context/renderSanityApp.test.tsx +151 -49
  38. package/src/context/renderSanityApp.tsx +12 -8
  39. package/src/context/workbenchToken.ts +63 -0
  40. package/src/hooks/access/useCheckPermissions.test-d.ts +15 -0
  41. package/src/hooks/access/useCheckPermissions.test.tsx +53 -0
  42. package/src/hooks/access/useCheckPermissions.ts +24 -0
  43. package/src/hooks/agent/agentActions.test.tsx +1 -1
  44. package/src/hooks/agent/agentActions.ts +51 -71
  45. package/src/hooks/applications/useApplication.test-d.ts +30 -0
  46. package/src/hooks/applications/useApplication.ts +22 -0
  47. package/src/hooks/applications/useApplications.test-d.ts +31 -0
  48. package/src/hooks/applications/useApplications.ts +25 -0
  49. package/src/hooks/applications/useCreateUserApplication.test-d.ts +14 -0
  50. package/src/hooks/applications/useCreateUserApplication.ts +11 -0
  51. package/src/hooks/applications/useDeleteApplication.test-d.ts +12 -0
  52. package/src/hooks/applications/useDeleteApplication.ts +11 -0
  53. package/src/hooks/applications/useDeleteUserApplication.test-d.ts +14 -0
  54. package/src/hooks/applications/useDeleteUserApplication.ts +11 -0
  55. package/src/hooks/applications/useUpdateApplication.test-d.ts +12 -0
  56. package/src/hooks/applications/useUpdateApplication.ts +11 -0
  57. package/src/hooks/applications/useUpdateUserApplication.test-d.ts +14 -0
  58. package/src/hooks/applications/useUpdateUserApplication.ts +11 -0
  59. package/src/hooks/applications/useUserApplication.test-d.ts +11 -0
  60. package/src/hooks/applications/useUserApplication.ts +14 -0
  61. package/src/hooks/applications/useUserApplications.test-d.ts +11 -0
  62. package/src/hooks/applications/useUserApplications.ts +14 -0
  63. package/src/hooks/auth/useDashboardOrganizationId.test.tsx +2 -8
  64. package/src/hooks/auth/useVerifyOrgProjects.test.tsx +8 -32
  65. package/src/hooks/auth/useVerifyOrgProjects.tsx +13 -6
  66. package/src/hooks/client/useClient.test.tsx +1 -4
  67. package/src/hooks/client/useClient.ts +1 -0
  68. package/src/hooks/context/useResource.test.tsx +32 -0
  69. package/src/hooks/context/useResource.ts +24 -0
  70. package/src/hooks/context/useSanityInstance.test.tsx +72 -3
  71. package/src/hooks/context/useSanityInstance.ts +30 -8
  72. package/src/hooks/dashboard/useManageFavorite.test.tsx +110 -46
  73. package/src/hooks/dashboard/useManageFavorite.ts +10 -16
  74. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +5 -7
  75. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +2 -6
  76. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.test.ts +0 -2
  77. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.ts +1 -2
  78. package/src/hooks/dashboard/useWindowTitle.test.ts +244 -0
  79. package/src/hooks/dashboard/useWindowTitle.ts +114 -0
  80. package/src/hooks/datasets/useDatasets.test.tsx +116 -0
  81. package/src/hooks/datasets/useDatasets.ts +33 -13
  82. package/src/hooks/document/useApplyDocumentActions.test.tsx +48 -0
  83. package/src/hooks/document/useApplyDocumentActions.ts +66 -48
  84. package/src/hooks/document/useCreateDocument.test.tsx +83 -0
  85. package/src/hooks/document/useCreateDocument.ts +117 -0
  86. package/src/hooks/document/useDocument.test.tsx +37 -8
  87. package/src/hooks/document/useDocument.ts +131 -66
  88. package/src/hooks/document/useDocumentEvent.test.tsx +16 -4
  89. package/src/hooks/document/useDocumentEvent.ts +16 -10
  90. package/src/hooks/document/useDocumentPermissions.test.tsx +8 -7
  91. package/src/hooks/document/useDocumentPermissions.ts +71 -39
  92. package/src/hooks/document/useDocumentSyncStatus.test.ts +1 -0
  93. package/src/hooks/document/useDocumentSyncStatus.ts +13 -9
  94. package/src/hooks/document/useEditDocument.test.tsx +21 -14
  95. package/src/hooks/document/useEditDocument.ts +163 -17
  96. package/src/hooks/documents/useDocuments.test.tsx +64 -44
  97. package/src/hooks/documents/useDocuments.ts +36 -20
  98. package/src/hooks/helpers/createCallbackHook.test.tsx +13 -19
  99. package/src/hooks/helpers/createFetcherHook.test.tsx +180 -0
  100. package/src/hooks/helpers/createFetcherHook.ts +69 -0
  101. package/src/hooks/helpers/createMutationHook.test.tsx +125 -0
  102. package/src/hooks/helpers/createMutationHook.tsx +93 -0
  103. package/src/hooks/helpers/createStateSourceHook.test.tsx +10 -10
  104. package/src/hooks/helpers/createStateSourceHook.tsx +2 -1
  105. package/src/hooks/{document/useApplyDocumentActions.test.ts → helpers/useApplyActions.test.tsx} +92 -53
  106. package/src/hooks/helpers/useApplyActions.ts +68 -0
  107. package/src/hooks/helpers/useNormalizedResourceOptions.test.tsx +253 -0
  108. package/src/hooks/helpers/useNormalizedResourceOptions.ts +92 -50
  109. package/src/hooks/helpers/useResolvedProjectId.test.tsx +59 -0
  110. package/src/hooks/helpers/useResolvedProjectId.ts +35 -0
  111. package/src/hooks/helpers/useTrackHookUsage.ts +37 -0
  112. package/src/hooks/installations/useInstallation.test-d.ts +19 -0
  113. package/src/hooks/installations/useInstallation.ts +22 -0
  114. package/src/hooks/installations/useInstallations.test-d.ts +26 -0
  115. package/src/hooks/installations/useInstallations.ts +25 -0
  116. package/src/hooks/organizations/useOrganization.test-d.ts +60 -0
  117. package/src/hooks/organizations/useOrganization.test.ts +63 -0
  118. package/src/hooks/organizations/useOrganization.ts +34 -0
  119. package/src/hooks/organizations/useOrganizations.test-d.ts +68 -0
  120. package/src/hooks/organizations/useOrganizations.test.ts +63 -0
  121. package/src/hooks/organizations/useOrganizations.ts +39 -0
  122. package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +34 -27
  123. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +40 -22
  124. package/src/hooks/presence/usePresence.test.tsx +0 -15
  125. package/src/hooks/presence/usePresence.ts +9 -20
  126. package/src/hooks/preview/useDocumentPreview.test.tsx +0 -1
  127. package/src/hooks/preview/useDocumentPreview.tsx +10 -8
  128. package/src/hooks/projection/useDocumentProjection.test.tsx +78 -9
  129. package/src/hooks/projection/useDocumentProjection.ts +81 -2
  130. package/src/hooks/projects/useProject.test-d.ts +56 -0
  131. package/src/hooks/projects/useProject.test.tsx +120 -0
  132. package/src/hooks/projects/useProject.ts +42 -43
  133. package/src/hooks/projects/useProjects.test-d.ts +60 -0
  134. package/src/hooks/projects/useProjects.test.ts +45 -98
  135. package/src/hooks/projects/useProjects.ts +19 -26
  136. package/src/hooks/query/useQuery.test.tsx +6 -5
  137. package/src/hooks/query/useQuery.ts +91 -9
  138. package/src/hooks/releases/useActiveReleases.test.tsx +13 -7
  139. package/src/hooks/releases/useActiveReleases.ts +28 -24
  140. package/src/hooks/releases/useAllReleases.test.tsx +93 -0
  141. package/src/hooks/releases/useAllReleases.ts +62 -0
  142. package/src/hooks/releases/useApplyReleaseActions.test.tsx +66 -0
  143. package/src/hooks/releases/useApplyReleaseActions.ts +82 -0
  144. package/src/hooks/releases/usePerspective.test.tsx +18 -10
  145. package/src/hooks/releases/usePerspective.ts +13 -17
  146. package/src/hooks/users/useUser.test.tsx +3 -9
  147. package/src/hooks/users/useUser.ts +2 -0
  148. package/src/hooks/users/useUsers.test.tsx +103 -7
  149. package/src/hooks/users/useUsers.ts +37 -3
  150. package/src/utils/resolveOrgResources.test.ts +111 -0
  151. package/src/utils/resolveOrgResources.ts +69 -0
  152. package/src/hooks/context/useDefaultResource.test.tsx +0 -25
  153. package/src/hooks/context/useDefaultResource.ts +0 -30
  154. package/src/hooks/dashboard/useDispatchIntent.test.ts +0 -254
  155. package/src/hooks/dashboard/useDispatchIntent.ts +0 -157
  156. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.test.ts +0 -107
  157. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +0 -39
  158. package/src/hooks/datasets/useDatasets.test.ts +0 -66
  159. package/src/hooks/helpers/useNormalizedResourceOptions.test.ts +0 -65
  160. package/src/hooks/projects/useProject.test.ts +0 -79
@@ -1,10 +1,8 @@
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'
6
5
  import {ResourceProvider} from '../../context/ResourceProvider'
7
- import {ResourcesContext} from '../../context/ResourcesContext'
8
6
  import {useQuery} from '../query/useQuery'
9
7
  import {useDocuments} from './useDocuments'
10
8
 
@@ -79,13 +77,25 @@ describe('useDocuments', () => {
79
77
 
80
78
  it('should respect custom page size', () => {
81
79
  const customBatchSize = 2
82
- const {result} = renderHook(() => useDocuments({batchSize: customBatchSize}))
80
+ const {result} = renderHook(() => useDocuments({batchSize: customBatchSize}), {
81
+ wrapper: ({children}) => (
82
+ <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
83
+ {children}
84
+ </ResourceProvider>
85
+ ),
86
+ })
83
87
 
84
88
  expect(result.current.data.length).toBe(customBatchSize)
85
89
  })
86
90
 
87
91
  it('should filter by document type', () => {
88
- const {result} = renderHook(() => useDocuments({filter: '_type == "movie"'}))
92
+ const {result} = renderHook(() => useDocuments({filter: '_type == "movie"'}), {
93
+ wrapper: ({children}) => (
94
+ <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
95
+ {children}
96
+ </ResourceProvider>
97
+ ),
98
+ })
89
99
 
90
100
  expect(result.current.data.every((doc) => doc.documentType === 'movie')).toBe(true)
91
101
  expect(result.current.count).toBe(5) // 5 movies in the dataset
@@ -93,18 +103,32 @@ describe('useDocuments', () => {
93
103
 
94
104
  // groq-js doesn't support search filters yet
95
105
  it.skip('should apply search filter', () => {
96
- const {result} = renderHook(() => useDocuments({search: 'inter'}))
106
+ const {result} = renderHook(() => useDocuments({search: 'inter'}), {
107
+ wrapper: ({children}) => (
108
+ <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
109
+ {children}
110
+ </ResourceProvider>
111
+ ),
112
+ })
97
113
 
98
114
  // Should match "Interstellar"
99
115
  expect(result.current.data.some((doc) => doc.documentId === 'movie3')).toBe(true)
100
116
  })
101
117
 
102
118
  it('should apply ordering', () => {
103
- const {result} = renderHook(() =>
104
- useDocuments({
105
- filter: '_type == "movie"',
106
- orderings: [{field: 'releaseYear', direction: 'desc'}],
107
- }),
119
+ const {result} = renderHook(
120
+ () =>
121
+ useDocuments({
122
+ filter: '_type == "movie"',
123
+ orderings: [{field: 'releaseYear', direction: 'desc'}],
124
+ }),
125
+ {
126
+ wrapper: ({children}) => (
127
+ <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
128
+ {children}
129
+ </ResourceProvider>
130
+ ),
131
+ },
108
132
  )
109
133
 
110
134
  // First item should be the most recent movie (Interstellar, 2014)
@@ -113,7 +137,13 @@ describe('useDocuments', () => {
113
137
 
114
138
  it('should load more data when loadMore is called', () => {
115
139
  const batchSize = 2
116
- const {result} = renderHook(() => useDocuments({batchSize: batchSize}))
140
+ const {result} = renderHook(() => useDocuments({batchSize: batchSize}), {
141
+ wrapper: ({children}) => (
142
+ <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
143
+ {children}
144
+ </ResourceProvider>
145
+ ),
146
+ })
117
147
 
118
148
  expect(result.current.data.length).toBe(batchSize)
119
149
 
@@ -125,7 +155,13 @@ describe('useDocuments', () => {
125
155
  })
126
156
 
127
157
  it('should indicate when there is more data to load', () => {
128
- const {result} = renderHook(() => useDocuments({batchSize: 3}))
158
+ const {result} = renderHook(() => useDocuments({batchSize: 3}), {
159
+ wrapper: ({children}) => (
160
+ <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
161
+ {children}
162
+ </ResourceProvider>
163
+ ),
164
+ })
129
165
  expect(result.current.hasMore).toBe(true)
130
166
  // Load all remaining data
131
167
  act(() => {
@@ -138,6 +174,11 @@ describe('useDocuments', () => {
138
174
  it('should reset limit when filter changes', () => {
139
175
  const {result, rerender} = renderHook((props) => useDocuments(props), {
140
176
  initialProps: {batchSize: 2, filter: ''},
177
+ wrapper: ({children}) => (
178
+ <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
179
+ {children}
180
+ </ResourceProvider>
181
+ ),
141
182
  })
142
183
  // Initially, data length equals pageSize (2)
143
184
  expect(result.current.data.length).toBe(2)
@@ -153,44 +194,23 @@ describe('useDocuments', () => {
153
194
  expect(result.current.data.length).toBe(2)
154
195
  })
155
196
 
156
- it('should add resources to document handles', () => {
157
- const {result} = renderHook(() => useDocuments({}))
158
-
159
- // Check that the first document handle has the projectId and dataset
160
- expect((result.current.data[0].resource as DatasetResource).projectId).toBe('test')
161
- expect((result.current.data[0].resource as DatasetResource).dataset).toBe('test')
162
-
163
- // Verify all document handles have these properties
164
- expect(
165
- result.current.data.every(
166
- (doc) =>
167
- (doc.resource as DatasetResource).projectId === 'test' &&
168
- (doc.resource as DatasetResource).dataset === 'test',
169
- ),
170
- ).toBe(true)
171
- })
172
-
173
- it('should resolve resourceName to the named dataset resource', () => {
174
- const resources = {
175
- default: {projectId: 'test-project', dataset: 'test-dataset'},
176
- secondary: {projectId: 'secondary-project', dataset: 'secondary-dataset'},
177
- }
178
-
179
- const {result} = renderHook(() => useDocuments({resourceName: 'secondary'}), {
197
+ it('should add projectId and dataset to document handles', () => {
198
+ const {result} = renderHook(() => useDocuments({}), {
180
199
  wrapper: ({children}) => (
181
- <ResourceProvider resource={resources.default} fallback={null}>
182
- <ResourcesContext.Provider value={resources}>{children}</ResourcesContext.Provider>
200
+ <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
201
+ {children}
183
202
  </ResourceProvider>
184
203
  ),
185
204
  })
186
205
 
187
- expect((result.current.data[0].resource as DatasetResource).projectId).toBe('secondary-project')
188
- expect((result.current.data[0].resource as DatasetResource).dataset).toBe('secondary-dataset')
206
+ // Check that the first document handle has the projectId and dataset
207
+ expect(result.current.data[0].projectId).toBe('test-project')
208
+ expect(result.current.data[0].dataset).toBe('test-dataset')
209
+
210
+ // Verify all document handles have these properties
189
211
  expect(
190
212
  result.current.data.every(
191
- (doc) =>
192
- (doc.resource as DatasetResource).projectId === 'secondary-project' &&
193
- (doc.resource as DatasetResource).dataset === 'secondary-dataset',
213
+ (doc) => doc.projectId === 'test-project' && doc.dataset === 'test-dataset',
194
214
  ),
195
215
  ).toBe(true)
196
216
  })
@@ -1,10 +1,16 @@
1
- import {createGroqSearchFilter, type QueryOptions} from '@sanity/sdk'
1
+ import {
2
+ createGroqSearchFilter,
3
+ type DocumentHandle,
4
+ isDatasetResource,
5
+ type QueryOptions,
6
+ } from '@sanity/sdk'
7
+ import {pickProperties} from '@sanity/sdk/_internal'
2
8
  import {type SortOrderingItem} from '@sanity/types'
3
- import {pick} from 'lodash-es'
4
- import {useCallback, useEffect, useMemo, useState} from 'react'
9
+ import {useCallback, useMemo, useState} from 'react'
5
10
 
6
- import {type DocumentHandle, type ResourceHandle} from '../../config/handles'
11
+ import {type ResourceHandle} from '../../config/handles'
7
12
  import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
13
+ import {useTrackHookUsage} from '../helpers/useTrackHookUsage'
8
14
  import {useQuery} from '../query/useQuery'
9
15
 
10
16
  const DEFAULT_BATCH_SIZE = 25
@@ -20,9 +26,7 @@ export interface DocumentsOptions<
20
26
  TDataset extends string = string,
21
27
  TProjectId extends string = string,
22
28
  >
23
- extends
24
- ResourceHandle<TProjectId, TDataset>,
25
- Pick<QueryOptions<TDocumentType, TDataset, TProjectId>, 'params'> {
29
+ extends ResourceHandle<TDataset, TProjectId>, Pick<QueryOptions, 'perspective' | 'params'> {
26
30
  /**
27
31
  * Filter documents by their `_type`. Can be a single type or an array of types.
28
32
  */
@@ -90,16 +94,18 @@ export interface DocumentsResponse<
90
94
  * @returns An object containing the list of document handles, the loading state, the total count of retrieved document handles, and a function to load more
91
95
  *
92
96
  * @remarks
93
- * - The returned document handles include resource information from the current Sanity instance
97
+ * - The returned document handles include projectId and dataset information from the current Sanity instance
94
98
  * - This makes them ready to use with document operations and other document hooks
95
- * - The hook automatically uses the correct Sanity instance based on the resource in the options
99
+ * - The hook automatically uses the correct Sanity instance based on the projectId and dataset in the options
96
100
  *
97
101
  * @example Basic infinite list with loading more
98
102
  * ```tsx
99
103
  * import {
100
104
  * useDocuments,
105
+ * createDatasetHandle,
106
+ * type DatasetHandle,
101
107
  * type DocumentHandle,
102
- * type DocumentResource,
108
+ * type SortOrderingItem
103
109
  * } from '@sanity/sdk-react'
104
110
  * import {Suspense} from 'react'
105
111
  *
@@ -115,14 +121,14 @@ export interface DocumentsResponse<
115
121
  *
116
122
  * // Define props for the list component
117
123
  * interface DocumentListProps {
118
- * resource: DocumentResource
124
+ * dataset: DatasetHandle
119
125
  * documentType: string
120
126
  * search?: string
121
127
  * }
122
128
  *
123
- * function DocumentList({resource, documentType, search}: DocumentListProps) {
129
+ * function DocumentList({dataset, documentType, search}: DocumentListProps) {
124
130
  * const { data, hasMore, isPending, loadMore, count } = useDocuments({
125
- * resource,
131
+ * ...dataset,
126
132
  * documentType,
127
133
  * search,
128
134
  * batchSize: 10,
@@ -136,7 +142,7 @@ export interface DocumentsResponse<
136
142
  * {data.map((docHandle) => (
137
143
  * <li key={docHandle.documentId}>
138
144
  * <Suspense fallback="Loading…">
139
- * <MyDocumentComponent doc={docHandle} />
145
+ * <MyDocumentComponent docHandle={docHandle} />
140
146
  * </Suspense>
141
147
  * </li>
142
148
  * ))}
@@ -151,7 +157,8 @@ export interface DocumentsResponse<
151
157
  * }
152
158
  *
153
159
  * // Usage:
154
- * // <DocumentList resource={{projectId: 'p1', dataset: 'production'}} documentType="post" search="Sanity" />
160
+ * // const myDatasetHandle = createDatasetHandle({ projectId: 'p1', dataset: 'production' })
161
+ * // <DocumentList dataset={myDatasetHandle} documentType="post" search="Sanity" />
155
162
  * ```
156
163
  *
157
164
  * @example Using `filter` and `params` options for narrowing a collection
@@ -202,8 +209,8 @@ export function useDocuments<
202
209
  TDataset,
203
210
  TProjectId
204
211
  > {
205
- const options =
206
- useNormalizedResourceOptions<DocumentsOptions<TDocumentType, TDataset, TProjectId>>(rawOptions)
212
+ useTrackHookUsage('useDocuments')
213
+ const options = useNormalizedResourceOptions(rawOptions)
207
214
  const [limit, setLimit] = useState(batchSize)
208
215
  const documentTypes = useMemo(
209
216
  () =>
@@ -224,9 +231,11 @@ export function useDocuments<
224
231
  types: documentTypes,
225
232
  ...options,
226
233
  })
227
- useEffect(() => {
234
+ const [prevKey, setPrevKey] = useState(key)
235
+ if (prevKey !== key) {
236
+ setPrevKey(key)
228
237
  setLimit(batchSize)
229
- }, [key, batchSize])
238
+ }
230
239
 
231
240
  const filterClause = useMemo(() => {
232
241
  const conditions: string[] = []
@@ -276,7 +285,14 @@ export function useDocuments<
276
285
  params: {
277
286
  ...params,
278
287
  // these are passed back to the user as part of each document handle
279
- __handle: pick(options, ['resource', 'perspective']),
288
+ __handle: {
289
+ // keep projectId/dataset for backward compat until v4; resource is added
290
+ // intentionally so that hook consumers can resolve the correct resource
291
+ ...(options.resource && isDatasetResource(options.resource)
292
+ ? pickProperties(options.resource, ['projectId', 'dataset'])
293
+ : {}),
294
+ ...pickProperties(options, ['perspective', 'resource']),
295
+ },
280
296
  __types: documentTypes,
281
297
  },
282
298
  })
@@ -1,7 +1,7 @@
1
1
  import {type SanityInstance} from '@sanity/sdk'
2
+ import {renderHook} from '@testing-library/react'
2
3
  import {describe, expect, it, vi} from 'vitest'
3
4
 
4
- import {renderHook} from '../../../test/test-utils'
5
5
  import {ResourceProvider} from '../../context/ResourceProvider'
6
6
  import {createCallbackHook} from './createCallbackHook'
7
7
 
@@ -21,7 +21,13 @@ describe('createCallbackHook', () => {
21
21
  const useTestHook = createCallbackHook(testCallback)
22
22
 
23
23
  // Render the hook
24
- const {result, rerender} = renderHook(() => useTestHook())
24
+ const {result, rerender} = renderHook(() => useTestHook(), {
25
+ wrapper: ({children}) => (
26
+ <ResourceProvider projectId="p" dataset="d" fallback={null}>
27
+ {children}
28
+ </ResourceProvider>
29
+ ),
30
+ })
25
31
 
26
32
  // Test the callback with parameters
27
33
  const result1 = result.current('test', 123)
@@ -38,17 +44,13 @@ describe('createCallbackHook', () => {
38
44
 
39
45
  it('should create new callback when instance changes', () => {
40
46
  // Create a test callback
41
- const testCallback = (instance: SanityInstance) => instance.config.studio?.projectId
47
+ const testCallback = (instance: SanityInstance) => instance.config.projectId
42
48
 
43
49
  // Create and render our hook with first provider
44
50
  const useTestHook = createCallbackHook(testCallback)
45
51
  const {result, unmount} = renderHook(() => useTestHook(), {
46
52
  wrapper: ({children}) => (
47
- <ResourceProvider
48
- studio={{projectId: 'p1'}}
49
- resource={{projectId: 'p1', dataset: 'd'}}
50
- fallback={null}
51
- >
53
+ <ResourceProvider projectId="p1" dataset="d" fallback={null}>
52
54
  {children}
53
55
  </ResourceProvider>
54
56
  ),
@@ -64,11 +66,7 @@ describe('createCallbackHook', () => {
64
66
  // Re-render with different provider configuration
65
67
  const {result: result2} = renderHook(() => useTestHook(), {
66
68
  wrapper: ({children}) => (
67
- <ResourceProvider
68
- studio={{projectId: 'p2'}}
69
- resource={{projectId: 'p2', dataset: 'd'}}
70
- fallback={null}
71
- >
69
+ <ResourceProvider projectId="p2" dataset="d" fallback={null}>
72
70
  {children}
73
71
  </ResourceProvider>
74
72
  ),
@@ -87,7 +85,7 @@ describe('createCallbackHook', () => {
87
85
  method: string,
88
86
  data: object,
89
87
  ) => ({
90
- url: `${instance.config.studio?.projectId}${path}`,
88
+ url: `${instance.config.projectId}${path}`,
91
89
  method,
92
90
  data,
93
91
  })
@@ -95,11 +93,7 @@ describe('createCallbackHook', () => {
95
93
  const useTestHook = createCallbackHook(testCallback)
96
94
  const {result} = renderHook(() => useTestHook(), {
97
95
  wrapper: ({children}) => (
98
- <ResourceProvider
99
- studio={{projectId: 'p'}}
100
- resource={{projectId: 'p', dataset: 'd'}}
101
- fallback={null}
102
- >
96
+ <ResourceProvider projectId="p" dataset="d" fallback={null}>
103
97
  {children}
104
98
  </ResourceProvider>
105
99
  ),
@@ -0,0 +1,180 @@
1
+ import {createSanityInstance} from '@sanity/sdk'
2
+ import {defineFetcher, type Fetcher, type FetcherSnapshot} from '@sanity/sdk/_internal'
3
+ import {renderHook} from '@testing-library/react'
4
+ import {of, throwError} from 'rxjs'
5
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
6
+
7
+ import {useSanityInstance} from '../context/useSanityInstance'
8
+ import {createFetcherHook} from './createFetcherHook'
9
+
10
+ vi.mock('../context/useSanityInstance', () => ({
11
+ useSanityInstance: vi.fn(),
12
+ }))
13
+
14
+ const instance = createSanityInstance({projectId: 'p', dataset: 'd'})
15
+
16
+ const makeFetcher = (snapshot: FetcherSnapshot<string>): Fetcher<[id: string], string> => {
17
+ const fetcher = {
18
+ getState: vi.fn(() => ({
19
+ subscribe: vi.fn(() => () => {}),
20
+ getCurrent: () => snapshot,
21
+ observable: throwError(() => new Error('unexpected usage of observable')),
22
+ })),
23
+ resolveState: vi.fn(() => Promise.resolve('resolved')),
24
+ refetch: vi.fn(() => Promise.resolve('refetched')),
25
+ invalidate: vi.fn(),
26
+ invalidateAll: vi.fn(),
27
+ setData: vi.fn(() => ({undo: vi.fn()})),
28
+ }
29
+ return fetcher as unknown as Fetcher<[id: string], string>
30
+ }
31
+
32
+ const success: FetcherSnapshot<string> = {
33
+ status: 'success',
34
+ data: 'DATA',
35
+ error: undefined,
36
+ isFetching: false,
37
+ dataUpdatedAt: 1,
38
+ }
39
+
40
+ describe('createFetcherHook', () => {
41
+ beforeEach(() => {
42
+ vi.clearAllMocks()
43
+ vi.mocked(useSanityInstance).mockReturnValue(instance)
44
+ })
45
+
46
+ it('returns {data, isFetching, error, refetch} from a success snapshot', () => {
47
+ const fetcher = makeFetcher(success)
48
+ const useThing = createFetcherHook(fetcher)
49
+ const {result} = renderHook(() => useThing('a'))
50
+
51
+ expect(result.current.data).toBe('DATA')
52
+ expect(result.current.isFetching).toBe(false)
53
+ expect(result.current.error).toBeUndefined()
54
+ expect(typeof result.current.refetch).toBe('function')
55
+ })
56
+
57
+ it('surfaces isFetching and the background error on the success arm', () => {
58
+ const boom = new Error('background')
59
+ const fetcher = makeFetcher({...success, isFetching: true, error: boom})
60
+ const useThing = createFetcherHook(fetcher)
61
+ const {result} = renderHook(() => useThing('a'))
62
+
63
+ expect(result.current.isFetching).toBe(true)
64
+ expect(result.current.error).toBe(boom)
65
+ })
66
+
67
+ it('refetch() calls the fetcher with the instance and params', () => {
68
+ const fetcher = makeFetcher(success)
69
+ const useThing = createFetcherHook(fetcher)
70
+ const {result} = renderHook(() => useThing('a'))
71
+
72
+ const returned = result.current.refetch()
73
+
74
+ expect(fetcher.refetch).toHaveBeenCalledWith(instance, 'a')
75
+ expect(returned).toBeInstanceOf(Promise)
76
+ })
77
+
78
+ it('suspends on a pending snapshot by throwing resolveState', () => {
79
+ const fetcher = makeFetcher({
80
+ status: 'pending',
81
+ data: undefined,
82
+ error: undefined,
83
+ isFetching: true,
84
+ dataUpdatedAt: undefined,
85
+ })
86
+ const useThing = createFetcherHook(fetcher)
87
+ const {result} = renderHook(() => {
88
+ try {
89
+ return useThing('a')
90
+ } catch (thrown) {
91
+ return thrown
92
+ }
93
+ })
94
+
95
+ expect(fetcher.resolveState).toHaveBeenCalledWith(instance, 'a')
96
+ expect(result.current).toBe(vi.mocked(fetcher.resolveState).mock.results[0]!.value)
97
+ })
98
+
99
+ it('throws the error arm to the error boundary', () => {
100
+ const boom = new Error('fatal')
101
+ const fetcher = makeFetcher({
102
+ status: 'error',
103
+ data: undefined,
104
+ error: boom,
105
+ isFetching: false,
106
+ dataUpdatedAt: undefined,
107
+ })
108
+ const useThing = createFetcherHook(fetcher)
109
+ const {result} = renderHook(() => {
110
+ try {
111
+ return useThing('a')
112
+ } catch (thrown) {
113
+ return thrown
114
+ }
115
+ })
116
+
117
+ expect(result.current).toBe(boom)
118
+ })
119
+
120
+ // Regression: the mocked fetcher above can never reproduce the real bug — its
121
+ // `getCurrent` returns a fixed snapshot object. A real fetcher store replaces
122
+ // the cache entry on every (un)subscribe, so `getCurrent` yields a new snapshot
123
+ // reference each time. Feeding that straight through `useSyncExternalStore`
124
+ // makes React see the store "change" on every commit and re-render forever
125
+ // ("Maximum update depth exceeded", React #185).
126
+ it('does not re-render forever over a real fetcher store (SDK-1448)', () => {
127
+ const fetcher = defineFetcher<[id: string], string>({
128
+ name: `regression-loop-${Math.random().toString(36).slice(2)}`,
129
+ getKey: (_instance, id) => id,
130
+ fetch: () => (id) => of(`DATA:${id}`),
131
+ })
132
+ // Seed a success entry so we skip Suspense and land in useSyncExternalStore.
133
+ fetcher.setData(instance, ['a'], 'DATA:a')
134
+
135
+ const useThing = createFetcherHook(fetcher)
136
+
137
+ let renders = 0
138
+ const {result} = renderHook(() => {
139
+ renders += 1
140
+ if (renders > 25) throw new Error(`infinite render loop: ${renders} renders`)
141
+ return useThing('a')
142
+ })
143
+
144
+ expect(result.current.data).toBe('DATA:a')
145
+ expect(renders).toBeLessThan(25)
146
+ })
147
+
148
+ // The realistic case: `data` is an object, not a primitive. The store hands
149
+ // back a fresh snapshot object on every (un)subscribe, so the dedup must keep
150
+ // returning the *same* `data` reference while the entry is unchanged —
151
+ // otherwise consumers that key off `data` identity (memo deps, effects)
152
+ // re-render on every commit even though nothing changed. This is the property
153
+ // the snapshot-equality check guards.
154
+ it('keeps a stable object data reference across re-renders (SDK-1448)', () => {
155
+ const data = {id: 'a', title: 'Thing', tags: ['x', 'y']}
156
+ const fetcher = defineFetcher<[id: string], typeof data>({
157
+ name: `regression-object-${Math.random().toString(36).slice(2)}`,
158
+ getKey: (_instance, id) => id,
159
+ fetch: () => () => of(data),
160
+ })
161
+ fetcher.setData(instance, ['a'], data)
162
+
163
+ const useThing = createFetcherHook(fetcher)
164
+
165
+ let renders = 0
166
+ const seen: unknown[] = []
167
+ const {result} = renderHook(() => {
168
+ renders += 1
169
+ if (renders > 25) throw new Error(`infinite render loop: ${renders} renders`)
170
+ const value = useThing('a')
171
+ seen.push(value.data)
172
+ return value
173
+ })
174
+
175
+ expect(result.current.data).toBe(data)
176
+ expect(renders).toBeLessThan(25)
177
+ // Every commit observed the identical object — no churn from fresh snapshots.
178
+ expect(seen.every((d) => d === data)).toBe(true)
179
+ })
180
+ })
@@ -0,0 +1,69 @@
1
+ import {type Fetcher, type FetcherSnapshot, isDeepEqual} from '@sanity/sdk/_internal'
2
+ import {useRef, useSyncExternalStore} from 'react'
3
+
4
+ import {useSanityInstance} from '../context/useSanityInstance'
5
+
6
+ /**
7
+ * The value returned by a fetcher-backed hook. The hook suspends until the
8
+ * first fetch succeeds, so `data` is always present once your component renders.
9
+ *
10
+ * @public
11
+ */
12
+ export interface FetcherHookResult<TData> {
13
+ /** The resolved data. Guaranteed present — the hook suspends until the first fetch succeeds. */
14
+ data: TData
15
+ /** A fetch for this entry is in flight (background revalidation or a `refetch()`). */
16
+ isFetching: boolean
17
+ /** The most recent background-fetch failure while data still renders; cleared by the next success. */
18
+ error: unknown
19
+ /** Imperatively refetch, bypassing staleness. Resolves with the refreshed data. */
20
+ refetch: () => Promise<TData>
21
+ }
22
+
23
+ /**
24
+ * Builds a Suspense hook over a {@link Fetcher}: it suspends until the first
25
+ * success via the snapshot's `status`, throws the error arm to the nearest error
26
+ * boundary, and returns the live `{data, isFetching, error, refetch}` envelope.
27
+ *
28
+ * @internal
29
+ */
30
+ export function createFetcherHook<TParams extends unknown[], TData>(
31
+ fetcher: Fetcher<TParams, TData>,
32
+ ): (...params: TParams) => FetcherHookResult<TData> {
33
+ return function useFetcherHook(...params: TParams): FetcherHookResult<TData> {
34
+ const instance = useSanityInstance()
35
+ const source = fetcher.getState(instance, ...params)
36
+
37
+ // No entry/data yet — suspend on the first fetch (matches an 'error' below never reaching here).
38
+ if (source.getCurrent().status === 'pending') {
39
+ throw fetcher.resolveState(instance, ...params)
40
+ }
41
+
42
+ // The store replaces its cache entry on every (un)subscribe (it bumps a
43
+ // subscription counter), so `getCurrent()` hands back a fresh snapshot object
44
+ // with identical fields on each commit. Passed raw to `useSyncExternalStore`
45
+ // — which compares by `Object.is` — that reads as a perpetual change and
46
+ // re-renders forever. Reuse the previous snapshot while its fields are
47
+ // unchanged so React sees a stable reference.
48
+ const previous = useRef<FetcherSnapshot<TData> | null>(null)
49
+ const snapshot = useSyncExternalStore(source.subscribe, () => {
50
+ const next = source.getCurrent()
51
+ const prev = previous.current
52
+ if (prev && isDeepEqual(prev, next)) return prev
53
+ previous.current = next
54
+ return next
55
+ })
56
+
57
+ if (snapshot.status !== 'success') {
58
+ // 'pending' already suspended above; 'error' surfaces to the error boundary.
59
+ throw snapshot.status === 'error' ? snapshot.error : fetcher.resolveState(instance, ...params)
60
+ }
61
+
62
+ return {
63
+ data: snapshot.data,
64
+ isFetching: snapshot.isFetching,
65
+ error: snapshot.error,
66
+ refetch: () => fetcher.refetch(instance, ...params),
67
+ }
68
+ }
69
+ }