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

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 (88) hide show
  1. package/README.md +125 -63
  2. package/dist/index.d.ts +381 -571
  3. package/dist/index.js +450 -366
  4. package/dist/index.js.map +1 -1
  5. package/package.json +6 -8
  6. package/src/_exports/index.ts +4 -0
  7. package/src/_exports/sdk-react.ts +16 -0
  8. package/src/components/SDKProvider.test.tsx +23 -58
  9. package/src/components/SDKProvider.tsx +38 -30
  10. package/src/components/SanityApp.test.tsx +12 -68
  11. package/src/components/SanityApp.tsx +88 -65
  12. package/src/components/auth/AuthBoundary.test.tsx +11 -26
  13. package/src/components/auth/LoginError.test.tsx +5 -0
  14. package/src/components/auth/LoginError.tsx +23 -2
  15. package/src/config/handles.ts +53 -0
  16. package/src/context/ComlinkTokenRefresh.test.tsx +27 -10
  17. package/src/context/DefaultResourceContext.ts +10 -0
  18. package/src/context/PerspectiveContext.ts +12 -0
  19. package/src/context/ResourceProvider.test.tsx +99 -19
  20. package/src/context/ResourceProvider.tsx +103 -37
  21. package/src/context/ResourcesContext.tsx +7 -0
  22. package/src/context/SDKStudioContext.test.tsx +33 -28
  23. package/src/context/SDKStudioContext.ts +6 -0
  24. package/src/context/renderSanityApp.test.tsx +49 -151
  25. package/src/context/renderSanityApp.tsx +8 -12
  26. package/src/hooks/agent/agentActions.test.tsx +1 -1
  27. package/src/hooks/agent/agentActions.ts +56 -19
  28. package/src/hooks/auth/useDashboardOrganizationId.test.tsx +8 -2
  29. package/src/hooks/auth/useVerifyOrgProjects.test.tsx +32 -8
  30. package/src/hooks/client/useClient.test.tsx +4 -1
  31. package/src/hooks/client/useClient.ts +0 -1
  32. package/src/hooks/context/useDefaultResource.test.tsx +25 -0
  33. package/src/hooks/context/useDefaultResource.ts +30 -0
  34. package/src/hooks/context/useSanityInstance.test.tsx +2 -140
  35. package/src/hooks/context/useSanityInstance.ts +9 -53
  36. package/src/hooks/dashboard/useDispatchIntent.test.ts +24 -15
  37. package/src/hooks/dashboard/useDispatchIntent.ts +7 -7
  38. package/src/hooks/dashboard/useManageFavorite.test.tsx +34 -94
  39. package/src/hooks/dashboard/useManageFavorite.ts +16 -10
  40. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +7 -5
  41. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +6 -2
  42. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.test.ts +2 -0
  43. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.ts +2 -1
  44. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.test.ts +17 -38
  45. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +12 -19
  46. package/src/hooks/datasets/useDatasets.test.ts +8 -22
  47. package/src/hooks/datasets/useDatasets.ts +8 -16
  48. package/src/hooks/document/useApplyDocumentActions.test.ts +98 -52
  49. package/src/hooks/document/useApplyDocumentActions.ts +35 -37
  50. package/src/hooks/document/useDocument.test.tsx +8 -37
  51. package/src/hooks/document/useDocument.ts +78 -129
  52. package/src/hooks/document/useDocumentEvent.test.tsx +7 -19
  53. package/src/hooks/document/useDocumentEvent.ts +21 -19
  54. package/src/hooks/document/useDocumentPermissions.test.tsx +75 -84
  55. package/src/hooks/document/useDocumentPermissions.ts +41 -28
  56. package/src/hooks/document/useDocumentSyncStatus.test.ts +13 -3
  57. package/src/hooks/document/useDocumentSyncStatus.ts +19 -14
  58. package/src/hooks/document/useEditDocument.test.tsx +28 -70
  59. package/src/hooks/document/useEditDocument.ts +29 -149
  60. package/src/hooks/documents/useDocuments.test.tsx +44 -64
  61. package/src/hooks/documents/useDocuments.ts +19 -25
  62. package/src/hooks/helpers/createCallbackHook.test.tsx +19 -13
  63. package/src/hooks/helpers/createStateSourceHook.test.tsx +10 -10
  64. package/src/hooks/helpers/createStateSourceHook.tsx +2 -4
  65. package/src/hooks/helpers/useNormalizedResourceOptions.test.ts +65 -0
  66. package/src/hooks/helpers/useNormalizedResourceOptions.ts +127 -0
  67. package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +27 -34
  68. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +19 -20
  69. package/src/hooks/presence/usePresence.test.tsx +71 -9
  70. package/src/hooks/presence/usePresence.ts +28 -3
  71. package/src/hooks/preview/useDocumentPreview.test.tsx +85 -193
  72. package/src/hooks/preview/useDocumentPreview.tsx +42 -62
  73. package/src/hooks/projection/useDocumentProjection.test.tsx +9 -37
  74. package/src/hooks/projection/useDocumentProjection.ts +9 -82
  75. package/src/hooks/projects/useProject.test.ts +1 -2
  76. package/src/hooks/projects/useProject.ts +7 -8
  77. package/src/hooks/query/useQuery.test.tsx +5 -6
  78. package/src/hooks/query/useQuery.ts +12 -91
  79. package/src/hooks/releases/useActiveReleases.test.tsx +2 -2
  80. package/src/hooks/releases/useActiveReleases.ts +25 -13
  81. package/src/hooks/releases/usePerspective.test.tsx +9 -17
  82. package/src/hooks/releases/usePerspective.ts +29 -18
  83. package/src/hooks/users/useUser.test.tsx +9 -3
  84. package/src/hooks/users/useUser.ts +1 -1
  85. package/src/hooks/users/useUsers.test.tsx +5 -2
  86. package/src/hooks/users/useUsers.ts +1 -1
  87. package/src/context/SourcesContext.tsx +0 -7
  88. package/src/hooks/helpers/useNormalizedSourceOptions.ts +0 -85
@@ -1,17 +1,42 @@
1
- import {getPresence, type UserPresence} from '@sanity/sdk'
1
+ import {getPresence, isCanvasResource, isMediaLibraryResource, type UserPresence} from '@sanity/sdk'
2
2
  import {useCallback, useMemo, useSyncExternalStore} from 'react'
3
3
 
4
+ import {type ResourceHandle} from '../../config/handles'
4
5
  import {useSanityInstance} from '../context/useSanityInstance'
6
+ import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
5
7
 
6
8
  /**
7
9
  * A hook for subscribing to presence information for the current project.
8
10
  * @public
9
11
  */
10
- export function usePresence(): {
12
+ export function usePresence(options: ResourceHandle = {}): {
11
13
  locations: UserPresence[]
12
14
  } {
15
+ const normalizedOptions = useNormalizedResourceOptions(options)
13
16
  const sanityInstance = useSanityInstance()
14
- const source = useMemo(() => getPresence(sanityInstance), [sanityInstance])
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
+ }
34
+ }
35
+
36
+ const source = useMemo(
37
+ () => getPresence(sanityInstance, normalizedOptions),
38
+ [sanityInstance, normalizedOptions],
39
+ )
15
40
  const subscribe = useCallback((callback: () => void) => source.subscribe(callback), [source])
16
41
  const locations = useSyncExternalStore(
17
42
  subscribe,
@@ -1,233 +1,125 @@
1
- import {type DocumentHandle, getPreviewState, type PreviewValue, resolvePreview} from '@sanity/sdk'
2
- import {act, render, screen} from '@testing-library/react'
3
- import {useRef} from 'react'
4
- import {type Mock} from 'vitest'
1
+ import {type DocumentHandle, type PreviewQueryResult} from '@sanity/sdk'
2
+ import {beforeEach, describe, expect, test, vi} from 'vitest'
5
3
 
6
- import {ResourceProvider} from '../../context/ResourceProvider'
4
+ import {render, renderHook, screen} from '../../../test/test-utils'
5
+ import {useDocumentProjection} from '../projection/useDocumentProjection'
7
6
  import {useDocumentPreview} from './useDocumentPreview'
8
7
 
9
- // Mock IntersectionObserver
10
- const mockIntersectionObserver = vi.fn()
11
- let intersectionObserverCallback: (entries: IntersectionObserverEntry[]) => void
12
-
13
- beforeAll(() => {
14
- vi.stubGlobal(
15
- 'IntersectionObserver',
16
- class {
17
- constructor(callback: (entries: IntersectionObserverEntry[]) => void) {
18
- intersectionObserverCallback = callback
19
- mockIntersectionObserver(callback)
20
- }
21
- observe = vi.fn()
22
- disconnect = vi.fn()
23
- },
24
- )
25
- })
26
-
27
- // Mock the preview store
28
- vi.mock('@sanity/sdk', async (importOriginal) => {
29
- const actual = await importOriginal<typeof import('@sanity/sdk')>()
30
- const getCurrent = vi.fn()
31
- const subscribe = vi.fn()
32
-
33
- return {
34
- ...actual,
35
- resolvePreview: vi.fn(),
36
- getPreviewState: vi.fn().mockReturnValue({getCurrent, subscribe}),
37
- }
38
- })
8
+ // Mock useDocumentProjection since useDocumentPreview now uses it internally
9
+ vi.mock('../projection/useDocumentProjection')
39
10
 
40
11
  const mockDocument: DocumentHandle = {
41
12
  documentId: 'doc1',
42
13
  documentType: 'exampleType',
43
- }
44
-
45
- function TestComponent(docHandle: DocumentHandle) {
46
- const ref = useRef(null)
47
- const {data, isPending} = useDocumentPreview({...docHandle, ref})
48
-
49
- return (
50
- <div ref={ref}>
51
- <h1>{data?.title}</h1>
52
- <p>{data?.subtitle}</p>
53
- {isPending && <div>Pending...</div>}
54
- </div>
55
- )
14
+ resource: {projectId: 'p', dataset: 'd'},
56
15
  }
57
16
 
58
17
  describe('useDocumentPreview', () => {
59
- let getCurrent: Mock
60
- let subscribe: Mock
61
-
62
18
  beforeEach(() => {
63
- // @ts-expect-error mock does not need param
64
- getCurrent = getPreviewState().getCurrent as Mock
65
- // @ts-expect-error mock does not need param
66
- subscribe = getPreviewState().subscribe as Mock
67
-
68
- // Reset all mocks between tests
69
- getCurrent.mockReset()
70
- subscribe.mockReset()
71
- mockIntersectionObserver.mockReset()
19
+ vi.clearAllMocks()
72
20
  })
73
21
 
74
- test('it only subscribes when element is visible', async () => {
75
- // Setup initial state
76
- getCurrent.mockReturnValue({
77
- data: {title: 'Initial Title', subtitle: 'Initial Subtitle'},
78
- isPending: false,
79
- })
80
- const eventsUnsubscribe = vi.fn()
81
- subscribe.mockImplementation(() => eventsUnsubscribe)
82
-
83
- render(
84
- <ResourceProvider fallback={<div>Loading...</div>}>
85
- <TestComponent {...mockDocument} />
86
- </ResourceProvider>,
87
- )
88
-
89
- // Initially, element is not intersecting
90
- expect(screen.getByText('Initial Title')).toBeInTheDocument()
91
- expect(subscribe).not.toHaveBeenCalled()
92
-
93
- // Simulate element becoming visible
94
- await act(async () => {
95
- intersectionObserverCallback([{isIntersecting: true} as IntersectionObserverEntry])
96
- })
97
-
98
- // After element becomes visible, events subscription should be active
99
- expect(subscribe).toHaveBeenCalled()
100
- expect(eventsUnsubscribe).not.toHaveBeenCalled()
22
+ test('transforms projection result to preview format', () => {
23
+ const mockProjectionResult: PreviewQueryResult = {
24
+ _id: 'doc1',
25
+ _type: 'exampleType',
26
+ _updatedAt: '2024-01-01',
27
+ titleCandidates: {title: 'Test Title'},
28
+ subtitleCandidates: {description: 'Test Description'},
29
+ media: null,
30
+ }
101
31
 
102
- // Simulate element becoming hidden
103
- await act(async () => {
104
- intersectionObserverCallback([{isIntersecting: false} as IntersectionObserverEntry])
32
+ vi.mocked(useDocumentProjection).mockReturnValue({
33
+ data: mockProjectionResult,
34
+ isPending: false,
105
35
  })
106
36
 
107
- // When hidden, should maintain last known state
108
- expect(screen.getByText('Initial Title')).toBeInTheDocument()
109
- expect(eventsUnsubscribe).toHaveBeenCalled()
37
+ const {result} = renderHook(() => useDocumentPreview(mockDocument))
38
+ const {data, isPending} = result.current
39
+ expect(data.title).toBe('Test Title')
40
+ expect(data.subtitle).toBe('Test Description')
41
+ expect(isPending).toBe(false)
110
42
  })
111
43
 
112
- test.skip('it suspends and resolves data when element becomes visible', async () => {
113
- // Initial setup with pending state
114
- getCurrent.mockReturnValueOnce([null, true])
115
- const resolvePromise = Promise.resolve<PreviewValue>({
116
- title: 'Resolved Title',
117
- subtitle: 'Resolved Subtitle',
44
+ test('handles pending state', () => {
45
+ const mockProjectionResult: PreviewQueryResult = {
46
+ _id: 'doc1',
47
+ _type: 'exampleType',
48
+ _updatedAt: '2024-01-01',
49
+ titleCandidates: {title: 'Loading Title'},
50
+ subtitleCandidates: {},
118
51
  media: null,
119
- })
120
- ;(resolvePreview as Mock).mockReturnValueOnce(resolvePromise)
52
+ }
121
53
 
122
- let subscriber: () => void
123
- subscribe.mockImplementation((sub: () => void) => {
124
- subscriber = sub
125
- return vi.fn()
54
+ vi.mocked(useDocumentProjection).mockReturnValue({
55
+ data: mockProjectionResult,
56
+ isPending: true,
126
57
  })
127
58
 
128
- render(
129
- <ResourceProvider fallback={<div>Loading...</div>}>
130
- <TestComponent {...mockDocument} />
131
- </ResourceProvider>,
132
- )
59
+ function TestComponent() {
60
+ const {data, isPending} = useDocumentPreview(mockDocument)
61
+ return (
62
+ <div>
63
+ <h1>{data.title}</h1>
64
+ {isPending && <div>Pending...</div>}
65
+ </div>
66
+ )
67
+ }
133
68
 
134
- expect(screen.getByText('Loading...')).toBeInTheDocument()
135
-
136
- // Simulate element becoming visible
137
- await act(async () => {
138
- intersectionObserverCallback([{isIntersecting: true} as IntersectionObserverEntry])
139
- await resolvePromise
140
- getCurrent.mockReturnValue({
141
- data: {title: 'Resolved Title', subtitle: 'Resolved Subtitle'},
142
- isPending: false,
143
- })
144
- subscriber?.()
145
- })
69
+ render(<TestComponent />)
146
70
 
147
- expect(screen.getByText('Resolved Title')).toBeInTheDocument()
148
- expect(screen.getByText('Resolved Subtitle')).toBeInTheDocument()
71
+ expect(screen.getByText('Loading Title')).toBeInTheDocument()
72
+ expect(screen.getByText('Pending...')).toBeInTheDocument()
149
73
  })
150
74
 
151
- test('it handles environments without IntersectionObserver', async () => {
152
- // Temporarily remove IntersectionObserver
153
- const originalIntersectionObserver = window.IntersectionObserver
154
- // @ts-expect-error - Intentionally removing IntersectionObserver
155
- delete window.IntersectionObserver
75
+ test('uses fallback title when no candidates exist', () => {
76
+ const mockProjectionResult: PreviewQueryResult = {
77
+ _id: 'doc1',
78
+ _type: 'article',
79
+ _updatedAt: '2024-01-01',
80
+ titleCandidates: {},
81
+ subtitleCandidates: {},
82
+ media: null,
83
+ }
156
84
 
157
- getCurrent.mockReturnValue({
158
- data: {title: 'Fallback Title', subtitle: 'Fallback Subtitle'},
85
+ vi.mocked(useDocumentProjection).mockReturnValue({
86
+ data: mockProjectionResult,
159
87
  isPending: false,
160
88
  })
161
- subscribe.mockImplementation(() => vi.fn())
162
89
 
163
- render(
164
- <ResourceProvider fallback={<div>Loading...</div>}>
165
- <TestComponent {...mockDocument} />
166
- </ResourceProvider>,
167
- )
168
-
169
- expect(screen.getByText('Fallback Title')).toBeInTheDocument()
170
-
171
- // Restore IntersectionObserver
172
- window.IntersectionObserver = originalIntersectionObserver
90
+ const {result} = renderHook(() => useDocumentPreview(mockDocument))
91
+ const {data} = result.current
92
+ expect(data.title).toBe('article: doc1')
173
93
  })
174
94
 
175
- test('it subscribes immediately when no ref is provided', async () => {
176
- getCurrent.mockReturnValue({
177
- data: {title: 'Title', subtitle: 'Subtitle'},
178
- isPending: false,
179
- })
180
- const eventsUnsubscribe = vi.fn()
181
- subscribe.mockImplementation(() => eventsUnsubscribe)
182
-
183
- function NoRefComponent(docHandle: DocumentHandle) {
184
- const {data} = useDocumentPreview(docHandle) // No ref provided
185
- return (
186
- <div>
187
- <h1>{data?.title}</h1>
188
- <p>{data?.subtitle}</p>
189
- </div>
190
- )
95
+ test('passes ref to useDocumentProjection', () => {
96
+ const mockProjectionResult: PreviewQueryResult = {
97
+ _id: 'doc1',
98
+ _type: 'exampleType',
99
+ _updatedAt: '2024-01-01',
100
+ titleCandidates: {title: 'Title'},
101
+ subtitleCandidates: {},
102
+ media: null,
191
103
  }
192
104
 
193
- render(
194
- <ResourceProvider fallback={<div>Loading...</div>}>
195
- <NoRefComponent {...mockDocument} />
196
- </ResourceProvider>,
197
- )
198
-
199
- // Should subscribe immediately without waiting for intersection
200
- expect(subscribe).toHaveBeenCalled()
201
- expect(screen.getByText('Title')).toBeInTheDocument()
202
- })
203
-
204
- test('it subscribes immediately when ref.current is not an HTML element', async () => {
205
- getCurrent.mockReturnValue({
206
- data: {title: 'Title', subtitle: 'Subtitle'},
105
+ vi.mocked(useDocumentProjection).mockReturnValue({
106
+ data: mockProjectionResult,
207
107
  isPending: false,
208
108
  })
209
- const eventsUnsubscribe = vi.fn()
210
- subscribe.mockImplementation(() => eventsUnsubscribe)
211
-
212
- function NonHtmlRefComponent(docHandle: DocumentHandle) {
213
- const ref = useRef({}) // ref.current is not an HTML element
214
- const {data} = useDocumentPreview({...docHandle, ref})
215
- return (
216
- <div>
217
- <h1>{data?.title}</h1>
218
- <p>{data?.subtitle}</p>
219
- </div>
220
- )
221
- }
222
109
 
223
- render(
224
- <ResourceProvider fallback={<div>Loading...</div>}>
225
- <NonHtmlRefComponent {...mockDocument} />
226
- </ResourceProvider>,
110
+ const ref = {current: null}
111
+ const {result} = renderHook(() => useDocumentPreview({...mockDocument, ref}))
112
+ const {data} = result.current
113
+ expect(data.title).toBe('Title')
114
+
115
+ // Verify useDocumentProjection was called with the ref and preview projection
116
+ expect(useDocumentProjection).toHaveBeenCalledWith(
117
+ expect.objectContaining({
118
+ documentId: 'doc1',
119
+ documentType: 'exampleType',
120
+ ref: ref,
121
+ projection: expect.any(String),
122
+ }),
227
123
  )
228
-
229
- // Should subscribe immediately without waiting for intersection
230
- expect(subscribe).toHaveBeenCalled()
231
- expect(screen.getByText('Title')).toBeInTheDocument()
232
124
  })
233
125
  })
@@ -1,8 +1,15 @@
1
- import {type DocumentHandle, getPreviewState, type PreviewValue, resolvePreview} from '@sanity/sdk'
2
- import {useCallback, useSyncExternalStore} from 'react'
3
- import {distinctUntilChanged, EMPTY, Observable, startWith, switchMap} from 'rxjs'
1
+ import {
2
+ PREVIEW_PROJECTION,
3
+ type PreviewQueryResult,
4
+ type PreviewValue,
5
+ transformProjectionToPreview,
6
+ } from '@sanity/sdk'
7
+ import {useMemo} from 'react'
4
8
 
9
+ import {type DocumentHandle} from '../../config/handles'
5
10
  import {useSanityInstance} from '../context/useSanityInstance'
11
+ import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
12
+ import {useDocumentProjection} from '../projection/useDocumentProjection'
6
13
 
7
14
  /**
8
15
  * @public
@@ -21,7 +28,7 @@ export interface useDocumentPreviewOptions extends DocumentHandle {
21
28
  * @category Types
22
29
  */
23
30
  export interface useDocumentPreviewResults {
24
- /** The results of inferring the documents preview values */
31
+ /** The results of inferring the document's preview values */
25
32
  data: PreviewValue
26
33
  /** True when inferred preview values are being refreshed */
27
34
  isPending: boolean
@@ -31,7 +38,7 @@ export interface useDocumentPreviewResults {
31
38
  * @public
32
39
  *
33
40
  * Attempts to infer preview values of a document (specified via a `DocumentHandle`),
34
- * including the documents `title`, `subtitle`, `media`, and `status`. These values are live and will update in realtime.
41
+ * including the document's `title`, `subtitle`, `media`, and `status`. These values are live and will update in realtime.
35
42
  * To reduce unnecessary network requests for resolving the preview values, an optional `ref` can be passed to the hook so that preview
36
43
  * resolution will only occur if the `ref` is intersecting the current viewport.
37
44
  *
@@ -40,18 +47,22 @@ export interface useDocumentPreviewResults {
40
47
  * @remarks
41
48
  * Values returned by this hook may not be as expected. It is currently unable to read preview values as defined in your schema;
42
49
  * instead, it attempts to infer these preview values by checking against a basic set of potential fields on your document.
43
- * We are anticipating being able to significantly improve this hooks functionality and output in a future release.
50
+ * We are anticipating being able to significantly improve this hook's functionality and output in a future release.
44
51
  * For now, we recommend using {@link useDocumentProjection} for rendering individual document fields (or projections of those fields).
45
52
  *
53
+ * Internally, this hook is implemented as a specialized projection with post-processing logic.
54
+ * It uses a fixed GROQ projection to fetch common preview fields (title, subtitle, media) and
55
+ * transforms the results into the PreviewValue format.
56
+ *
46
57
  * @category Documents
47
58
  * @param options - The document handle for the document you want to infer preview values for, and an optional ref
48
59
  * @returns The inferred values for the given document and a boolean to indicate whether the resolution is pending
49
60
  *
50
61
  * @example Combining with useDocuments to render a collection of document previews
51
62
  * ```
52
- * // PreviewComponent.jsx
53
- * export default function PreviewComponent({ document }) {
54
- * const { data: { title, subtitle, media }, isPending } = useDocumentPreview({ document })
63
+ * // PreviewComponent.tsx
64
+ * export default function PreviewComponent(docHandle: DocumentHandle) {
65
+ * const { data: { title, subtitle, media }, isPending } = useDocumentPreview(docHandle)
55
66
  * return (
56
67
  * <article style={{ opacity: isPending ? 0.5 : 1}}>
57
68
  * {media?.type === 'image-asset' ? <img src={media.url} alt='' /> : ''}
@@ -61,16 +72,16 @@ export interface useDocumentPreviewResults {
61
72
  * )
62
73
  * }
63
74
  *
64
- * // DocumentList.jsx
65
- * const { data } = useDocuments({ filter: '_type == "movie"' })
75
+ * // DocumentList.tsx
76
+ * const { data } = useDocuments({ documentType: 'movie' })
66
77
  * return (
67
78
  * <div>
68
79
  * <h1>Movies</h1>
69
80
  * <ul>
70
81
  * {data.map(movie => (
71
- * <li key={movie._id}>
82
+ * <li key={movie.documentId}>
72
83
  * <Suspense fallback='Loading…'>
73
- * <PreviewComponent document={movie} />
84
+ * <PreviewComponent {...movie} />
74
85
  * </Suspense>
75
86
  * </li>
76
87
  * ))}
@@ -83,57 +94,26 @@ export function useDocumentPreview({
83
94
  ref,
84
95
  ...docHandle
85
96
  }: useDocumentPreviewOptions): useDocumentPreviewResults {
86
- const instance = useSanityInstance(docHandle)
87
- const stateSource = getPreviewState(instance, docHandle)
88
-
89
- // Create subscribe function for useSyncExternalStore
90
- const subscribe = useCallback(
91
- (onStoreChanged: () => void) => {
92
- const subscription = new Observable<boolean>((observer) => {
93
- // For environments that don't have an intersection observer (e.g. server-side),
94
- // we pass true to always subscribe since we can't detect visibility
95
- if (typeof IntersectionObserver === 'undefined' || typeof HTMLElement === 'undefined') {
96
- observer.next(true)
97
- return
98
- }
97
+ const instance = useSanityInstance()
98
+ const normalizedDocHandle = useNormalizedResourceOptions(docHandle)
99
99
 
100
- const intersectionObserver = new IntersectionObserver(
101
- ([entry]) => observer.next(entry.isIntersecting),
102
- {rootMargin: '0px', threshold: 0},
103
- )
104
- if (ref?.current && ref.current instanceof HTMLElement) {
105
- intersectionObserver.observe(ref.current)
106
- } else {
107
- // If no ref is provided or ref.current isn't an HTML element,
108
- // pass true to always subscribe since we can't track visibility
109
- observer.next(true)
110
- }
111
- return () => intersectionObserver.disconnect()
112
- })
113
- .pipe(
114
- startWith(false),
115
- distinctUntilChanged(),
116
- switchMap((isVisible) =>
117
- isVisible
118
- ? new Observable<void>((obs) => {
119
- return stateSource.subscribe(() => obs.next())
120
- })
121
- : EMPTY,
122
- ),
123
- )
124
- .subscribe({next: onStoreChanged})
100
+ // Use the projection hook with the fixed preview projection
101
+ const projectionResult = useDocumentProjection<PreviewQueryResult>({
102
+ ...normalizedDocHandle,
103
+ projection: PREVIEW_PROJECTION,
104
+ ref,
105
+ })
125
106
 
126
- return () => subscription.unsubscribe()
127
- },
128
- [stateSource, ref],
107
+ // Contract: useDocumentProjection suspends while data is null, so data is always available here.
108
+ // Keep this non-null assumption aligned with useDocumentPreviewResults.data.
109
+ const previewValue = useMemo(
110
+ () =>
111
+ transformProjectionToPreview(instance, normalizedDocHandle.resource, projectionResult.data),
112
+ [projectionResult.data, instance, normalizedDocHandle.resource],
129
113
  )
130
114
 
131
- // Create getSnapshot function to return current state
132
- const getSnapshot = useCallback(() => {
133
- const currentState = stateSource.getCurrent()
134
- if (currentState.data === null) throw resolvePreview(instance, docHandle)
135
- return currentState as useDocumentPreviewResults
136
- }, [docHandle, instance, stateSource])
137
-
138
- return useSyncExternalStore(subscribe, getSnapshot)
115
+ return {
116
+ data: previewValue,
117
+ isPending: projectionResult.isPending,
118
+ }
139
119
  }
@@ -1,9 +1,8 @@
1
1
  import {type DocumentHandle, getProjectionState, resolveProjection} from '@sanity/sdk'
2
- import {act, render, screen} from '@testing-library/react'
3
2
  import {useRef} from 'react'
4
3
  import {type Mock} from 'vitest'
5
4
 
6
- import {ResourceProvider} from '../../context/ResourceProvider'
5
+ import {act, render, screen} from '../../../test/test-utils'
7
6
  import {useDocumentProjection} from './useDocumentProjection'
8
7
 
9
8
  // Mock IntersectionObserver
@@ -40,6 +39,7 @@ vi.mock('@sanity/sdk', async (importOriginal) => {
40
39
  const mockDocument: DocumentHandle = {
41
40
  documentId: 'doc1',
42
41
  documentType: 'exampleType',
42
+ resource: {projectId: 'p', dataset: 'd'},
43
43
  }
44
44
 
45
45
  interface ProjectionResult {
@@ -85,11 +85,7 @@ describe('useDocumentProjection', () => {
85
85
  const eventsUnsubscribe = vi.fn()
86
86
  subscribe.mockImplementation(() => eventsUnsubscribe)
87
87
 
88
- render(
89
- <ResourceProvider fallback={<div>Loading...</div>}>
90
- <TestComponent document={mockDocument} projection="{name, description}" />
91
- </ResourceProvider>,
92
- )
88
+ render(<TestComponent document={mockDocument} projection="{name, description}" />)
93
89
 
94
90
  // Initially, element is not intersecting
95
91
  expect(screen.getByText('Initial Title')).toBeInTheDocument()
@@ -135,11 +131,7 @@ describe('useDocumentProjection', () => {
135
131
  // Setup subscription that does nothing (we'll manually trigger updates)
136
132
  subscribe.mockReturnValue(() => {})
137
133
 
138
- render(
139
- <ResourceProvider fallback={<div>Loading...</div>}>
140
- <TestComponent document={mockDocument} projection="{title, description}" />
141
- </ResourceProvider>,
142
- )
134
+ render(<TestComponent document={mockDocument} projection="{title, description}" />)
143
135
 
144
136
  await act(async () => {
145
137
  intersectionObserverCallback([{isIntersecting: true} as IntersectionObserverEntry])
@@ -162,11 +154,7 @@ describe('useDocumentProjection', () => {
162
154
  })
163
155
  subscribe.mockImplementation(() => vi.fn())
164
156
 
165
- render(
166
- <ResourceProvider fallback={<div>Loading...</div>}>
167
- <TestComponent document={mockDocument} projection="{title, description}" />
168
- </ResourceProvider>,
169
- )
157
+ render(<TestComponent document={mockDocument} projection="{title, description}" />)
170
158
 
171
159
  expect(screen.getByText('Fallback Title')).toBeInTheDocument()
172
160
 
@@ -182,11 +170,7 @@ describe('useDocumentProjection', () => {
182
170
  const eventsUnsubscribe = vi.fn()
183
171
  subscribe.mockImplementation(() => eventsUnsubscribe)
184
172
 
185
- const {rerender} = render(
186
- <ResourceProvider fallback={<div>Loading...</div>}>
187
- <TestComponent document={mockDocument} projection="{title}" />
188
- </ResourceProvider>,
189
- )
173
+ const {rerender} = render(<TestComponent document={mockDocument} projection="{title}" />)
190
174
 
191
175
  // Change projection
192
176
  getCurrent.mockReturnValue({
@@ -194,11 +178,7 @@ describe('useDocumentProjection', () => {
194
178
  isPending: false,
195
179
  })
196
180
 
197
- rerender(
198
- <ResourceProvider fallback={<div>Loading...</div>}>
199
- <TestComponent document={mockDocument} projection="{title, description}" />
200
- </ResourceProvider>,
201
- )
181
+ rerender(<TestComponent document={mockDocument} projection="{title, description}" />)
202
182
 
203
183
  expect(screen.getByText('Updated Title')).toBeInTheDocument()
204
184
  expect(screen.getByText('Added Description')).toBeInTheDocument()
@@ -222,11 +202,7 @@ describe('useDocumentProjection', () => {
222
202
  )
223
203
  }
224
204
 
225
- render(
226
- <ResourceProvider fallback={<div>Loading...</div>}>
227
- <NoRefComponent {...mockDocument} projection="{title, description}" />
228
- </ResourceProvider>,
229
- )
205
+ render(<NoRefComponent {...mockDocument} projection="{title, description}" />)
230
206
 
231
207
  // Should subscribe immediately without waiting for intersection
232
208
  expect(subscribe).toHaveBeenCalled()
@@ -255,11 +231,7 @@ describe('useDocumentProjection', () => {
255
231
  )
256
232
  }
257
233
 
258
- render(
259
- <ResourceProvider fallback={<div>Loading...</div>}>
260
- <NonHtmlRefComponent {...mockDocument} projection="{title, description}" />
261
- </ResourceProvider>,
262
- )
234
+ render(<NonHtmlRefComponent {...mockDocument} projection="{title, description}" />)
263
235
 
264
236
  // Should subscribe immediately without waiting for intersection
265
237
  expect(subscribe).toHaveBeenCalled()