@sanity/sdk-react 0.0.0-alpha.21 → 0.0.0-alpha.23

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 (71) hide show
  1. package/dist/index.d.ts +502 -3460
  2. package/dist/index.js +400 -465
  3. package/dist/index.js.map +1 -1
  4. package/package.json +17 -15
  5. package/src/_exports/index.ts +4 -5
  6. package/src/components/SDKProvider.test.tsx +78 -54
  7. package/src/components/SDKProvider.tsx +31 -26
  8. package/src/components/SanityApp.test.tsx +121 -15
  9. package/src/components/SanityApp.tsx +26 -15
  10. package/src/components/auth/AuthBoundary.test.tsx +32 -14
  11. package/src/components/auth/AuthBoundary.tsx +53 -23
  12. package/src/components/auth/LoginCallback.test.tsx +19 -6
  13. package/src/components/auth/LoginCallback.tsx +2 -11
  14. package/src/components/auth/LoginError.test.tsx +12 -4
  15. package/src/components/auth/LoginError.tsx +13 -21
  16. package/src/components/auth/LoginFooter.test.tsx +7 -3
  17. package/src/context/ResourceProvider.test.tsx +157 -0
  18. package/src/context/ResourceProvider.tsx +111 -0
  19. package/src/context/SanityInstanceContext.ts +1 -1
  20. package/src/hooks/auth/useLoginUrl.tsx +14 -0
  21. package/src/hooks/client/useClient.ts +2 -1
  22. package/src/hooks/comlink/useManageFavorite.test.ts +16 -8
  23. package/src/hooks/comlink/useManageFavorite.ts +37 -13
  24. package/src/hooks/comlink/useRecordDocumentHistoryEvent.test.ts +8 -4
  25. package/src/hooks/comlink/useRecordDocumentHistoryEvent.ts +10 -8
  26. package/src/hooks/context/useSanityInstance.test.tsx +157 -15
  27. package/src/hooks/context/useSanityInstance.ts +66 -26
  28. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +13 -31
  29. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +12 -15
  30. package/src/hooks/dashboard/{useStudioWorkspacesByResourceId.test.tsx → useStudioWorkspacesByProjectIdDataset.test.tsx} +13 -13
  31. package/src/hooks/dashboard/{useStudioWorkspacesByResourceId.ts → useStudioWorkspacesByProjectIdDataset.ts} +10 -9
  32. package/src/hooks/datasets/useDatasets.ts +15 -4
  33. package/src/hooks/document/useApplyDocumentActions.test.ts +4 -9
  34. package/src/hooks/document/useApplyDocumentActions.ts +6 -31
  35. package/src/hooks/document/useDocument.test.ts +2 -2
  36. package/src/hooks/document/useDocument.ts +40 -19
  37. package/src/hooks/document/useDocumentEvent.test.ts +2 -3
  38. package/src/hooks/document/useDocumentEvent.ts +7 -11
  39. package/src/hooks/document/useDocumentPermissions.test.ts +204 -0
  40. package/src/hooks/document/useDocumentPermissions.ts +31 -23
  41. package/src/hooks/document/useDocumentSyncStatus.ts +5 -4
  42. package/src/hooks/document/useEditDocument.test.ts +2 -3
  43. package/src/hooks/document/useEditDocument.ts +43 -29
  44. package/src/hooks/documents/useDocuments.test.tsx +30 -3
  45. package/src/hooks/documents/useDocuments.ts +20 -7
  46. package/src/hooks/helpers/createCallbackHook.test.tsx +2 -2
  47. package/src/hooks/helpers/createCallbackHook.tsx +2 -3
  48. package/src/hooks/helpers/createStateSourceHook.test.tsx +1 -1
  49. package/src/hooks/helpers/createStateSourceHook.tsx +5 -8
  50. package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +43 -18
  51. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +36 -50
  52. package/src/hooks/preview/usePreview.test.tsx +66 -7
  53. package/src/hooks/preview/usePreview.tsx +17 -12
  54. package/src/hooks/projection/useProjection.test.tsx +68 -3
  55. package/src/hooks/projection/useProjection.ts +21 -24
  56. package/src/hooks/projects/useProject.ts +7 -4
  57. package/src/hooks/query/useQuery.ts +32 -14
  58. package/src/hooks/users/useUsers.test.tsx +330 -0
  59. package/src/hooks/users/useUsers.ts +65 -52
  60. package/src/components/Login/LoginLinks.test.tsx +0 -90
  61. package/src/components/Login/LoginLinks.tsx +0 -58
  62. package/src/components/auth/Login.test.tsx +0 -27
  63. package/src/components/auth/Login.tsx +0 -39
  64. package/src/components/auth/LoginLayout.test.tsx +0 -19
  65. package/src/components/auth/LoginLayout.tsx +0 -69
  66. package/src/components/auth/authTestHelpers.tsx +0 -11
  67. package/src/context/SanityProvider.test.tsx +0 -25
  68. package/src/context/SanityProvider.tsx +0 -50
  69. package/src/hooks/auth/useLoginUrls.test.tsx +0 -68
  70. package/src/hooks/auth/useLoginUrls.tsx +0 -52
  71. package/src/hooks/users/useUsers.test.ts +0 -163
@@ -55,7 +55,7 @@ describe('createCallbackHook', () => {
55
55
  vi.mocked(useSanityInstance).mockReturnValueOnce(mockInstance1)
56
56
 
57
57
  // Create a test callback
58
- const testCallback = (instance: SanityInstance) => instance.identity.projectId
58
+ const testCallback = (instance: SanityInstance) => instance.config.projectId
59
59
 
60
60
  // Create and render our hook
61
61
  const useTestHook = createCallbackHook(testCallback)
@@ -87,7 +87,7 @@ describe('createCallbackHook', () => {
87
87
  method: string,
88
88
  data: object,
89
89
  ) => ({
90
- url: `${instance.identity.projectId}${path}`,
90
+ url: `${instance.config.projectId}${path}`,
91
91
  method,
92
92
  data,
93
93
  })
@@ -1,14 +1,13 @@
1
- import {type ResourceId, type SanityInstance} from '@sanity/sdk'
1
+ import {type SanityInstance} from '@sanity/sdk'
2
2
  import {useCallback} from 'react'
3
3
 
4
4
  import {useSanityInstance} from '../context/useSanityInstance'
5
5
 
6
6
  export function createCallbackHook<TParams extends unknown[], TReturn>(
7
7
  callback: (instance: SanityInstance, ...params: TParams) => TReturn,
8
- resourceId?: ResourceId,
9
8
  ): () => (...params: TParams) => TReturn {
10
9
  function useHook() {
11
- const instance = useSanityInstance(resourceId)
10
+ const instance = useSanityInstance()
12
11
  return useCallback((...params: TParams) => callback(instance, ...params), [instance])
13
12
  }
14
13
 
@@ -72,7 +72,7 @@ describe('createStateSourceHook', () => {
72
72
 
73
73
  const stateSourceFactory = vi.fn((instance: SanityInstance) => ({
74
74
  subscribe: vi.fn(),
75
- getCurrent: () => instance.identity.projectId,
75
+ getCurrent: () => instance.config.projectId,
76
76
  observable: throwError(() => new Error('unexpected usage of observable')),
77
77
  }))
78
78
 
@@ -1,4 +1,4 @@
1
- import {type ResourceId, type SanityInstance, type StateSource} from '@sanity/sdk'
1
+ import {type SanityConfig, type SanityInstance, type StateSource} from '@sanity/sdk'
2
2
  import {useSyncExternalStore} from 'react'
3
3
 
4
4
  import {useSanityInstance} from '../context/useSanityInstance'
@@ -12,22 +12,19 @@ interface CreateStateSourceHookOptions<TParams extends unknown[], TState> {
12
12
  getState: StateSourceFactory<TParams, TState>
13
13
  shouldSuspend?: (instance: SanityInstance, ...params: TParams) => boolean
14
14
  suspender?: (instance: SanityInstance, ...params: TParams) => Promise<unknown>
15
- getResourceId?: (...params: TParams) => ResourceId | undefined
15
+ getConfig?: (...params: TParams) => SanityConfig | undefined
16
16
  }
17
17
 
18
18
  export function createStateSourceHook<TParams extends unknown[], TState>(
19
19
  options: StateSourceFactory<TParams, TState> | CreateStateSourceHookOptions<TParams, TState>,
20
20
  ): (...params: TParams) => TState {
21
21
  const getState = typeof options === 'function' ? options : options.getState
22
- const getResourceId = 'getResourceId' in options ? options.getResourceId : undefined
22
+ const getConfig = 'getConfig' in options ? options.getConfig : undefined
23
23
  const suspense = 'shouldSuspend' in options && 'suspender' in options ? options : undefined
24
24
 
25
25
  function useHook(...params: TParams) {
26
- let resourceId: ResourceId | undefined
27
- if (getResourceId) {
28
- resourceId = getResourceId(...params)
29
- }
30
- const instance = useSanityInstance(resourceId)
26
+ const instance = useSanityInstance(getConfig?.(...params))
27
+
31
28
  if (suspense?.suspender && suspense?.shouldSuspend?.(instance, ...params)) {
32
29
  throw suspense.suspender(instance, ...params)
33
30
  }
@@ -1,6 +1,7 @@
1
1
  import {act, renderHook} from '@testing-library/react'
2
2
  import {describe, vi} from 'vitest'
3
3
 
4
+ import {ResourceProvider} from '../../context/ResourceProvider'
4
5
  import {evaluateSync, parse} from '../_synchronous-groq-js.mjs'
5
6
  import {useQuery} from '../query/useQuery'
6
7
  import {usePaginatedDocuments} from './usePaginatedDocuments'
@@ -8,6 +9,12 @@ import {usePaginatedDocuments} from './usePaginatedDocuments'
8
9
  vi.mock('../query/useQuery')
9
10
 
10
11
  describe('usePaginatedDocuments', () => {
12
+ const wrapper = ({children}: {children: React.ReactNode}) => (
13
+ <ResourceProvider projectId="p" dataset="d" fallback={null}>
14
+ {children}
15
+ </ResourceProvider>
16
+ )
17
+
11
18
  beforeEach(() => {
12
19
  const dataset = [
13
20
  {
@@ -76,42 +83,46 @@ describe('usePaginatedDocuments', () => {
76
83
 
77
84
  it('should respect custom page size', () => {
78
85
  const customPageSize = 2
79
- const {result} = renderHook(() => usePaginatedDocuments({pageSize: customPageSize}))
86
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize: customPageSize}), {wrapper})
80
87
 
81
88
  expect(result.current.pageSize).toBe(customPageSize)
82
89
  expect(result.current.data.length).toBeLessThanOrEqual(customPageSize)
83
90
  })
84
91
 
85
92
  it('should filter by document type', () => {
86
- const {result} = renderHook(() => usePaginatedDocuments({filter: '_type == "movie"'}))
93
+ const {result} = renderHook(() => usePaginatedDocuments({filter: '_type == "movie"'}), {
94
+ wrapper,
95
+ })
87
96
 
88
- expect(result.current.data.every((doc) => doc._type === 'movie')).toBe(true)
97
+ expect(result.current.data.every((doc) => doc.documentType === 'movie')).toBe(true)
89
98
  expect(result.current.count).toBe(5) // 5 movies in the dataset
90
99
  })
91
100
 
92
101
  // groq-js doesn't support search filters yet
93
102
  it.skip('should apply search filter', () => {
94
- const {result} = renderHook(() => usePaginatedDocuments({search: 'inter'}))
103
+ const {result} = renderHook(() => usePaginatedDocuments({search: 'inter'}), {wrapper})
95
104
 
96
105
  // Should match "Interstellar"
97
- expect(result.current.data.some((doc) => doc._id === 'movie3')).toBe(true)
106
+ expect(result.current.data.some((doc) => doc.documentId === 'movie3')).toBe(true)
98
107
  })
99
108
 
100
109
  it('should apply ordering', () => {
101
- const {result} = renderHook(() =>
102
- usePaginatedDocuments({
103
- filter: '_type == "movie"',
104
- orderings: [{field: 'releaseYear', direction: 'desc'}],
105
- }),
110
+ const {result} = renderHook(
111
+ () =>
112
+ usePaginatedDocuments({
113
+ filter: '_type == "movie"',
114
+ orderings: [{field: 'releaseYear', direction: 'desc'}],
115
+ }),
116
+ {wrapper},
106
117
  )
107
118
 
108
119
  // First item should be the most recent movie (Interstellar, 2014)
109
- expect(result.current.data[0]._id).toBe('movie3')
120
+ expect(result.current.data[0].documentId).toBe('movie3')
110
121
  })
111
122
 
112
123
  it('should calculate pagination values correctly', () => {
113
124
  const pageSize = 2
114
- const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
125
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}), {wrapper})
115
126
 
116
127
  expect(result.current.currentPage).toBe(1)
117
128
  expect(result.current.totalPages).toBe(3) // 6 items with page size 2
@@ -122,7 +133,7 @@ describe('usePaginatedDocuments', () => {
122
133
 
123
134
  it('should navigate to next page', () => {
124
135
  const pageSize = 2
125
- const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
136
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}), {wrapper})
126
137
 
127
138
  expect(result.current.currentPage).toBe(1)
128
139
  expect(result.current.data.length).toBe(pageSize)
@@ -138,7 +149,7 @@ describe('usePaginatedDocuments', () => {
138
149
 
139
150
  it('should navigate to previous page', () => {
140
151
  const pageSize = 2
141
- const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
152
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}), {wrapper})
142
153
 
143
154
  // Go to page 2 first
144
155
  act(() => {
@@ -158,7 +169,7 @@ describe('usePaginatedDocuments', () => {
158
169
 
159
170
  it('should navigate to first page', () => {
160
171
  const pageSize = 2
161
- const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
172
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}), {wrapper})
162
173
 
163
174
  // Go to last page first
164
175
  act(() => {
@@ -178,7 +189,7 @@ describe('usePaginatedDocuments', () => {
178
189
 
179
190
  it('should navigate to last page', () => {
180
191
  const pageSize = 2
181
- const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
192
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}), {wrapper})
182
193
 
183
194
  act(() => {
184
195
  result.current.lastPage()
@@ -190,7 +201,7 @@ describe('usePaginatedDocuments', () => {
190
201
 
191
202
  it('should navigate to specific page', () => {
192
203
  const pageSize = 2
193
- const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
204
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}), {wrapper})
194
205
 
195
206
  act(() => {
196
207
  result.current.goToPage(2) // Go to page 2
@@ -215,7 +226,7 @@ describe('usePaginatedDocuments', () => {
215
226
 
216
227
  it('should set page availability flags correctly', () => {
217
228
  const pageSize = 2
218
- const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
229
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}), {wrapper})
219
230
  // On first page
220
231
  expect(result.current.hasFirstPage).toBe(false)
221
232
  expect(result.current.hasPreviousPage).toBe(false)
@@ -243,6 +254,7 @@ describe('usePaginatedDocuments', () => {
243
254
  it('should reset current page when filter changes', () => {
244
255
  const {result, rerender} = renderHook((props) => usePaginatedDocuments(props), {
245
256
  initialProps: {pageSize: 2, filter: ''},
257
+ wrapper,
246
258
  })
247
259
  // Initially, current page should be 1
248
260
  expect(result.current.currentPage).toBe(1)
@@ -256,4 +268,17 @@ describe('usePaginatedDocuments', () => {
256
268
  expect(result.current.currentPage).toBe(1)
257
269
  expect(result.current.startIndex).toBe(0)
258
270
  })
271
+
272
+ it('should add projectId and dataset to document handles', () => {
273
+ const {result} = renderHook(() => usePaginatedDocuments({}), {wrapper})
274
+
275
+ // Check that the first document handle has the projectId and dataset
276
+ expect(result.current.data[0].projectId).toBe('p')
277
+ expect(result.current.data[0].dataset).toBe('d')
278
+
279
+ // Verify all document handles have these properties
280
+ expect(result.current.data.every((doc) => doc.projectId === 'p' && doc.dataset === 'd')).toBe(
281
+ true,
282
+ )
283
+ })
259
284
  })
@@ -1,7 +1,9 @@
1
1
  import {type DocumentHandle, type QueryOptions} from '@sanity/sdk'
2
2
  import {type SortOrderingItem} from '@sanity/types'
3
+ import {pick} from 'lodash-es'
3
4
  import {useCallback, useEffect, useMemo, useState} from 'react'
4
5
 
6
+ import {useSanityInstance} from '../context/useSanityInstance'
5
7
  import {useQuery} from '../query/useQuery'
6
8
 
7
9
  const DEFAULT_PERSPECTIVE = 'drafts'
@@ -125,7 +127,13 @@ export interface PaginatedDocumentsResponse {
125
127
  * @category Documents
126
128
  * @param options - Configuration options for the paginated list
127
129
  * @returns An object containing the current page of document handles, the loading and pagination state, and navigation functions
128
- * @example
130
+ *
131
+ * @remarks
132
+ * - The returned document handles include projectId and dataset information from the current Sanity instance
133
+ * - This makes them ready to use with document operations and other document hooks
134
+ * - The hook automatically uses the correct Sanity instance based on the projectId and dataset in the options
135
+ *
136
+ * @example Basic usage
129
137
  * ```tsx
130
138
  * const {
131
139
  * data,
@@ -147,14 +155,12 @@ export interface PaginatedDocumentsResponse {
147
155
  * <>
148
156
  * <table>
149
157
  * {data.map(doc => (
150
- * <MyTableRowComponent key={doc._id} doc={doc} />
158
+ * <MyTableRowComponent key={doc.documentId} doc={doc} />
151
159
  * ))}
152
160
  * </table>
153
- * <>
154
- * {hasPreviousPage && <button onClick={previousPage}>Previous</button>}
155
- * {currentPage} / {totalPages}
156
- * {hasNextPage && <button onClick={nextPage}>Next</button>}
157
- * </>
161
+ * {hasPreviousPage && <button onClick={previousPage}>Previous</button>}
162
+ * {currentPage} / {totalPages}
163
+ * {hasNextPage && <button onClick={nextPage}>Next</button>}
158
164
  * </>
159
165
  * )
160
166
  * ```
@@ -167,7 +173,8 @@ export function usePaginatedDocuments({
167
173
  orderings,
168
174
  search,
169
175
  ...options
170
- }: PaginatedDocumentsOptions = {}): PaginatedDocumentsResponse {
176
+ }: PaginatedDocumentsOptions): PaginatedDocumentsResponse {
177
+ const instance = useSanityInstance(options)
171
178
  const [pageIndex, setPageIndex] = useState(0)
172
179
  const key = JSON.stringify({filter, search, params, orderings, pageSize})
173
180
  // Reset the pageIndex to 0 whenever any query parameters (filter, search,
@@ -207,7 +214,7 @@ export function usePaginatedDocuments({
207
214
  .join(',')})`
208
215
  : ''
209
216
 
210
- const dataQuery = `*${filterClause}${orderClause}[${startIndex}...${endIndex}]{_id,_type}`
217
+ const dataQuery = `*${filterClause}${orderClause}[${startIndex}...${endIndex}]{"documentId":_id,"documentType":_type,...$__dataset}`
211
218
  const countQuery = `count(*${filterClause})`
212
219
 
213
220
  const {
@@ -218,7 +225,7 @@ export function usePaginatedDocuments({
218
225
  {
219
226
  ...options,
220
227
  perspective,
221
- params,
228
+ params: {...params, __dataset: pick(instance.config, 'projectId', 'dataset')},
222
229
  },
223
230
  )
224
231
 
@@ -247,44 +254,23 @@ export function usePaginatedDocuments({
247
254
  const hasNextPage = pageIndex < totalPages - 1
248
255
  const hasLastPage = pageIndex < totalPages - 1
249
256
 
250
- return useMemo(
251
- () => ({
252
- data,
253
- isPending,
254
- pageSize,
255
- currentPage,
256
- totalPages,
257
- startIndex,
258
- endIndex,
259
- count,
260
- firstPage,
261
- hasFirstPage,
262
- previousPage,
263
- hasPreviousPage,
264
- nextPage,
265
- hasNextPage,
266
- lastPage,
267
- hasLastPage,
268
- goToPage,
269
- }),
270
- [
271
- data,
272
- isPending,
273
- pageSize,
274
- currentPage,
275
- totalPages,
276
- startIndex,
277
- endIndex,
278
- count,
279
- firstPage,
280
- hasFirstPage,
281
- previousPage,
282
- hasPreviousPage,
283
- nextPage,
284
- hasNextPage,
285
- lastPage,
286
- hasLastPage,
287
- goToPage,
288
- ],
289
- )
257
+ return {
258
+ data,
259
+ isPending,
260
+ pageSize,
261
+ currentPage,
262
+ totalPages,
263
+ startIndex,
264
+ endIndex,
265
+ count,
266
+ firstPage,
267
+ hasFirstPage,
268
+ previousPage,
269
+ hasPreviousPage,
270
+ nextPage,
271
+ hasNextPage,
272
+ lastPage,
273
+ hasLastPage,
274
+ goToPage,
275
+ }
290
276
  }
@@ -39,13 +39,13 @@ vi.mock('../context/useSanityInstance', () => ({
39
39
  }))
40
40
 
41
41
  const mockDocument: DocumentHandle = {
42
- _id: 'doc1',
43
- _type: 'exampleType',
42
+ documentId: 'doc1',
43
+ documentType: 'exampleType',
44
44
  }
45
45
 
46
- function TestComponent({document}: {document: DocumentHandle}) {
46
+ function TestComponent(docHandle: DocumentHandle) {
47
47
  const ref = useRef(null)
48
- const {data, isPending} = usePreview({document, ref})
48
+ const {data, isPending} = usePreview({...docHandle, ref})
49
49
 
50
50
  return (
51
51
  <div ref={ref}>
@@ -83,7 +83,7 @@ describe('usePreview', () => {
83
83
 
84
84
  render(
85
85
  <Suspense fallback={<div>Loading...</div>}>
86
- <TestComponent document={mockDocument} />
86
+ <TestComponent {...mockDocument} />
87
87
  </Suspense>,
88
88
  )
89
89
 
@@ -128,7 +128,7 @@ describe('usePreview', () => {
128
128
 
129
129
  render(
130
130
  <Suspense fallback={<div>Loading...</div>}>
131
- <TestComponent document={mockDocument} />
131
+ <TestComponent {...mockDocument} />
132
132
  </Suspense>,
133
133
  )
134
134
 
@@ -163,7 +163,7 @@ describe('usePreview', () => {
163
163
 
164
164
  render(
165
165
  <Suspense fallback={<div>Loading...</div>}>
166
- <TestComponent document={mockDocument} />
166
+ <TestComponent {...mockDocument} />
167
167
  </Suspense>,
168
168
  )
169
169
 
@@ -172,4 +172,63 @@ describe('usePreview', () => {
172
172
  // Restore IntersectionObserver
173
173
  window.IntersectionObserver = originalIntersectionObserver
174
174
  })
175
+
176
+ test('it subscribes immediately when no ref is provided', async () => {
177
+ getCurrent.mockReturnValue({
178
+ data: {title: 'Title', subtitle: 'Subtitle'},
179
+ isPending: false,
180
+ })
181
+ const eventsUnsubscribe = vi.fn()
182
+ subscribe.mockImplementation(() => eventsUnsubscribe)
183
+
184
+ function NoRefComponent(docHandle: DocumentHandle) {
185
+ const {data} = usePreview(docHandle) // No ref provided
186
+ return (
187
+ <div>
188
+ <h1>{data?.title}</h1>
189
+ <p>{data?.subtitle}</p>
190
+ </div>
191
+ )
192
+ }
193
+
194
+ render(
195
+ <Suspense fallback={<div>Loading...</div>}>
196
+ <NoRefComponent {...mockDocument} />
197
+ </Suspense>,
198
+ )
199
+
200
+ // Should subscribe immediately without waiting for intersection
201
+ expect(subscribe).toHaveBeenCalled()
202
+ expect(screen.getByText('Title')).toBeInTheDocument()
203
+ })
204
+
205
+ test('it subscribes immediately when ref.current is not an HTML element', async () => {
206
+ getCurrent.mockReturnValue({
207
+ data: {title: 'Title', subtitle: 'Subtitle'},
208
+ isPending: false,
209
+ })
210
+ const eventsUnsubscribe = vi.fn()
211
+ subscribe.mockImplementation(() => eventsUnsubscribe)
212
+
213
+ function NonHtmlRefComponent(docHandle: DocumentHandle) {
214
+ const ref = useRef({}) // ref.current is not an HTML element
215
+ const {data} = usePreview({...docHandle, ref})
216
+ return (
217
+ <div>
218
+ <h1>{data?.title}</h1>
219
+ <p>{data?.subtitle}</p>
220
+ </div>
221
+ )
222
+ }
223
+
224
+ render(
225
+ <Suspense fallback={<div>Loading...</div>}>
226
+ <NonHtmlRefComponent {...mockDocument} />
227
+ </Suspense>,
228
+ )
229
+
230
+ // Should subscribe immediately without waiting for intersection
231
+ expect(subscribe).toHaveBeenCalled()
232
+ expect(screen.getByText('Title')).toBeInTheDocument()
233
+ })
175
234
  })
@@ -1,5 +1,5 @@
1
1
  import {type DocumentHandle, getPreviewState, type PreviewValue, resolvePreview} from '@sanity/sdk'
2
- import {useCallback, useMemo, useSyncExternalStore} from 'react'
2
+ import {useCallback, useSyncExternalStore} from 'react'
3
3
  import {distinctUntilChanged, EMPTY, Observable, startWith, switchMap} from 'rxjs'
4
4
 
5
5
  import {useSanityInstance} from '../context/useSanityInstance'
@@ -8,8 +8,11 @@ import {useSanityInstance} from '../context/useSanityInstance'
8
8
  * @beta
9
9
  * @category Types
10
10
  */
11
- export interface UsePreviewOptions {
12
- document: DocumentHandle
11
+ export interface UsePreviewOptions extends DocumentHandle {
12
+ /**
13
+ * Optional ref object to track visibility. When provided, preview resolution
14
+ * only occurs when the referenced element is visible in the viewport.
15
+ */
13
16
  ref?: React.RefObject<unknown>
14
17
  }
15
18
 
@@ -68,20 +71,18 @@ export interface UsePreviewResults {
68
71
  * )
69
72
  * ```
70
73
  */
71
- export function usePreview({document: {_id, _type}, ref}: UsePreviewOptions): UsePreviewResults {
74
+ export function usePreview({ref, ...docHandle}: UsePreviewOptions): UsePreviewResults {
72
75
  const instance = useSanityInstance()
73
-
74
- const stateSource = useMemo(
75
- () => getPreviewState(instance, {document: {_id, _type}}),
76
- [instance, _id, _type],
77
- )
76
+ const stateSource = getPreviewState(instance, docHandle)
78
77
 
79
78
  // Create subscribe function for useSyncExternalStore
80
79
  const subscribe = useCallback(
81
80
  (onStoreChanged: () => void) => {
82
81
  const subscription = new Observable<boolean>((observer) => {
83
- // for environments that don't have an intersection observer
82
+ // For environments that don't have an intersection observer (e.g. server-side),
83
+ // we pass true to always subscribe since we can't detect visibility
84
84
  if (typeof IntersectionObserver === 'undefined' || typeof HTMLElement === 'undefined') {
85
+ observer.next(true)
85
86
  return
86
87
  }
87
88
 
@@ -91,6 +92,10 @@ export function usePreview({document: {_id, _type}, ref}: UsePreviewOptions): Us
91
92
  )
92
93
  if (ref?.current && ref.current instanceof HTMLElement) {
93
94
  intersectionObserver.observe(ref.current)
95
+ } else {
96
+ // If no ref is provided or ref.current isn't an HTML element,
97
+ // pass true to always subscribe since we can't track visibility
98
+ observer.next(true)
94
99
  }
95
100
  return () => intersectionObserver.disconnect()
96
101
  })
@@ -115,9 +120,9 @@ export function usePreview({document: {_id, _type}, ref}: UsePreviewOptions): Us
115
120
  // Create getSnapshot function to return current state
116
121
  const getSnapshot = useCallback(() => {
117
122
  const currentState = stateSource.getCurrent()
118
- if (currentState.data === null) throw resolvePreview(instance, {document: {_id, _type}})
123
+ if (currentState.data === null) throw resolvePreview(instance, docHandle)
119
124
  return currentState as UsePreviewResults
120
- }, [_id, _type, instance, stateSource])
125
+ }, [docHandle, instance, stateSource])
121
126
 
122
127
  return useSyncExternalStore(subscribe, getSnapshot)
123
128
  }
@@ -44,8 +44,8 @@ vi.mock('../context/useSanityInstance', () => ({
44
44
  }))
45
45
 
46
46
  const mockDocument: DocumentHandle = {
47
- _id: 'doc1',
48
- _type: 'exampleType',
47
+ documentId: 'doc1',
48
+ documentType: 'exampleType',
49
49
  }
50
50
 
51
51
  interface ProjectionResult {
@@ -61,7 +61,7 @@ function TestComponent({
61
61
  projection: ValidProjection
62
62
  }) {
63
63
  const ref = useRef(null)
64
- const {data, isPending} = useProjection<ProjectionResult>({document, projection, ref})
64
+ const {data, isPending} = useProjection<ProjectionResult>({...document, projection, ref})
65
65
 
66
66
  return (
67
67
  <div ref={ref}>
@@ -215,4 +215,69 @@ describe('useProjection', () => {
215
215
  expect(screen.getByText('Updated Title')).toBeInTheDocument()
216
216
  expect(screen.getByText('Added Description')).toBeInTheDocument()
217
217
  })
218
+
219
+ test('it subscribes immediately when no ref is provided', async () => {
220
+ getCurrent.mockReturnValue({
221
+ data: {title: 'Title', description: 'Description'},
222
+ isPending: false,
223
+ })
224
+ const eventsUnsubscribe = vi.fn()
225
+ subscribe.mockImplementation(() => eventsUnsubscribe)
226
+
227
+ function NoRefComponent({
228
+ projection,
229
+ ...docHandle
230
+ }: DocumentHandle & {projection: ValidProjection}) {
231
+ const {data} = useProjection<ProjectionResult>({...docHandle, projection}) // No ref provided
232
+ return (
233
+ <div>
234
+ <h1>{data.title}</h1>
235
+ <p>{data.description}</p>
236
+ </div>
237
+ )
238
+ }
239
+
240
+ render(
241
+ <Suspense fallback={<div>Loading...</div>}>
242
+ <NoRefComponent {...mockDocument} projection="{title, description}" />
243
+ </Suspense>,
244
+ )
245
+
246
+ // Should subscribe immediately without waiting for intersection
247
+ expect(subscribe).toHaveBeenCalled()
248
+ expect(screen.getByText('Title')).toBeInTheDocument()
249
+ })
250
+
251
+ test('it subscribes immediately when ref.current is not an HTML element', async () => {
252
+ getCurrent.mockReturnValue({
253
+ data: {title: 'Title', description: 'Description'},
254
+ isPending: false,
255
+ })
256
+ const eventsUnsubscribe = vi.fn()
257
+ subscribe.mockImplementation(() => eventsUnsubscribe)
258
+
259
+ function NonHtmlRefComponent({
260
+ projection,
261
+ ...docHandle
262
+ }: DocumentHandle & {projection: ValidProjection}) {
263
+ const ref = useRef({}) // ref.current is not an HTML element
264
+ const {data} = useProjection<ProjectionResult>({...docHandle, projection, ref})
265
+ return (
266
+ <div>
267
+ <h1>{data.title}</h1>
268
+ <p>{data.description}</p>
269
+ </div>
270
+ )
271
+ }
272
+
273
+ render(
274
+ <Suspense fallback={<div>Loading...</div>}>
275
+ <NonHtmlRefComponent {...mockDocument} projection="{title, description}" />
276
+ </Suspense>,
277
+ )
278
+
279
+ // Should subscribe immediately without waiting for intersection
280
+ expect(subscribe).toHaveBeenCalled()
281
+ expect(screen.getByText('Title')).toBeInTheDocument()
282
+ })
218
283
  })