@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
@@ -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
+ })
@@ -0,0 +1,120 @@
1
+ import {createSanityInstance, type Project, project, type StateSource} from '@sanity/sdk'
2
+ import {type FetcherSnapshot} from '@sanity/sdk/_internal'
3
+ import {type ReactNode} from 'react'
4
+ import {type Observable} from 'rxjs'
5
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
6
+
7
+ import {renderHook} from '../../../test/test-utils'
8
+ import {ResourceProvider} from '../../context/ResourceProvider'
9
+ import {SanityInstanceContext} from '../../context/SanityInstanceContext'
10
+ import {useProject} from './useProject'
11
+
12
+ vi.mock('@sanity/sdk', async (importOriginal) => {
13
+ const original = await importOriginal<typeof import('@sanity/sdk')>()
14
+ return {...original, project: {getState: vi.fn(), resolveState: vi.fn()}}
15
+ })
16
+
17
+ const stateSource = (current: Project | undefined): StateSource<FetcherSnapshot<Project>> => {
18
+ // Cache the snapshot: useSyncExternalStore requires a referentially stable current value.
19
+ const snapshot = current
20
+ ? {status: 'success', data: current, error: undefined, isFetching: false, dataUpdatedAt: 1}
21
+ : {
22
+ status: 'pending',
23
+ data: undefined,
24
+ error: undefined,
25
+ isFetching: true,
26
+ dataUpdatedAt: undefined,
27
+ }
28
+ return {
29
+ getCurrent: vi.fn(() => snapshot),
30
+ subscribe: vi.fn(() => () => {}),
31
+ get observable(): Observable<unknown> {
32
+ throw new Error('Not implemented')
33
+ },
34
+ } as unknown as StateSource<FetcherSnapshot<Project>>
35
+ }
36
+
37
+ const sanityInstance = expect.objectContaining({config: expect.any(Object)})
38
+
39
+ describe('useProject', () => {
40
+ beforeEach(() => {
41
+ vi.clearAllMocks()
42
+ vi.mocked(project.getState).mockReturnValue(stateSource({id: 'p'} as unknown as Project))
43
+ })
44
+
45
+ it('resolves the projectId from the instance config resource', () => {
46
+ // test-utils wraps with ResourceProvider projectId="test" dataset="test".
47
+ renderHook(() => useProject())
48
+ expect(project.getState).toHaveBeenCalledWith(
49
+ sanityInstance,
50
+ expect.objectContaining({projectId: 'test'}),
51
+ )
52
+ })
53
+
54
+ it('lets an explicit projectId override the ambient resource', () => {
55
+ renderHook(() => useProject({projectId: 'explicit-project'}))
56
+ expect(project.getState).toHaveBeenCalledWith(
57
+ sanityInstance,
58
+ expect.objectContaining({projectId: 'explicit-project'}),
59
+ )
60
+ })
61
+
62
+ it('resolves the projectId from an explicit resource when the config has none', () => {
63
+ renderHook(() => useProject(), {
64
+ wrapper: ({children}: {children: ReactNode}) => (
65
+ <ResourceProvider
66
+ resource={{projectId: 'resource-project', dataset: 'production'}}
67
+ fallback={null}
68
+ >
69
+ {children}
70
+ </ResourceProvider>
71
+ ),
72
+ })
73
+ expect(project.getState).toHaveBeenCalledWith(
74
+ sanityInstance,
75
+ expect.objectContaining({projectId: 'resource-project'}),
76
+ )
77
+ })
78
+
79
+ it('resolves a dataset-less projectId config for project-scoped use', () => {
80
+ renderHook(() => useProject(), {
81
+ wrapper: ({children}: {children: ReactNode}) => (
82
+ <ResourceProvider projectId="config-project" fallback={null}>
83
+ {children}
84
+ </ResourceProvider>
85
+ ),
86
+ })
87
+ // A dataset-less config can't form a DatasetResource; the projectId is carried
88
+ // via ProjectContext and injected so project-scoped reads still resolve it.
89
+ expect(project.getState).toHaveBeenCalledWith(
90
+ sanityInstance,
91
+ expect.objectContaining({projectId: 'config-project'}),
92
+ )
93
+ })
94
+
95
+ it('resolves a bare projectId from a ResourceProvider when the parent instance has no config', () => {
96
+ // An instance with no project/dataset config and no ambient
97
+ // resource, then a projectId-only ResourceProvider.
98
+ const emptyInstance = createSanityInstance({})
99
+ renderHook(() => useProject(), {
100
+ wrapper: ({children}: {children: ReactNode}) => (
101
+ <SanityInstanceContext.Provider value={emptyInstance}>
102
+ <ResourceProvider projectId="bare-project" fallback={null}>
103
+ {children}
104
+ </ResourceProvider>
105
+ </SanityInstanceContext.Provider>
106
+ ),
107
+ })
108
+ expect(project.getState).toHaveBeenCalledWith(
109
+ emptyInstance,
110
+ expect.objectContaining({projectId: 'bare-project'}),
111
+ )
112
+ })
113
+
114
+ it('suspends via the project fetcher until project data is available', () => {
115
+ vi.mocked(project.getState).mockReturnValue(stateSource(undefined))
116
+ vi.mocked(project.resolveState).mockReturnValue(new Promise(() => {}))
117
+ renderHook(() => useProject())
118
+ expect(project.resolveState).toHaveBeenCalled()
119
+ })
120
+ })
@@ -1,50 +1,49 @@
1
- import {
2
- getProjectState,
3
- type ProjectHandle,
4
- resolveProject,
5
- type SanityInstance,
6
- type SanityProject,
7
- type StateSource,
8
- } from '@sanity/sdk'
1
+ import {type Project, project, type ProjectOptions} from '@sanity/sdk'
9
2
 
10
- import {createStateSourceHook} from '../helpers/createStateSourceHook'
3
+ import {createFetcherHook, type FetcherHookResult} from '../helpers/createFetcherHook'
4
+ import {useResolvedProjectId} from '../helpers/useResolvedProjectId'
11
5
 
12
- type UseProject = {
13
- /**
14
- *
15
- * Returns metadata for a given project
16
- *
17
- * @category Projects
18
- * @param projectHandle - An optional project handle identifying which project to retrieve metadata for
19
- * @returns The metadata for the project
20
- * @example
21
- * ```tsx
22
- * function ProjectMetadata({ projectId }: { projectId: string }) {
23
- * const project = useProject({ projectId })
24
- *
25
- * return (
26
- * <figure style={{ backgroundColor: project.metadata.color || 'lavender'}}>
27
- * <h1>{project.displayName}</h1>
28
- * </figure>
29
- * )
30
- * }
31
- * ```
32
- */
33
- (projectHandle?: ProjectHandle): SanityProject
34
- }
6
+ const useProjectBase = createFetcherHook(project)
35
7
 
36
8
  /**
9
+ * Returns metadata for a given project.
10
+ *
11
+ * @category Projects
12
+ * @param options - Configuration options
13
+ * @returns A {@link FetcherHookResult} whose `data` is the metadata for the
14
+ * project. `members` is included only when `includeMembers: true`; `features`
15
+ * is included unless `includeFeatures: false`.
16
+ * @example
17
+ * ```tsx
18
+ * function ProjectMetadata({projectId}: {projectId: string}) {
19
+ * const {data: project} = useProject({projectId})
20
+ *
21
+ * return (
22
+ * <figure style={{backgroundColor: project.metadata.color || 'lavender'}}>
23
+ * <h1>{project.displayName}</h1>
24
+ * </figure>
25
+ * )
26
+ * }
27
+ * ```
28
+ * @example
29
+ * ```tsx
30
+ * const {data: projectWithMembersAndFeatures} = useProject({projectId})
31
+ * const {data: projectWithMembers} = useProject({projectId, includeMembers: true})
32
+ * const {data: projectWithoutMembers} = useProject({projectId, includeMembers: false})
33
+ * const {data: projectWithoutFeatures} = useProject({projectId, includeFeatures: false})
34
+ * ```
35
+ * @remarks
36
+ * The `projectId` is resolved in order from:
37
+ * 1. an explicit `projectId` option
38
+ * 2. A legacy ProjectContext (e.g. a `<ResourceProvider projectId="…">` with no dataset), then
39
+ * 3. The active resource (`ResourceProvider`/`SDKProvider`)
40
+ * 4. `instance.config`.
37
41
  * @public
38
42
  * @function
39
43
  */
40
- export const useProject: UseProject = createStateSourceHook({
41
- // remove `undefined` since we're suspending when that is the case
42
- getState: getProjectState as (
43
- instance: SanityInstance,
44
- projectHandle?: ProjectHandle,
45
- ) => StateSource<SanityProject>,
46
- shouldSuspend: (instance: SanityInstance, projectHandle?: ProjectHandle) =>
47
- getProjectState(instance, projectHandle as ProjectHandle).getCurrent() === undefined,
48
- suspender: (instance: SanityInstance, projectHandle?: ProjectHandle) =>
49
- resolveProject(instance, projectHandle as ProjectHandle),
50
- }) as UseProject
44
+ export const useProject = ((options?: ProjectOptions<boolean, boolean>) => {
45
+ const projectId = useResolvedProjectId(options)
46
+ return useProjectBase(projectId ? {...options, projectId} : options)
47
+ }) as <IncludeMembers extends boolean = true, IncludeFeatures extends boolean = true>(
48
+ options?: ProjectOptions<IncludeMembers, IncludeFeatures>,
49
+ ) => FetcherHookResult<Project<IncludeMembers, IncludeFeatures>>
@@ -0,0 +1,60 @@
1
+ import {type Project, type ProjectMember} from '@sanity/sdk'
2
+ import {expectTypeOf, test} from 'vitest'
3
+
4
+ import {useProjects} from './useProjects'
5
+
6
+ test('useProjects — no args: features included, members omitted', () => {
7
+ expectTypeOf(useProjects().data).toEqualTypeOf<Project<false, true>[]>()
8
+ })
9
+
10
+ test('useProjects — returns the FetcherHookResult envelope', () => {
11
+ const result = useProjects()
12
+ expectTypeOf(result.isFetching).toEqualTypeOf<boolean>()
13
+ expectTypeOf(result.error).toEqualTypeOf<unknown>()
14
+ expectTypeOf(result.refetch).toEqualTypeOf<() => Promise<Project<false, true>[]>>()
15
+ })
16
+
17
+ test('useProjects — includeMembers: true adds members to the type', () => {
18
+ expectTypeOf(useProjects({includeMembers: true}).data).toEqualTypeOf<Project<true, true>[]>()
19
+ type Result = ReturnType<typeof useProjects<true, true>>
20
+ expectTypeOf<Result['data'][number]['members']>().toEqualTypeOf<ProjectMember[]>()
21
+ })
22
+
23
+ test('useProjects — includeFeatures: false drops features from the type', () => {
24
+ expectTypeOf(useProjects({includeFeatures: false}).data).toEqualTypeOf<Project<false, false>[]>()
25
+ })
26
+
27
+ test('useProjects — both flags true → both arrays present', () => {
28
+ expectTypeOf(useProjects({includeMembers: true, includeFeatures: true}).data).toEqualTypeOf<
29
+ Project<true, true>[]
30
+ >()
31
+ })
32
+
33
+ test('useProjects — both flags false → bare base shape', () => {
34
+ expectTypeOf(useProjects({includeMembers: false, includeFeatures: false}).data).toEqualTypeOf<
35
+ Project<false, false>[]
36
+ >()
37
+ type Result = ReturnType<typeof useProjects<false, false>>
38
+ expectTypeOf<Result['data'][number]['id']>().toEqualTypeOf<string>()
39
+ })
40
+
41
+ test('useProjects — rejects non-boolean flag values', () => {
42
+ // @ts-expect-error — includeMembers must be a boolean
43
+ void useProjects({includeMembers: 'yes'})
44
+ })
45
+
46
+ test('useProjects — organizationId alone does not change the data shape', () => {
47
+ expectTypeOf(useProjects({organizationId: 'org_123'}).data).toEqualTypeOf<
48
+ Project<false, true>[]
49
+ >()
50
+ })
51
+
52
+ test('useProjects — non-literal boolean flag makes members optional', () => {
53
+ const includeMembers = false as boolean
54
+ expectTypeOf(useProjects({includeMembers}).data).toEqualTypeOf<Project<boolean, true>[]>()
55
+ type Result = ReturnType<typeof useProjects<boolean, true>>
56
+ expectTypeOf<Result['data'][number]['members']>().toEqualTypeOf<ProjectMember[] | undefined>()
57
+ expectTypeOf<Pick<Result['data'][number], 'members'>>().toEqualTypeOf<{
58
+ members?: ProjectMember[]
59
+ }>()
60
+ })