@sanity/sdk-react 2.8.0 → 2.9.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 (35) hide show
  1. package/dist/index.d.ts +144 -25
  2. package/dist/index.js +216 -122
  3. package/dist/index.js.map +1 -1
  4. package/package.json +7 -7
  5. package/src/_exports/sdk-react.ts +1 -0
  6. package/src/components/SanityApp.tsx +1 -0
  7. package/src/context/ResourceProvider.test.tsx +7 -1
  8. package/src/context/ResourceProvider.tsx +6 -0
  9. package/src/context/SDKStudioContext.ts +6 -0
  10. package/src/hooks/dashboard/useDispatchIntent.test.ts +2 -0
  11. package/src/hooks/dashboard/useWindowTitle.test.ts +213 -0
  12. package/src/hooks/dashboard/useWindowTitle.ts +112 -0
  13. package/src/hooks/document/useApplyDocumentActions.test.ts +113 -10
  14. package/src/hooks/document/useApplyDocumentActions.ts +99 -3
  15. package/src/hooks/document/useDocument.ts +22 -6
  16. package/src/hooks/document/useDocumentEvent.test.tsx +3 -3
  17. package/src/hooks/document/useDocumentEvent.ts +10 -3
  18. package/src/hooks/document/useDocumentPermissions.test.tsx +86 -2
  19. package/src/hooks/document/useDocumentPermissions.ts +22 -0
  20. package/src/hooks/document/useDocumentSyncStatus.test.ts +13 -2
  21. package/src/hooks/document/useDocumentSyncStatus.ts +14 -5
  22. package/src/hooks/document/useEditDocument.ts +34 -8
  23. package/src/hooks/documents/useDocuments.ts +2 -0
  24. package/src/hooks/helpers/useNormalizedSourceOptions.ts +50 -28
  25. package/src/hooks/helpers/useTrackHookUsage.ts +37 -0
  26. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +2 -0
  27. package/src/hooks/presence/usePresence.ts +2 -0
  28. package/src/hooks/preview/useDocumentPreview.test.tsx +84 -193
  29. package/src/hooks/preview/useDocumentPreview.tsx +39 -55
  30. package/src/hooks/projection/useDocumentProjection.ts +2 -0
  31. package/src/hooks/query/useQuery.ts +2 -0
  32. package/src/hooks/releases/useActiveReleases.ts +32 -13
  33. package/src/hooks/releases/usePerspective.ts +26 -14
  34. package/src/hooks/users/useUser.ts +2 -0
  35. package/src/hooks/users/useUsers.ts +2 -0
@@ -23,45 +23,32 @@ export type WithSourceNameSupport<T extends {source?: DocumentSource}> = T & {
23
23
  }
24
24
 
25
25
  /**
26
- * Normalizes hook options by resolving `sourceName` to a `DocumentSource`.
27
- * This hook ensures that options passed to core layer functions only contain
28
- * `source` (never `sourceName`), preventing duplicate cache keys and maintaining
29
- * clean separation between React and core layers.
26
+ * Pure function that normalizes options by resolving `sourceName` to a `DocumentSource`
27
+ * using the provided sources map. Use this when options are only available at call time
28
+ * (e.g. inside a callback) and you cannot call the {@link useNormalizedSourceOptions} hook.
30
29
  *
31
30
  * @typeParam T - The options type (must include optional source field)
32
- * @param options - Hook options that may include `sourceName` and/or `source`
31
+ * @param options - Options that may include `sourceName` and/or `source`
32
+ * @param sources - Map of source names to DocumentSource (e.g. from SourcesContext)
33
33
  * @returns Normalized options with `sourceName` removed and `source` resolved
34
- *
35
- * @remarks
36
- * Resolution priority:
37
- * 1. If `sourceName` is provided, resolves it via `SourcesContext` and uses that
38
- * 2. Otherwise, uses the inline `source` if provided
39
- * 3. If neither is provided, returns options without a source field
40
- *
41
- * @example
42
- * ```tsx
43
- * function useQuery(options: WithSourceNameSupport<QueryOptions>) {
44
- * const instance = useSanityInstance(options)
45
- * const normalized = useNormalizedOptions(options)
46
- * // normalized now has source but never sourceName
47
- * const queryKey = getQueryKey(normalized)
48
- * }
49
- * ```
50
- *
51
- * @beta
34
+ * @internal
52
35
  */
53
- export function useNormalizedSourceOptions<
54
- T extends {source?: DocumentSource; sourceName?: string},
55
- >(options: T): Omit<T, 'sourceName'> {
36
+ export function normalizeSourceOptions<T extends {source?: DocumentSource; sourceName?: string}>(
37
+ options: T,
38
+ sources: Record<string, DocumentSource>,
39
+ ): Omit<T, 'sourceName'> {
56
40
  const {sourceName, ...rest} = options
41
+
42
+ if (!sourceName && !options.source) {
43
+ return options
44
+ }
45
+
57
46
  if (sourceName && Object.hasOwn(options, 'source')) {
58
47
  throw new Error(
59
48
  `Source name ${JSON.stringify(sourceName)} and source ${JSON.stringify(options.source)} cannot be used together.`,
60
49
  )
61
50
  }
62
51
 
63
- // Resolve sourceName to source via context
64
- const sources = useContext(SourcesContext)
65
52
  let resolvedSource: DocumentSource | undefined
66
53
 
67
54
  if (options.source) {
@@ -83,3 +70,38 @@ export function useNormalizedSourceOptions<
83
70
  source: resolvedSource,
84
71
  }
85
72
  }
73
+
74
+ /**
75
+ * Normalizes hook options by resolving `sourceName` to a `DocumentSource`.
76
+ * This hook ensures that options passed to core layer functions only contain
77
+ * `source` (never `sourceName`), preventing duplicate cache keys and maintaining
78
+ * clean separation between React and core layers.
79
+ *
80
+ * @typeParam T - The options type (must include optional source field)
81
+ * @param options - Hook options that may include `sourceName` and/or `source`
82
+ * @returns Normalized options with `sourceName` removed and `source` resolved
83
+ *
84
+ * @remarks
85
+ * Resolution priority:
86
+ * 1. If `sourceName` is provided, resolves it via `SourcesContext` and uses that
87
+ * 2. Otherwise, uses the inline `source` if provided
88
+ * 3. If neither is provided, returns options without a source field
89
+ *
90
+ * @example
91
+ * ```tsx
92
+ * function useQuery(options: WithSourceNameSupport<QueryOptions>) {
93
+ * const instance = useSanityInstance(options)
94
+ * const normalized = useNormalizedOptions(options)
95
+ * // normalized now has source but never sourceName
96
+ * const queryKey = getQueryKey(normalized)
97
+ * }
98
+ * ```
99
+ *
100
+ * @beta
101
+ */
102
+ export function useNormalizedSourceOptions<
103
+ T extends {source?: DocumentSource; sourceName?: string},
104
+ >(options: T): Omit<T, 'sourceName'> {
105
+ const sources = useContext(SourcesContext)
106
+ return normalizeSourceOptions(options, sources)
107
+ }
@@ -0,0 +1,37 @@
1
+ import {type SanityInstance} from '@sanity/sdk'
2
+ import {trackHookMounted} from '@sanity/sdk/_internal'
3
+ import {useRef} from 'react'
4
+
5
+ import {useSanityInstance} from '../context/useSanityInstance'
6
+
7
+ /**
8
+ * Tracks the first usage of a named hook per SDK session.
9
+ * If the telemetry manager hasn't initialized yet, the hook
10
+ * name is buffered and flushed when it becomes available.
11
+ *
12
+ * Uses a ref to ensure the tracking call only happens once per
13
+ * component mount, avoiding repeated WeakMap lookups on re-renders.
14
+ *
15
+ * Call at the top of any public hook whose adoption we want to measure.
16
+ *
17
+ * @internal
18
+ */
19
+ export function useTrackHookUsage(hookName: string): void {
20
+ const instance = useSanityInstance()
21
+ const tracked = useRef(false)
22
+ if (!tracked.current) {
23
+ tracked.current = true
24
+ trackHookMounted(instance, hookName)
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Non-hook variant for tracking hook usage when an instance is already
30
+ * available (avoids an extra `useSanityInstance` call in hooks that
31
+ * already have the instance).
32
+ *
33
+ * @internal
34
+ */
35
+ export function trackHookUsage(instance: SanityInstance, hookName: string): void {
36
+ trackHookMounted(instance, hookName)
37
+ }
@@ -4,6 +4,7 @@ import {pick} from 'lodash-es'
4
4
  import {useCallback, useEffect, useMemo, useState} from 'react'
5
5
 
6
6
  import {useSanityInstance} from '../context/useSanityInstance'
7
+ import {useTrackHookUsage} from '../helpers/useTrackHookUsage'
7
8
  import {useQuery} from '../query/useQuery'
8
9
 
9
10
  /**
@@ -238,6 +239,7 @@ export function usePaginatedDocuments<
238
239
  TDataset,
239
240
  TProjectId
240
241
  > {
242
+ useTrackHookUsage('usePaginatedDocuments')
241
243
  const instance = useSanityInstance(options)
242
244
  const [pageIndex, setPageIndex] = useState(0)
243
245
  const key = JSON.stringify({filter, search, params, orderings, pageSize})
@@ -2,6 +2,7 @@ import {getPresence, type UserPresence} from '@sanity/sdk'
2
2
  import {useCallback, useMemo, useSyncExternalStore} from 'react'
3
3
 
4
4
  import {useSanityInstance} from '../context/useSanityInstance'
5
+ import {trackHookUsage} from '../helpers/useTrackHookUsage'
5
6
 
6
7
  /**
7
8
  * A hook for subscribing to presence information for the current project.
@@ -11,6 +12,7 @@ export function usePresence(): {
11
12
  locations: UserPresence[]
12
13
  } {
13
14
  const sanityInstance = useSanityInstance()
15
+ trackHookUsage(sanityInstance, 'usePresence')
14
16
  const source = useMemo(() => getPresence(sanityInstance), [sanityInstance])
15
17
  const subscribe = useCallback((callback: () => void) => source.subscribe(callback), [source])
16
18
  const locations = useSyncExternalStore(
@@ -1,233 +1,124 @@
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
14
  }
44
15
 
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
- )
56
- }
57
-
58
16
  describe('useDocumentPreview', () => {
59
- let getCurrent: Mock
60
- let subscribe: Mock
61
-
62
17
  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()
18
+ vi.clearAllMocks()
72
19
  })
73
20
 
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()
21
+ test('transforms projection result to preview format', () => {
22
+ const mockProjectionResult: PreviewQueryResult = {
23
+ _id: 'doc1',
24
+ _type: 'exampleType',
25
+ _updatedAt: '2024-01-01',
26
+ titleCandidates: {title: 'Test Title'},
27
+ subtitleCandidates: {description: 'Test Description'},
28
+ media: null,
29
+ }
101
30
 
102
- // Simulate element becoming hidden
103
- await act(async () => {
104
- intersectionObserverCallback([{isIntersecting: false} as IntersectionObserverEntry])
31
+ vi.mocked(useDocumentProjection).mockReturnValue({
32
+ data: mockProjectionResult,
33
+ isPending: false,
105
34
  })
106
35
 
107
- // When hidden, should maintain last known state
108
- expect(screen.getByText('Initial Title')).toBeInTheDocument()
109
- expect(eventsUnsubscribe).toHaveBeenCalled()
36
+ const {result} = renderHook(() => useDocumentPreview(mockDocument))
37
+ const {data, isPending} = result.current
38
+ expect(data.title).toBe('Test Title')
39
+ expect(data.subtitle).toBe('Test Description')
40
+ expect(isPending).toBe(false)
110
41
  })
111
42
 
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',
43
+ test('handles pending state', () => {
44
+ const mockProjectionResult: PreviewQueryResult = {
45
+ _id: 'doc1',
46
+ _type: 'exampleType',
47
+ _updatedAt: '2024-01-01',
48
+ titleCandidates: {title: 'Loading Title'},
49
+ subtitleCandidates: {},
118
50
  media: null,
119
- })
120
- ;(resolvePreview as Mock).mockReturnValueOnce(resolvePromise)
51
+ }
121
52
 
122
- let subscriber: () => void
123
- subscribe.mockImplementation((sub: () => void) => {
124
- subscriber = sub
125
- return vi.fn()
53
+ vi.mocked(useDocumentProjection).mockReturnValue({
54
+ data: mockProjectionResult,
55
+ isPending: true,
126
56
  })
127
57
 
128
- render(
129
- <ResourceProvider fallback={<div>Loading...</div>}>
130
- <TestComponent {...mockDocument} />
131
- </ResourceProvider>,
132
- )
58
+ function TestComponent() {
59
+ const {data, isPending} = useDocumentPreview(mockDocument)
60
+ return (
61
+ <div>
62
+ <h1>{data.title}</h1>
63
+ {isPending && <div>Pending...</div>}
64
+ </div>
65
+ )
66
+ }
133
67
 
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
- })
68
+ render(<TestComponent />)
146
69
 
147
- expect(screen.getByText('Resolved Title')).toBeInTheDocument()
148
- expect(screen.getByText('Resolved Subtitle')).toBeInTheDocument()
70
+ expect(screen.getByText('Loading Title')).toBeInTheDocument()
71
+ expect(screen.getByText('Pending...')).toBeInTheDocument()
149
72
  })
150
73
 
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
74
+ test('uses fallback title when no candidates exist', () => {
75
+ const mockProjectionResult: PreviewQueryResult = {
76
+ _id: 'doc1',
77
+ _type: 'article',
78
+ _updatedAt: '2024-01-01',
79
+ titleCandidates: {},
80
+ subtitleCandidates: {},
81
+ media: null,
82
+ }
156
83
 
157
- getCurrent.mockReturnValue({
158
- data: {title: 'Fallback Title', subtitle: 'Fallback Subtitle'},
84
+ vi.mocked(useDocumentProjection).mockReturnValue({
85
+ data: mockProjectionResult,
159
86
  isPending: false,
160
87
  })
161
- subscribe.mockImplementation(() => vi.fn())
162
88
 
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
89
+ const {result} = renderHook(() => useDocumentPreview(mockDocument))
90
+ const {data} = result.current
91
+ expect(data.title).toBe('article: doc1')
173
92
  })
174
93
 
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
- )
94
+ test('passes ref to useDocumentProjection', () => {
95
+ const mockProjectionResult: PreviewQueryResult = {
96
+ _id: 'doc1',
97
+ _type: 'exampleType',
98
+ _updatedAt: '2024-01-01',
99
+ titleCandidates: {title: 'Title'},
100
+ subtitleCandidates: {},
101
+ media: null,
191
102
  }
192
103
 
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'},
104
+ vi.mocked(useDocumentProjection).mockReturnValue({
105
+ data: mockProjectionResult,
207
106
  isPending: false,
208
107
  })
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
108
 
223
- render(
224
- <ResourceProvider fallback={<div>Loading...</div>}>
225
- <NonHtmlRefComponent {...mockDocument} />
226
- </ResourceProvider>,
109
+ const ref = {current: null}
110
+ const {result} = renderHook(() => useDocumentPreview({...mockDocument, ref}))
111
+ const {data} = result.current
112
+ expect(data.title).toBe('Title')
113
+
114
+ // Verify useDocumentProjection was called with the ref and preview projection
115
+ expect(useDocumentProjection).toHaveBeenCalledWith(
116
+ expect.objectContaining({
117
+ documentId: 'doc1',
118
+ documentType: 'exampleType',
119
+ ref: ref,
120
+ projection: expect.any(String),
121
+ }),
227
122
  )
228
-
229
- // Should subscribe immediately without waiting for intersection
230
- expect(subscribe).toHaveBeenCalled()
231
- expect(screen.getByText('Title')).toBeInTheDocument()
232
123
  })
233
124
  })
@@ -1,14 +1,25 @@
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
+ type DocumentHandle,
3
+ PREVIEW_PROJECTION,
4
+ type PreviewQueryResult,
5
+ type PreviewValue,
6
+ transformProjectionToPreview,
7
+ } from '@sanity/sdk'
8
+ import {useMemo} from 'react'
4
9
 
5
10
  import {useSanityInstance} from '../context/useSanityInstance'
11
+ import {
12
+ useNormalizedSourceOptions,
13
+ type WithSourceNameSupport,
14
+ } from '../helpers/useNormalizedSourceOptions'
15
+ import {trackHookUsage} from '../helpers/useTrackHookUsage'
16
+ import {useDocumentProjection} from '../projection/useDocumentProjection'
6
17
 
7
18
  /**
8
19
  * @public
9
20
  * @category Types
10
21
  */
11
- export interface useDocumentPreviewOptions extends DocumentHandle {
22
+ export interface useDocumentPreviewOptions extends WithSourceNameSupport<DocumentHandle> {
12
23
  /**
13
24
  * Optional ref object to track visibility. When provided, preview resolution
14
25
  * only occurs when the referenced element is visible in the viewport.
@@ -21,7 +32,7 @@ export interface useDocumentPreviewOptions extends DocumentHandle {
21
32
  * @category Types
22
33
  */
23
34
  export interface useDocumentPreviewResults {
24
- /** The results of inferring the documents preview values */
35
+ /** The results of inferring the document's preview values */
25
36
  data: PreviewValue
26
37
  /** True when inferred preview values are being refreshed */
27
38
  isPending: boolean
@@ -31,7 +42,7 @@ export interface useDocumentPreviewResults {
31
42
  * @public
32
43
  *
33
44
  * 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.
45
+ * including the document's `title`, `subtitle`, `media`, and `status`. These values are live and will update in realtime.
35
46
  * To reduce unnecessary network requests for resolving the preview values, an optional `ref` can be passed to the hook so that preview
36
47
  * resolution will only occur if the `ref` is intersecting the current viewport.
37
48
  *
@@ -40,9 +51,13 @@ export interface useDocumentPreviewResults {
40
51
  * @remarks
41
52
  * Values returned by this hook may not be as expected. It is currently unable to read preview values as defined in your schema;
42
53
  * 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.
54
+ * We are anticipating being able to significantly improve this hook's functionality and output in a future release.
44
55
  * For now, we recommend using {@link useDocumentProjection} for rendering individual document fields (or projections of those fields).
45
56
  *
57
+ * Internally, this hook is implemented as a specialized projection with post-processing logic.
58
+ * It uses a fixed GROQ projection to fetch common preview fields (title, subtitle, media) and
59
+ * transforms the results into the PreviewValue format.
60
+ *
46
61
  * @category Documents
47
62
  * @param options - The document handle for the document you want to infer preview values for, and an optional ref
48
63
  * @returns The inferred values for the given document and a boolean to indicate whether the resolution is pending
@@ -84,56 +99,25 @@ export function useDocumentPreview({
84
99
  ...docHandle
85
100
  }: useDocumentPreviewOptions): useDocumentPreviewResults {
86
101
  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
- }
102
+ trackHookUsage(instance, 'useDocumentPreview')
103
+ const normalizedDocHandle = useNormalizedSourceOptions(docHandle)
99
104
 
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})
105
+ // Use the projection hook with the fixed preview projection
106
+ const projectionResult = useDocumentProjection<PreviewQueryResult>({
107
+ ...normalizedDocHandle,
108
+ projection: PREVIEW_PROJECTION,
109
+ ref,
110
+ })
125
111
 
126
- return () => subscription.unsubscribe()
127
- },
128
- [stateSource, ref],
112
+ // Contract: useDocumentProjection suspends while data is null, so data is always available here.
113
+ // Keep this non-null assumption aligned with useDocumentPreviewResults.data.
114
+ const previewValue = useMemo(
115
+ () => transformProjectionToPreview(instance, projectionResult.data, normalizedDocHandle.source),
116
+ [projectionResult.data, instance, normalizedDocHandle.source],
129
117
  )
130
118
 
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)
119
+ return {
120
+ data: previewValue,
121
+ isPending: projectionResult.isPending,
122
+ }
139
123
  }
@@ -8,6 +8,7 @@ import {
8
8
  useNormalizedSourceOptions,
9
9
  type WithSourceNameSupport,
10
10
  } from '../helpers/useNormalizedSourceOptions'
11
+ import {trackHookUsage} from '../helpers/useTrackHookUsage'
11
12
 
12
13
  /**
13
14
  * @public
@@ -181,6 +182,7 @@ export function useDocumentProjection<TData extends object>({
181
182
  ...docHandle
182
183
  }: useDocumentProjectionOptions): useDocumentProjectionResults<TData> {
183
184
  const instance = useSanityInstance(docHandle)
185
+ trackHookUsage(instance, 'useDocumentProjection')
184
186
 
185
187
  // Normalize projection string to handle template literals with whitespace
186
188
  // This ensures that the same projection content produces the same state source
@@ -13,6 +13,7 @@ import {
13
13
  useNormalizedSourceOptions,
14
14
  type WithSourceNameSupport,
15
15
  } from '../helpers/useNormalizedSourceOptions'
16
+ import {trackHookUsage} from '../helpers/useTrackHookUsage'
16
17
  /**
17
18
  * Hook options for useQuery, supporting both direct source and sourceName.
18
19
  * @beta
@@ -152,6 +153,7 @@ export function useQuery(options: WithSourceNameSupport<QueryOptions>): {
152
153
  } {
153
154
  // Implementation returns unknown, overloads define specifics
154
155
  const instance = useSanityInstance(options)
156
+ trackHookUsage(instance, 'useQuery')
155
157
 
156
158
  // Normalize options: resolve sourceName to source and strip sourceName
157
159
  const normalized = useNormalizedSourceOptions(options)