@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,18 @@
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'
7
- import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
11
+ import {
12
+ useNormalizedResourceOptions,
13
+ type WithResourceNameSupport,
14
+ } from '../helpers/useNormalizedResourceOptions'
15
+ import {useTrackHookUsage} from '../helpers/useTrackHookUsage'
8
16
  import {useQuery} from '../query/useQuery'
9
17
 
10
18
  /**
@@ -17,10 +25,9 @@ export interface PaginatedDocumentsOptions<
17
25
  TDocumentType extends string = string,
18
26
  TDataset extends string = string,
19
27
  TProjectId extends string = string,
20
- >
21
- extends
22
- ResourceHandle<TProjectId, TDataset>,
23
- Pick<QueryOptions<TDocumentType, TDataset, TProjectId>, 'params'> {
28
+ > extends WithResourceNameSupport<
29
+ Omit<QueryOptions<TDocumentType, TDataset, TProjectId>, 'query'>
30
+ > {
24
31
  documentType?: TDocumentType | TDocumentType[]
25
32
  /**
26
33
  * GROQ filter expression to apply to the query
@@ -141,16 +148,18 @@ export interface PaginatedDocumentsResponse<
141
148
  * @returns An object containing the list of document handles, pagination details, and functions to navigate between pages
142
149
  *
143
150
  * @remarks
144
- * - The returned document handles include resource information from the current Sanity instance
151
+ * - The returned document handles include projectId and dataset information from the current Sanity instance
145
152
  * - 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
153
+ * - The hook automatically uses the correct Sanity instance based on the projectId and dataset in the options
147
154
  *
148
155
  * @example Paginated list of documents with navigation
149
156
  * ```tsx
150
157
  * import {
151
158
  * usePaginatedDocuments,
159
+ * createDatasetHandle,
160
+ * type DatasetHandle,
152
161
  * type DocumentHandle,
153
- * type DocumentResource,
162
+ * type SortOrderingItem,
154
163
  * useDocumentProjection
155
164
  * } from '@sanity/sdk-react'
156
165
  * import {Suspense} from 'react'
@@ -173,10 +182,10 @@ export interface PaginatedDocumentsResponse<
173
182
  * // Define props for the list component
174
183
  * interface PaginatedDocumentListProps {
175
184
  * documentType: string
176
- * resource?: DocumentResource
185
+ * dataset?: DatasetHandle
177
186
  * }
178
187
  *
179
- * function PaginatedDocumentList({documentType, resource}: PaginatedDocumentListProps) {
188
+ * function PaginatedDocumentList({documentType, dataset}: PaginatedDocumentListProps) {
180
189
  * const {
181
190
  * data,
182
191
  * isPending,
@@ -187,7 +196,7 @@ export interface PaginatedDocumentsResponse<
187
196
  * hasNextPage,
188
197
  * hasPreviousPage
189
198
  * } = usePaginatedDocuments({
190
- * resource,
199
+ * ...dataset,
191
200
  * documentType,
192
201
  * pageSize: 10,
193
202
  * orderings: [{field: '_createdAt', direction: 'desc'}],
@@ -219,7 +228,8 @@ export interface PaginatedDocumentsResponse<
219
228
  * }
220
229
  *
221
230
  * // Usage:
222
- * // <PaginatedDocumentList resource={{projectId: 'p1', dataset: 'production'}} documentType="post" />
231
+ * // const myDatasetHandle = createDatasetHandle({ projectId: 'p1', dataset: 'production' })
232
+ * // <PaginatedDocumentList dataset={myDatasetHandle} documentType="post" />
223
233
  * ```
224
234
  */
225
235
  export function usePaginatedDocuments<
@@ -239,14 +249,16 @@ export function usePaginatedDocuments<
239
249
  TDataset,
240
250
  TProjectId
241
251
  > {
252
+ useTrackHookUsage('usePaginatedDocuments')
242
253
  const options = useNormalizedResourceOptions(rawOptions)
243
254
  const [pageIndex, setPageIndex] = useState(0)
244
255
  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(() => {
256
+ // Reset pageIndex to 0 whenever any query parameter changes.
257
+ const [prevKey, setPrevKey] = useState(key)
258
+ if (prevKey !== key) {
259
+ setPrevKey(key)
248
260
  setPageIndex(0)
249
- }, [key])
261
+ }
250
262
 
251
263
  const startIndex = pageIndex * pageSize
252
264
  const endIndex = (pageIndex + 1) * pageSize
@@ -301,8 +313,14 @@ export function usePaginatedDocuments<
301
313
  params: {
302
314
  ...params,
303
315
  __types: documentTypes,
304
- // these are passed back to the user as part of each document handle
305
- __handle: pick(options, ['resource', 'perspective']),
316
+ __handle: {
317
+ // keep projectId/dataset for backward compat until v4; resource is added
318
+ // intentionally so that hook consumers can resolve the correct resource
319
+ ...(options.resource && isDatasetResource(options.resource)
320
+ ? pickProperties(options.resource, ['projectId', 'dataset'])
321
+ : {}),
322
+ ...pickProperties(options, ['perspective', 'resource']),
323
+ },
306
324
  },
307
325
  })
308
326
 
@@ -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,27 @@
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
+ * A hook for subscribing to presence information for the current project or Canvas.
10
11
  * @public
11
12
  */
12
13
  export function usePresence(options: ResourceHandle = {}): {
13
14
  locations: UserPresence[]
14
15
  } {
15
16
  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
- }
17
+ if (normalizedOptions.resource && isMediaLibraryResource(normalizedOptions.resource)) {
18
+ throw new Error(
19
+ 'usePresence() does not support media library resources. Presence tracking requires a canvas or dataset resource.',
20
+ )
34
21
  }
35
22
 
23
+ const sanityInstance = useSanityInstance()
24
+ trackHookUsage(sanityInstance, 'usePresence')
36
25
  const source = useMemo(
37
26
  () => getPresence(sanityInstance, normalizedOptions),
38
27
  [sanityInstance, normalizedOptions],
@@ -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', () => {
@@ -9,6 +9,7 @@ import {useMemo} from 'react'
9
9
  import {type DocumentHandle} from '../../config/handles'
10
10
  import {useSanityInstance} from '../context/useSanityInstance'
11
11
  import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
12
+ import {trackHookUsage} from '../helpers/useTrackHookUsage'
12
13
  import {useDocumentProjection} from '../projection/useDocumentProjection'
13
14
 
14
15
  /**
@@ -60,9 +61,9 @@ export interface useDocumentPreviewResults {
60
61
  *
61
62
  * @example Combining with useDocuments to render a collection of document previews
62
63
  * ```
63
- * // PreviewComponent.tsx
64
- * export default function PreviewComponent(docHandle: DocumentHandle) {
65
- * const { data: { title, subtitle, media }, isPending } = useDocumentPreview(docHandle)
64
+ * // PreviewComponent.jsx
65
+ * export default function PreviewComponent({ document }) {
66
+ * const { data: { title, subtitle, media }, isPending } = useDocumentPreview({ document })
66
67
  * return (
67
68
  * <article style={{ opacity: isPending ? 0.5 : 1}}>
68
69
  * {media?.type === 'image-asset' ? <img src={media.url} alt='' /> : ''}
@@ -72,16 +73,16 @@ export interface useDocumentPreviewResults {
72
73
  * )
73
74
  * }
74
75
  *
75
- * // DocumentList.tsx
76
- * const { data } = useDocuments({ documentType: 'movie' })
76
+ * // DocumentList.jsx
77
+ * const { data } = useDocuments({ filter: '_type == "movie"' })
77
78
  * return (
78
79
  * <div>
79
80
  * <h1>Movies</h1>
80
81
  * <ul>
81
82
  * {data.map(movie => (
82
- * <li key={movie.documentId}>
83
+ * <li key={movie._id}>
83
84
  * <Suspense fallback='Loading…'>
84
- * <PreviewComponent {...movie} />
85
+ * <PreviewComponent document={movie} />
85
86
  * </Suspense>
86
87
  * </li>
87
88
  * ))}
@@ -95,6 +96,7 @@ export function useDocumentPreview({
95
96
  ...docHandle
96
97
  }: useDocumentPreviewOptions): useDocumentPreviewResults {
97
98
  const instance = useSanityInstance()
99
+ trackHookUsage(instance, 'useDocumentPreview')
98
100
  const normalizedDocHandle = useNormalizedResourceOptions(docHandle)
99
101
 
100
102
  // Use the projection hook with the fixed preview projection
@@ -108,7 +110,7 @@ export function useDocumentPreview({
108
110
  // Keep this non-null assumption aligned with useDocumentPreviewResults.data.
109
111
  const previewValue = useMemo(
110
112
  () =>
111
- transformProjectionToPreview(instance, normalizedDocHandle.resource, projectionResult.data),
113
+ transformProjectionToPreview(instance, projectionResult.data, normalizedDocHandle.resource),
112
114
  [projectionResult.data, instance, normalizedDocHandle.resource],
113
115
  )
114
116
 
@@ -1,8 +1,9 @@
1
1
  import {type DocumentHandle, getProjectionState, resolveProjection} from '@sanity/sdk'
2
+ import {act, render, screen} from '@testing-library/react'
2
3
  import {useRef} from 'react'
3
4
  import {type Mock} from 'vitest'
4
5
 
5
- import {act, render, screen} from '../../../test/test-utils'
6
+ import {ResourceProvider} from '../../context/ResourceProvider'
6
7
  import {useDocumentProjection} from './useDocumentProjection'
7
8
 
8
9
  // Mock IntersectionObserver
@@ -39,7 +40,6 @@ vi.mock('@sanity/sdk', async (importOriginal) => {
39
40
  const mockDocument: DocumentHandle = {
40
41
  documentId: 'doc1',
41
42
  documentType: 'exampleType',
42
- resource: {projectId: 'p', dataset: 'd'},
43
43
  }
44
44
 
45
45
  interface ProjectionResult {
@@ -85,7 +85,11 @@ describe('useDocumentProjection', () => {
85
85
  const eventsUnsubscribe = vi.fn()
86
86
  subscribe.mockImplementation(() => eventsUnsubscribe)
87
87
 
88
- render(<TestComponent document={mockDocument} projection="{name, description}" />)
88
+ render(
89
+ <ResourceProvider fallback={<div>Loading...</div>}>
90
+ <TestComponent document={mockDocument} projection="{name, description}" />
91
+ </ResourceProvider>,
92
+ )
89
93
 
90
94
  // Initially, element is not intersecting
91
95
  expect(screen.getByText('Initial Title')).toBeInTheDocument()
@@ -110,6 +114,47 @@ describe('useDocumentProjection', () => {
110
114
  expect(eventsUnsubscribe).toHaveBeenCalled()
111
115
  })
112
116
 
117
+ test('it re-reads the current snapshot when the element becomes visible again after an off-screen update', async () => {
118
+ // The store notifies subscribers only on changes *after* subscribe (it skips the
119
+ // value current at subscribe time), so the mocked subscribe never invokes its
120
+ // callback — matching real behavior. The hook must therefore re-read getCurrent()
121
+ // itself whenever the visibility gate (re)opens, otherwise a value that updated
122
+ // while the element was off-screen stays frozen on screen.
123
+ getCurrent.mockReturnValue({
124
+ data: {title: 'Initial Title', description: 'Initial Description'},
125
+ isPending: false,
126
+ })
127
+ const eventsUnsubscribe = vi.fn()
128
+ subscribe.mockImplementation(() => eventsUnsubscribe)
129
+
130
+ render(
131
+ <ResourceProvider fallback={<div>Loading...</div>}>
132
+ <TestComponent document={mockDocument} projection="{name, description}" />
133
+ </ResourceProvider>,
134
+ )
135
+
136
+ // Become visible, then hidden — mirrors a card being scrolled out of view.
137
+ await act(async () => {
138
+ intersectionObserverCallback([{isIntersecting: true} as IntersectionObserverEntry])
139
+ })
140
+ await act(async () => {
141
+ intersectionObserverCallback([{isIntersecting: false} as IntersectionObserverEntry])
142
+ })
143
+
144
+ // The underlying document changes while the element is off-screen.
145
+ getCurrent.mockReturnValue({
146
+ data: {title: 'Updated Title', description: 'Updated Description'},
147
+ isPending: false,
148
+ })
149
+
150
+ // Becoming visible again must surface the value that changed while hidden.
151
+ await act(async () => {
152
+ intersectionObserverCallback([{isIntersecting: true} as IntersectionObserverEntry])
153
+ })
154
+
155
+ expect(screen.getByText('Updated Title')).toBeInTheDocument()
156
+ })
157
+
113
158
  test('it suspends and resolves data when element becomes visible', async () => {
114
159
  // Mock the initial state to trigger suspense
115
160
  getCurrent.mockReturnValueOnce({
@@ -131,7 +176,11 @@ describe('useDocumentProjection', () => {
131
176
  // Setup subscription that does nothing (we'll manually trigger updates)
132
177
  subscribe.mockReturnValue(() => {})
133
178
 
134
- render(<TestComponent document={mockDocument} projection="{title, description}" />)
179
+ render(
180
+ <ResourceProvider fallback={<div>Loading...</div>}>
181
+ <TestComponent document={mockDocument} projection="{title, description}" />
182
+ </ResourceProvider>,
183
+ )
135
184
 
136
185
  await act(async () => {
137
186
  intersectionObserverCallback([{isIntersecting: true} as IntersectionObserverEntry])
@@ -154,7 +203,11 @@ describe('useDocumentProjection', () => {
154
203
  })
155
204
  subscribe.mockImplementation(() => vi.fn())
156
205
 
157
- render(<TestComponent document={mockDocument} projection="{title, description}" />)
206
+ render(
207
+ <ResourceProvider fallback={<div>Loading...</div>}>
208
+ <TestComponent document={mockDocument} projection="{title, description}" />
209
+ </ResourceProvider>,
210
+ )
158
211
 
159
212
  expect(screen.getByText('Fallback Title')).toBeInTheDocument()
160
213
 
@@ -170,7 +223,11 @@ describe('useDocumentProjection', () => {
170
223
  const eventsUnsubscribe = vi.fn()
171
224
  subscribe.mockImplementation(() => eventsUnsubscribe)
172
225
 
173
- const {rerender} = render(<TestComponent document={mockDocument} projection="{title}" />)
226
+ const {rerender} = render(
227
+ <ResourceProvider fallback={<div>Loading...</div>}>
228
+ <TestComponent document={mockDocument} projection="{title}" />
229
+ </ResourceProvider>,
230
+ )
174
231
 
175
232
  // Change projection
176
233
  getCurrent.mockReturnValue({
@@ -178,7 +235,11 @@ describe('useDocumentProjection', () => {
178
235
  isPending: false,
179
236
  })
180
237
 
181
- rerender(<TestComponent document={mockDocument} projection="{title, description}" />)
238
+ rerender(
239
+ <ResourceProvider fallback={<div>Loading...</div>}>
240
+ <TestComponent document={mockDocument} projection="{title, description}" />
241
+ </ResourceProvider>,
242
+ )
182
243
 
183
244
  expect(screen.getByText('Updated Title')).toBeInTheDocument()
184
245
  expect(screen.getByText('Added Description')).toBeInTheDocument()
@@ -202,7 +263,11 @@ describe('useDocumentProjection', () => {
202
263
  )
203
264
  }
204
265
 
205
- render(<NoRefComponent {...mockDocument} projection="{title, description}" />)
266
+ render(
267
+ <ResourceProvider fallback={<div>Loading...</div>}>
268
+ <NoRefComponent {...mockDocument} projection="{title, description}" />
269
+ </ResourceProvider>,
270
+ )
206
271
 
207
272
  // Should subscribe immediately without waiting for intersection
208
273
  expect(subscribe).toHaveBeenCalled()
@@ -231,7 +296,11 @@ describe('useDocumentProjection', () => {
231
296
  )
232
297
  }
233
298
 
234
- render(<NonHtmlRefComponent {...mockDocument} projection="{title, description}" />)
299
+ render(
300
+ <ResourceProvider fallback={<div>Loading...</div>}>
301
+ <NonHtmlRefComponent {...mockDocument} projection="{title, description}" />
302
+ </ResourceProvider>,
303
+ )
235
304
 
236
305
  // Should subscribe immediately without waiting for intersection
237
306
  expect(subscribe).toHaveBeenCalled()
@@ -1,10 +1,12 @@
1
1
  import {getProjectionState, resolveProjection} from '@sanity/sdk'
2
+ import {type SanityProjectionResult} from 'groq'
2
3
  import {useCallback, useMemo, useSyncExternalStore} from 'react'
3
4
  import {distinctUntilChanged, EMPTY, Observable, startWith, switchMap} from 'rxjs'
4
5
 
5
6
  import {type DocumentHandle} from '../../config/handles'
6
7
  import {useSanityInstance} from '../context/useSanityInstance'
7
8
  import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
9
+ import {trackHookUsage} from '../helpers/useTrackHookUsage'
8
10
 
9
11
  /**
10
12
  * @public
@@ -45,13 +47,83 @@ export interface useDocumentProjectionResults<TData> {
45
47
  *
46
48
  * @category Documents
47
49
  * @remarks
48
- * This hook allows specifying an explicit type parameter `TData` for the projected result.
50
+ * This hook has multiple signatures allowing for fine-grained control over type inference:
51
+ * - Using Typegen: Infers the return type based on the `documentType`, `dataset`, `projectId`, and `projection`.
52
+ * - Using explicit type parameter: Allows specifying a custom return type `TData`.
49
53
  *
50
54
  * @param options - An object containing the `DocumentHandle` properties (`documentId`, `documentType`, etc.), the `projection` string, optional `params`, and an optional `ref`.
51
55
  * @returns An object containing the projection results (`data`) and a boolean indicating whether the resolution is pending (`isPending`). Note: Suspense handles initial loading states; `data` being `undefined` after initial loading means the document doesn't exist or the projection yielded no result.
52
56
  */
53
57
 
54
- // Overload 1: Explicit type provided
58
+ // Overload 1: Relies on Typegen
59
+ /**
60
+ * @public
61
+ * Fetch a projection, relying on Typegen for the return type based on the handle and projection.
62
+ *
63
+ * @category Documents
64
+ * @param options - Options including the document handle properties (`documentId`, `documentType`, etc.) and the `projection`.
65
+ * @returns The projected data, typed based on Typegen.
66
+ *
67
+ * @example Using Typegen for a book preview
68
+ * ```tsx
69
+ * // ProjectionComponent.tsx
70
+ * import {useDocumentProjection, type DocumentHandle} from '@sanity/sdk-react'
71
+ * import {useRef} from 'react'
72
+ * import {defineProjection} from 'groq'
73
+ *
74
+ * // Define props using DocumentHandle with the specific document type
75
+ * type ProjectionComponentProps = {
76
+ * doc: DocumentHandle<'book'> // Typegen knows 'book'
77
+ * }
78
+ *
79
+ * // This is required for typegen to generate the correct return type
80
+ * const myProjection = defineProjection(`{
81
+ * title,
82
+ * 'coverImage': cover.asset->url,
83
+ * 'authors': array::join(authors[]->{'name': firstName + ' ' + lastName}.name, ', ')
84
+ * }`)
85
+ *
86
+ * export default function ProjectionComponent({ doc }: ProjectionComponentProps) {
87
+ * const ref = useRef(null) // Optional ref to track viewport intersection for lazy loading
88
+ *
89
+ * // Spread the doc handle into the options
90
+ * // Typegen infers the return type based on 'book' and the projection
91
+ * const { data } = useDocumentProjection({
92
+ * ...doc, // Pass the handle properties
93
+ * ref,
94
+ * projection: myProjection,
95
+ * })
96
+ *
97
+ * // Suspense handles initial load, check for data existence after
98
+ * return (
99
+ * <article ref={ref}>
100
+ * <h2>{data.title ?? 'Untitled'}</h2>
101
+ * {data.coverImage && <img src={data.coverImage} alt={data.title} />}
102
+ * <p>{data.authors ?? 'Unknown authors'}</p>
103
+ * </article>
104
+ * )
105
+ * }
106
+ *
107
+ * // Usage:
108
+ * // import {createDocumentHandle} from '@sanity/sdk-react'
109
+ * // const myDocHandle = createDocumentHandle({ documentId: 'book123', documentType: 'book' })
110
+ * // <Suspense fallback='Loading preview...'>
111
+ * // <ProjectionComponent doc={myDocHandle} />
112
+ * // </Suspense>
113
+ * ```
114
+ */
115
+ export function useDocumentProjection<
116
+ TProjection extends string = string,
117
+ TDocumentType extends string = string,
118
+ TDataset extends string = string,
119
+ TProjectId extends string = string,
120
+ >(
121
+ options: useDocumentProjectionOptions<TProjection, TDocumentType, TDataset, TProjectId>,
122
+ ): useDocumentProjectionResults<
123
+ SanityProjectionResult<TProjection, TDocumentType, `${TProjectId}.${TDataset}`>
124
+ >
125
+
126
+ // Overload 2: Explicit type provided
55
127
  /**
56
128
  * @public
57
129
  * Fetch a projection with an explicitly defined return type `TData`.
@@ -108,6 +180,7 @@ export function useDocumentProjection<TData extends object>({
108
180
  ...docHandle
109
181
  }: useDocumentProjectionOptions): useDocumentProjectionResults<TData> {
110
182
  const instance = useSanityInstance()
183
+ trackHookUsage(instance, 'useDocumentProjection')
111
184
 
112
185
  // Normalize projection string to handle template literals with whitespace
113
186
  // This ensures that the same projection content produces the same state source
@@ -162,6 +235,12 @@ export function useDocumentProjection<TData extends object>({
162
235
  switchMap((isVisible) =>
163
236
  isVisible
164
237
  ? new Observable<void>((obs) => {
238
+ // `stateSource.subscribe` skips the value current at subscribe time;
239
+ // (intentionally -- we only want new events)
240
+ // but in this case the store might have updated while the element was off-screen;
241
+ // so we fire an immediate notification here to make useSyncExternalStore
242
+ // re-read getCurrent() and pick up the fresh value.
243
+ obs.next()
165
244
  return stateSource.subscribe(() => obs.next())
166
245
  })
167
246
  : EMPTY,
@@ -0,0 +1,56 @@
1
+ import {type Project, type ProjectMember} from '@sanity/sdk'
2
+ import {expectTypeOf, test} from 'vitest'
3
+
4
+ import {useProject} from './useProject'
5
+
6
+ test('useProject — no args: members and features both included by default', () => {
7
+ expectTypeOf(useProject().data).toEqualTypeOf<Project<true, true>>()
8
+ type Result = ReturnType<typeof useProject<true, true>>
9
+ expectTypeOf<Result['data']['members']>().toEqualTypeOf<ProjectMember[]>()
10
+ })
11
+
12
+ test('useProject — returns the FetcherHookResult envelope', () => {
13
+ const result = useProject()
14
+ expectTypeOf(result.isFetching).toEqualTypeOf<boolean>()
15
+ expectTypeOf(result.error).toEqualTypeOf<unknown>()
16
+ expectTypeOf(result.refetch).toEqualTypeOf<() => Promise<Project<true, true>>>()
17
+ })
18
+
19
+ test('useProject — includeMembers: false drops members from the type', () => {
20
+ expectTypeOf(useProject({includeMembers: false}).data).toEqualTypeOf<Project<false, true>>()
21
+ })
22
+
23
+ test('useProject — includeFeatures: false drops features from the type', () => {
24
+ expectTypeOf(useProject({includeFeatures: false}).data).toEqualTypeOf<Project<true, false>>()
25
+ })
26
+
27
+ test('useProject — both flags true → both arrays present', () => {
28
+ expectTypeOf(useProject({includeMembers: true, includeFeatures: true}).data).toEqualTypeOf<
29
+ Project<true, true>
30
+ >()
31
+ })
32
+
33
+ test('useProject — both flags false → bare base shape', () => {
34
+ expectTypeOf(useProject({includeMembers: false, includeFeatures: false}).data).toEqualTypeOf<
35
+ Project<false, false>
36
+ >()
37
+ type Result = ReturnType<typeof useProject<false, false>>
38
+ expectTypeOf<Result['data']['id']>().toEqualTypeOf<string>()
39
+ })
40
+
41
+ test('useProject — rejects non-boolean flag values', () => {
42
+ // @ts-expect-error — includeMembers must be a boolean
43
+ void useProject({includeMembers: 'yes'})
44
+ })
45
+
46
+ test('useProject — projectId alone does not change the data shape', () => {
47
+ expectTypeOf(useProject({projectId: 'p'}).data).toEqualTypeOf<Project<true, true>>()
48
+ })
49
+
50
+ test('useProject — non-literal boolean flag makes members optional', () => {
51
+ const includeMembers = false as boolean
52
+ expectTypeOf(useProject({includeMembers}).data).toEqualTypeOf<Project<boolean, true>>()
53
+ type Result = ReturnType<typeof useProject<boolean, true>>
54
+ expectTypeOf<Result['data']['members']>().toEqualTypeOf<ProjectMember[] | undefined>()
55
+ expectTypeOf<Pick<Result['data'], 'members'>>().toEqualTypeOf<{members?: ProjectMember[]}>()
56
+ })