@sanity/sdk-react 2.8.0 → 2.10.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 (53) hide show
  1. package/dist/index.d.ts +232 -47
  2. package/dist/index.js +468 -263
  3. package/dist/index.js.map +1 -1
  4. package/package.json +8 -10
  5. package/src/_exports/sdk-react.ts +5 -0
  6. package/src/components/SDKProvider.tsx +36 -8
  7. package/src/components/SanityApp.tsx +3 -2
  8. package/src/components/auth/AuthBoundary.tsx +8 -1
  9. package/src/components/auth/DashboardAccessRequest.tsx +37 -0
  10. package/src/components/auth/LoginError.test.tsx +191 -5
  11. package/src/components/auth/LoginError.tsx +100 -56
  12. package/src/components/errors/ChunkLoadError.test.tsx +59 -0
  13. package/src/components/errors/ChunkLoadError.tsx +56 -0
  14. package/src/components/errors/chunkReloadStorage.ts +57 -0
  15. package/src/context/ResourceProvider.test.tsx +7 -1
  16. package/src/context/ResourceProvider.tsx +11 -4
  17. package/src/context/ResourcesContext.tsx +7 -0
  18. package/src/context/SDKStudioContext.ts +6 -0
  19. package/src/context/SanityInstanceProvider.test.tsx +100 -0
  20. package/src/context/SanityInstanceProvider.tsx +71 -0
  21. package/src/hooks/auth/useVerifyOrgProjects.tsx +13 -6
  22. package/src/hooks/dashboard/useDispatchIntent.test.ts +8 -6
  23. package/src/hooks/dashboard/useDispatchIntent.ts +6 -6
  24. package/src/hooks/dashboard/useWindowTitle.test.ts +213 -0
  25. package/src/hooks/dashboard/useWindowTitle.ts +112 -0
  26. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.test.ts +15 -15
  27. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +13 -13
  28. package/src/hooks/document/useApplyDocumentActions.test.ts +113 -10
  29. package/src/hooks/document/useApplyDocumentActions.ts +99 -3
  30. package/src/hooks/document/useDocument.ts +22 -6
  31. package/src/hooks/document/useDocumentEvent.test.tsx +3 -3
  32. package/src/hooks/document/useDocumentEvent.ts +10 -3
  33. package/src/hooks/document/useDocumentPermissions.test.tsx +86 -2
  34. package/src/hooks/document/useDocumentPermissions.ts +22 -0
  35. package/src/hooks/document/useDocumentSyncStatus.test.ts +13 -2
  36. package/src/hooks/document/useDocumentSyncStatus.ts +14 -5
  37. package/src/hooks/document/useEditDocument.ts +34 -8
  38. package/src/hooks/documents/useDocuments.ts +11 -6
  39. package/src/hooks/helpers/useNormalizedResourceOptions.ts +131 -0
  40. package/src/hooks/helpers/useTrackHookUsage.ts +37 -0
  41. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +11 -8
  42. package/src/hooks/presence/usePresence.test.tsx +56 -9
  43. package/src/hooks/presence/usePresence.ts +25 -4
  44. package/src/hooks/preview/useDocumentPreview.test.tsx +84 -193
  45. package/src/hooks/preview/useDocumentPreview.tsx +40 -55
  46. package/src/hooks/projection/useDocumentProjection.ts +8 -6
  47. package/src/hooks/query/useQuery.ts +12 -9
  48. package/src/hooks/releases/useActiveReleases.ts +32 -13
  49. package/src/hooks/releases/usePerspective.ts +26 -14
  50. package/src/hooks/users/useUser.ts +2 -0
  51. package/src/hooks/users/useUsers.ts +2 -0
  52. package/src/context/SourcesContext.tsx +0 -7
  53. package/src/hooks/helpers/useNormalizedSourceOptions.ts +0 -85
@@ -0,0 +1,131 @@
1
+ import {type DocumentResource} from '@sanity/sdk'
2
+ import {useContext} from 'react'
3
+
4
+ import {ResourcesContext} from '../../context/ResourcesContext'
5
+
6
+ /**
7
+ * Adds React hook support (resourceName resolution) to core types.
8
+ * This wrapper allows hooks to accept `resourceName` as a convenience,
9
+ * which is then resolved to a `DocumentResource` at the React layer.
10
+ * For now, we are trying to avoid resource name resolution in core --
11
+ * functions having resources explicitly passed will reduce complexity.
12
+ *
13
+ * @typeParam T - The core type to extend (must have optional `resource` field)
14
+ * @beta
15
+ */
16
+ export type WithResourceNameSupport<T extends {resource?: DocumentResource}> = T & {
17
+ /**
18
+ * Optional name of a resource to resolve from context.
19
+ * If provided, will be resolved to a `DocumentResource` via `ResourcesContext`.
20
+ * @beta
21
+ */
22
+ resourceName?: string
23
+ /**
24
+ * @deprecated Use `resourceName` instead.
25
+ * @beta
26
+ */
27
+ sourceName?: string
28
+ }
29
+
30
+ /**
31
+ * Pure function that normalizes options by resolving `resourceName` to a `DocumentResource`
32
+ * using the provided resources map. Use this when options are only available at call time
33
+ * (e.g. inside a callback) and you cannot call the {@link useNormalizedResourceOptions} hook.
34
+ *
35
+ * @typeParam T - The options type (must include optional resource field)
36
+ * @param options - Options that may include `resourceName` and/or `resource`
37
+ * @param resources - Map of resource names to DocumentResource (e.g. from ResourcesContext)
38
+ * @returns Normalized options with `resourceName` removed and `resource` resolved
39
+ * @internal
40
+ */
41
+ export function normalizeResourceOptions<
42
+ T extends {
43
+ resource?: DocumentResource
44
+ resourceName?: string
45
+ source?: DocumentResource
46
+ sourceName?: string
47
+ },
48
+ >(
49
+ options: T,
50
+ resources: Record<string, DocumentResource>,
51
+ ): Omit<T, 'resourceName' | 'source' | 'sourceName'> {
52
+ const {resourceName, sourceName, source, ...rest} = options
53
+
54
+ // Coalesce deprecated aliases to their canonical equivalents
55
+ const effectiveResourceName = resourceName ?? sourceName
56
+ const effectiveResource = options.resource ?? source
57
+
58
+ if (!effectiveResourceName && !effectiveResource) {
59
+ return rest as Omit<T, 'resourceName' | 'source' | 'sourceName'>
60
+ }
61
+
62
+ const hasNameKey = Object.hasOwn(options, 'resourceName') || Object.hasOwn(options, 'sourceName')
63
+ const hasResourceKey = Object.hasOwn(options, 'resource') || Object.hasOwn(options, 'source')
64
+
65
+ if (hasNameKey && hasResourceKey) {
66
+ throw new Error(
67
+ `Resource name ${JSON.stringify(effectiveResourceName)} and resource ${JSON.stringify(effectiveResource)} cannot be used together.`,
68
+ )
69
+ }
70
+
71
+ let resolvedResource: DocumentResource | undefined
72
+
73
+ if (effectiveResource) {
74
+ resolvedResource = effectiveResource
75
+ }
76
+
77
+ if (effectiveResourceName && !Object.hasOwn(resources, effectiveResourceName)) {
78
+ throw new Error(
79
+ `There's no resource named ${JSON.stringify(effectiveResourceName)} in context. Please use <ResourceProvider>.`,
80
+ )
81
+ }
82
+
83
+ if (effectiveResourceName && resources[effectiveResourceName]) {
84
+ resolvedResource = resources[effectiveResourceName]
85
+ }
86
+
87
+ return {
88
+ ...rest,
89
+ resource: resolvedResource,
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Normalizes hook options by resolving `resourceName` to a `DocumentResource`.
95
+ * This hook ensures that options passed to core layer functions only contain
96
+ * `resource` (never `resourceName`), preventing duplicate cache keys and maintaining
97
+ * clean separation between React and core layers.
98
+ *
99
+ * @typeParam T - The options type (must include optional resource field)
100
+ * @param options - Hook options that may include `resourceName` and/or `resource`
101
+ * @returns Normalized options with `resourceName` removed and `resource` resolved
102
+ *
103
+ * @remarks
104
+ * Resolution priority:
105
+ * 1. If `resourceName` is provided, resolves it via `ResourcesContext` and uses that
106
+ * 2. Otherwise, uses the inline `resource` if provided
107
+ * 3. If neither is provided, returns options without a resource field
108
+ *
109
+ * @example
110
+ * ```tsx
111
+ * function useQuery(options: WithResourceNameSupport<QueryOptions>) {
112
+ * const instance = useSanityInstance(options)
113
+ * const normalized = useNormalizedOptions(options)
114
+ * // normalized now has resource but never resourceName
115
+ * const queryKey = getQueryKey(normalized)
116
+ * }
117
+ * ```
118
+ *
119
+ * @beta
120
+ */
121
+ export function useNormalizedResourceOptions<
122
+ T extends {
123
+ resource?: DocumentResource
124
+ resourceName?: string
125
+ source?: DocumentResource
126
+ sourceName?: string
127
+ },
128
+ >(options: T): Omit<T, 'resourceName' | 'source' | 'sourceName'> {
129
+ const resources = useContext(ResourcesContext)
130
+ return normalizeResourceOptions(options, resources)
131
+ }
@@ -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<true | null>(null)
22
+ if (tracked.current === null) {
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
+ }
@@ -1,9 +1,9 @@
1
1
  import {createGroqSearchFilter, type DocumentHandle, type QueryOptions} from '@sanity/sdk'
2
2
  import {type SortOrderingItem} from '@sanity/types'
3
- import {pick} from 'lodash-es'
4
- import {useCallback, useEffect, useMemo, useState} from 'react'
3
+ import {useCallback, useMemo, useState} from 'react'
5
4
 
6
5
  import {useSanityInstance} from '../context/useSanityInstance'
6
+ import {useTrackHookUsage} from '../helpers/useTrackHookUsage'
7
7
  import {useQuery} from '../query/useQuery'
8
8
 
9
9
  /**
@@ -238,14 +238,16 @@ export function usePaginatedDocuments<
238
238
  TDataset,
239
239
  TProjectId
240
240
  > {
241
+ useTrackHookUsage('usePaginatedDocuments')
241
242
  const instance = useSanityInstance(options)
242
243
  const [pageIndex, setPageIndex] = useState(0)
243
244
  const key = JSON.stringify({filter, search, params, orderings, pageSize})
244
- // Reset the pageIndex to 0 whenever any query parameters (filter, search,
245
- // params, orderings) or pageSize changes
246
- useEffect(() => {
245
+ // Reset pageIndex to 0 whenever any query parameter changes.
246
+ const [prevKey, setPrevKey] = useState(key)
247
+ if (prevKey !== key) {
248
+ setPrevKey(key)
247
249
  setPageIndex(0)
248
- }, [key])
250
+ }
249
251
 
250
252
  const startIndex = pageIndex * pageSize
251
253
  const endIndex = (pageIndex + 1) * pageSize
@@ -301,8 +303,9 @@ export function usePaginatedDocuments<
301
303
  ...params,
302
304
  __types: documentTypes,
303
305
  __handle: {
304
- ...pick(instance.config, 'projectId', 'dataset', 'perspective'),
305
- ...pick(options, 'projectId', 'dataset', 'perspective'),
306
+ projectId: options.projectId ?? instance.config.projectId,
307
+ dataset: options.dataset ?? instance.config.dataset,
308
+ perspective: options.perspective ?? instance.config.perspective,
306
309
  },
307
310
  },
308
311
  })
@@ -6,14 +6,17 @@ import {describe, expect, it, vi} from 'vitest'
6
6
  import {ResourceProvider} from '../../context/ResourceProvider'
7
7
  import {usePresence} from './usePresence'
8
8
 
9
- vi.mock('@sanity/sdk', () => ({
10
- getPresence: vi.fn(),
11
- createSanityInstance: vi.fn(() => ({
12
- createChild: vi.fn(),
13
- isDisposed: vi.fn(() => false),
14
- dispose: vi.fn(),
15
- })),
16
- }))
9
+ vi.mock('@sanity/sdk', async (importOriginal) => {
10
+ const actual = await importOriginal<typeof import('@sanity/sdk')>()
11
+ return {
12
+ ...actual,
13
+ getPresence: vi.fn(),
14
+ createSanityInstance: vi.fn(() => ({
15
+ isDisposed: vi.fn(() => false),
16
+ dispose: vi.fn(),
17
+ })),
18
+ }
19
+ })
17
20
 
18
21
  describe('usePresence', () => {
19
22
  it('should return presence locations and update when the store changes', () => {
@@ -59,7 +62,10 @@ describe('usePresence', () => {
59
62
 
60
63
  const {result, unmount} = renderHook(() => usePresence(), {
61
64
  wrapper: ({children}) => (
62
- <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
65
+ <ResourceProvider
66
+ resource={{projectId: 'test-project', dataset: 'test-dataset'}}
67
+ fallback={null}
68
+ >
63
69
  {children}
64
70
  </ResourceProvider>
65
71
  ),
@@ -80,4 +86,45 @@ describe('usePresence', () => {
80
86
  expect(result.current.locations).toEqual(updatedLocations)
81
87
  unmount()
82
88
  })
89
+
90
+ it('should throw an error when used with a media library resource', () => {
91
+ expect(() => {
92
+ renderHook(() => usePresence({resource: {mediaLibraryId: 'ml123'}}), {
93
+ wrapper: ({children}) => (
94
+ <ResourceProvider
95
+ resource={{projectId: 'test-project', dataset: 'test-dataset'}}
96
+ fallback={null}
97
+ >
98
+ {children}
99
+ </ResourceProvider>
100
+ ),
101
+ })
102
+ }).toThrow('usePresence() does not support media library resources')
103
+ })
104
+
105
+ it('should work with a dataset resource', () => {
106
+ const mockPresenceSource = {
107
+ getCurrent: vi.fn().mockReturnValue([]),
108
+ subscribe: vi.fn(() => () => {}),
109
+ observable: NEVER,
110
+ }
111
+ vi.mocked(getPresence).mockReturnValue(mockPresenceSource)
112
+
113
+ const {result, unmount} = renderHook(
114
+ () => usePresence({resource: {projectId: 'test-project', dataset: 'test-dataset'}}),
115
+ {
116
+ wrapper: ({children}) => (
117
+ <ResourceProvider
118
+ resource={{projectId: 'test-project', dataset: 'test-dataset'}}
119
+ fallback={null}
120
+ >
121
+ {children}
122
+ </ResourceProvider>
123
+ ),
124
+ },
125
+ )
126
+
127
+ expect(result.current.locations).toEqual([])
128
+ unmount()
129
+ })
83
130
  })
@@ -1,17 +1,38 @@
1
- import {getPresence, type UserPresence} from '@sanity/sdk'
1
+ import {
2
+ type DatasetHandle,
3
+ getPresence,
4
+ isMediaLibraryResource,
5
+ type UserPresence,
6
+ } from '@sanity/sdk'
2
7
  import {useCallback, useMemo, useSyncExternalStore} from 'react'
3
8
 
4
9
  import {useSanityInstance} from '../context/useSanityInstance'
10
+ import {
11
+ useNormalizedResourceOptions,
12
+ type WithResourceNameSupport,
13
+ } from '../helpers/useNormalizedResourceOptions'
14
+ import {trackHookUsage} from '../helpers/useTrackHookUsage'
5
15
 
6
16
  /**
7
- * A hook for subscribing to presence information for the current project.
17
+ * A hook for subscribing to presence information for the current project or Canvas.
8
18
  * @public
9
19
  */
10
- export function usePresence(): {
20
+ export function usePresence(options: WithResourceNameSupport<DatasetHandle> = {}): {
11
21
  locations: UserPresence[]
12
22
  } {
23
+ const normalizedOptions = useNormalizedResourceOptions(options)
24
+ if (normalizedOptions.resource && isMediaLibraryResource(normalizedOptions.resource)) {
25
+ throw new Error(
26
+ 'usePresence() does not support media library resources. Presence tracking requires a canvas or dataset resource.',
27
+ )
28
+ }
29
+
13
30
  const sanityInstance = useSanityInstance()
14
- const source = useMemo(() => getPresence(sanityInstance), [sanityInstance])
31
+ trackHookUsage(sanityInstance, 'usePresence')
32
+ const source = useMemo(
33
+ () => getPresence(sanityInstance, normalizedOptions),
34
+ [sanityInstance, normalizedOptions],
35
+ )
15
36
  const subscribe = useCallback((callback: () => void) => source.subscribe(callback), [source])
16
37
  const locations = useSyncExternalStore(
17
38
  subscribe,
@@ -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
  })