@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.
- package/dist/index.d.ts +144 -25
- package/dist/index.js +216 -122
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/_exports/sdk-react.ts +1 -0
- package/src/components/SanityApp.tsx +1 -0
- package/src/context/ResourceProvider.test.tsx +7 -1
- package/src/context/ResourceProvider.tsx +6 -0
- package/src/context/SDKStudioContext.ts +6 -0
- package/src/hooks/dashboard/useDispatchIntent.test.ts +2 -0
- package/src/hooks/dashboard/useWindowTitle.test.ts +213 -0
- package/src/hooks/dashboard/useWindowTitle.ts +112 -0
- package/src/hooks/document/useApplyDocumentActions.test.ts +113 -10
- package/src/hooks/document/useApplyDocumentActions.ts +99 -3
- package/src/hooks/document/useDocument.ts +22 -6
- package/src/hooks/document/useDocumentEvent.test.tsx +3 -3
- package/src/hooks/document/useDocumentEvent.ts +10 -3
- package/src/hooks/document/useDocumentPermissions.test.tsx +86 -2
- package/src/hooks/document/useDocumentPermissions.ts +22 -0
- package/src/hooks/document/useDocumentSyncStatus.test.ts +13 -2
- package/src/hooks/document/useDocumentSyncStatus.ts +14 -5
- package/src/hooks/document/useEditDocument.ts +34 -8
- package/src/hooks/documents/useDocuments.ts +2 -0
- package/src/hooks/helpers/useNormalizedSourceOptions.ts +50 -28
- package/src/hooks/helpers/useTrackHookUsage.ts +37 -0
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +2 -0
- package/src/hooks/presence/usePresence.ts +2 -0
- package/src/hooks/preview/useDocumentPreview.test.tsx +84 -193
- package/src/hooks/preview/useDocumentPreview.tsx +39 -55
- package/src/hooks/projection/useDocumentProjection.ts +2 -0
- package/src/hooks/query/useQuery.ts +2 -0
- package/src/hooks/releases/useActiveReleases.ts +32 -13
- package/src/hooks/releases/usePerspective.ts +26 -14
- package/src/hooks/users/useUser.ts +2 -0
- 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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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 -
|
|
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
|
|
54
|
-
T
|
|
55
|
-
|
|
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,
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
|
10
|
-
|
|
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
|
-
|
|
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('
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
31
|
+
vi.mocked(useDocumentProjection).mockReturnValue({
|
|
32
|
+
data: mockProjectionResult,
|
|
33
|
+
isPending: false,
|
|
105
34
|
})
|
|
106
35
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
expect(
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
return vi.fn()
|
|
53
|
+
vi.mocked(useDocumentProjection).mockReturnValue({
|
|
54
|
+
data: mockProjectionResult,
|
|
55
|
+
isPending: true,
|
|
126
56
|
})
|
|
127
57
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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('
|
|
148
|
-
expect(screen.getByText('
|
|
70
|
+
expect(screen.getByText('Loading Title')).toBeInTheDocument()
|
|
71
|
+
expect(screen.getByText('Pending...')).toBeInTheDocument()
|
|
149
72
|
})
|
|
150
73
|
|
|
151
|
-
test('
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
158
|
-
data:
|
|
84
|
+
vi.mocked(useDocumentProjection).mockReturnValue({
|
|
85
|
+
data: mockProjectionResult,
|
|
159
86
|
isPending: false,
|
|
160
87
|
})
|
|
161
|
-
subscribe.mockImplementation(() => vi.fn())
|
|
162
88
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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('
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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 {
|
|
2
|
-
|
|
3
|
-
|
|
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 document
|
|
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 document
|
|
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 hook
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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)
|