@sanity/sdk-react 0.0.0-alpha.3 → 0.0.0-alpha.30

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 (131) hide show
  1. package/README.md +6 -100
  2. package/dist/index.d.ts +2390 -2
  3. package/dist/index.js +1119 -2
  4. package/dist/index.js.map +1 -1
  5. package/package.json +35 -49
  6. package/src/_exports/index.ts +2 -10
  7. package/src/_exports/sdk-react.ts +73 -0
  8. package/src/components/SDKProvider.test.tsx +103 -0
  9. package/src/components/SDKProvider.tsx +52 -0
  10. package/src/components/SanityApp.test.tsx +244 -0
  11. package/src/components/SanityApp.tsx +106 -0
  12. package/src/components/auth/AuthBoundary.test.tsx +204 -29
  13. package/src/components/auth/AuthBoundary.tsx +96 -19
  14. package/src/components/auth/ConfigurationError.ts +22 -0
  15. package/src/components/auth/LoginCallback.test.tsx +22 -24
  16. package/src/components/auth/LoginCallback.tsx +6 -16
  17. package/src/components/auth/LoginError.test.tsx +11 -18
  18. package/src/components/auth/LoginError.tsx +43 -25
  19. package/src/components/utils.ts +22 -0
  20. package/src/context/ResourceProvider.test.tsx +157 -0
  21. package/src/context/ResourceProvider.tsx +111 -0
  22. package/src/context/SanityInstanceContext.ts +4 -0
  23. package/src/hooks/_synchronous-groq-js.mjs +4 -0
  24. package/src/hooks/auth/useAuthState.tsx +4 -5
  25. package/src/hooks/auth/useAuthToken.tsx +1 -1
  26. package/src/hooks/auth/useCurrentUser.tsx +28 -4
  27. package/src/hooks/auth/useDashboardOrganizationId.test.tsx +42 -0
  28. package/src/hooks/auth/useDashboardOrganizationId.tsx +30 -0
  29. package/src/hooks/auth/useHandleAuthCallback.test.tsx +16 -0
  30. package/src/hooks/auth/{useHandleCallback.tsx → useHandleAuthCallback.tsx} +7 -6
  31. package/src/hooks/auth/useLogOut.test.tsx +2 -2
  32. package/src/hooks/auth/useLogOut.tsx +1 -1
  33. package/src/hooks/auth/useLoginUrl.tsx +14 -0
  34. package/src/hooks/auth/useVerifyOrgProjects.test.tsx +136 -0
  35. package/src/hooks/auth/useVerifyOrgProjects.tsx +48 -0
  36. package/src/hooks/client/useClient.ts +13 -33
  37. package/src/hooks/comlink/useFrameConnection.test.tsx +167 -0
  38. package/src/hooks/comlink/useFrameConnection.ts +107 -0
  39. package/src/hooks/comlink/useManageFavorite.test.ts +368 -0
  40. package/src/hooks/comlink/useManageFavorite.ts +210 -0
  41. package/src/hooks/comlink/useRecordDocumentHistoryEvent.test.ts +85 -0
  42. package/src/hooks/comlink/useRecordDocumentHistoryEvent.ts +115 -0
  43. package/src/hooks/comlink/useWindowConnection.test.ts +135 -0
  44. package/src/hooks/comlink/useWindowConnection.ts +123 -0
  45. package/src/hooks/context/useSanityInstance.test.tsx +157 -15
  46. package/src/hooks/context/useSanityInstance.ts +68 -11
  47. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +276 -0
  48. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +139 -0
  49. package/src/hooks/dashboard/useStudioWorkspacesByProjectIdDataset.test.tsx +291 -0
  50. package/src/hooks/dashboard/useStudioWorkspacesByProjectIdDataset.ts +101 -0
  51. package/src/hooks/datasets/useDatasets.test.ts +80 -0
  52. package/src/hooks/datasets/useDatasets.ts +52 -0
  53. package/src/hooks/document/useApplyDocumentActions.test.ts +20 -0
  54. package/src/hooks/document/useApplyDocumentActions.ts +124 -0
  55. package/src/hooks/document/useDocument.test.ts +118 -0
  56. package/src/hooks/document/useDocument.ts +212 -0
  57. package/src/hooks/document/useDocumentEvent.test.ts +62 -0
  58. package/src/hooks/document/useDocumentEvent.ts +94 -0
  59. package/src/hooks/document/useDocumentPermissions.test.ts +204 -0
  60. package/src/hooks/document/useDocumentPermissions.ts +131 -0
  61. package/src/hooks/document/useDocumentSyncStatus.test.ts +23 -0
  62. package/src/hooks/document/useDocumentSyncStatus.ts +61 -0
  63. package/src/hooks/document/useEditDocument.test.ts +196 -0
  64. package/src/hooks/document/useEditDocument.ts +314 -0
  65. package/src/hooks/documents/useDocuments.test.tsx +179 -0
  66. package/src/hooks/documents/useDocuments.ts +300 -0
  67. package/src/hooks/helpers/createCallbackHook.test.tsx +2 -2
  68. package/src/hooks/helpers/createCallbackHook.tsx +1 -1
  69. package/src/hooks/helpers/createStateSourceHook.test.tsx +67 -1
  70. package/src/hooks/helpers/createStateSourceHook.tsx +27 -11
  71. package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +284 -0
  72. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +353 -0
  73. package/src/hooks/preview/usePreview.test.tsx +85 -17
  74. package/src/hooks/preview/usePreview.tsx +81 -22
  75. package/src/hooks/projection/useProjection.test.tsx +283 -0
  76. package/src/hooks/projection/useProjection.ts +232 -0
  77. package/src/hooks/projects/useProject.test.ts +80 -0
  78. package/src/hooks/projects/useProject.ts +51 -0
  79. package/src/hooks/projects/useProjects.test.ts +77 -0
  80. package/src/hooks/projects/useProjects.ts +45 -0
  81. package/src/hooks/query/useQuery.test.tsx +188 -0
  82. package/src/hooks/query/useQuery.ts +193 -0
  83. package/src/hooks/releases/useActiveReleases.test.tsx +84 -0
  84. package/src/hooks/releases/useActiveReleases.ts +39 -0
  85. package/src/hooks/releases/usePerspective.test.tsx +120 -0
  86. package/src/hooks/releases/usePerspective.ts +49 -0
  87. package/src/hooks/users/useUsers.test.tsx +330 -0
  88. package/src/hooks/users/useUsers.ts +120 -0
  89. package/src/utils/getEnv.ts +21 -0
  90. package/src/version.ts +8 -0
  91. package/src/vite-env.d.ts +10 -0
  92. package/dist/_chunks-es/useLogOut.js +0 -44
  93. package/dist/_chunks-es/useLogOut.js.map +0 -1
  94. package/dist/assets/bundle-CcAyERuZ.css +0 -11
  95. package/dist/components.d.ts +0 -259
  96. package/dist/components.js +0 -301
  97. package/dist/components.js.map +0 -1
  98. package/dist/hooks.d.ts +0 -186
  99. package/dist/hooks.js +0 -81
  100. package/dist/hooks.js.map +0 -1
  101. package/src/_exports/components.ts +0 -13
  102. package/src/_exports/hooks.ts +0 -9
  103. package/src/components/DocumentGridLayout/DocumentGridLayout.stories.tsx +0 -113
  104. package/src/components/DocumentGridLayout/DocumentGridLayout.test.tsx +0 -42
  105. package/src/components/DocumentGridLayout/DocumentGridLayout.tsx +0 -21
  106. package/src/components/DocumentListLayout/DocumentListLayout.stories.tsx +0 -105
  107. package/src/components/DocumentListLayout/DocumentListLayout.test.tsx +0 -42
  108. package/src/components/DocumentListLayout/DocumentListLayout.tsx +0 -12
  109. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.md +0 -49
  110. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.stories.tsx +0 -39
  111. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.test.tsx +0 -30
  112. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.tsx +0 -171
  113. package/src/components/Login/LoginLinks.test.tsx +0 -100
  114. package/src/components/Login/LoginLinks.tsx +0 -73
  115. package/src/components/auth/Login.test.tsx +0 -41
  116. package/src/components/auth/Login.tsx +0 -45
  117. package/src/components/auth/LoginFooter.test.tsx +0 -29
  118. package/src/components/auth/LoginFooter.tsx +0 -65
  119. package/src/components/auth/LoginLayout.test.tsx +0 -33
  120. package/src/components/auth/LoginLayout.tsx +0 -81
  121. package/src/components/context/SanityProvider.test.tsx +0 -25
  122. package/src/components/context/SanityProvider.tsx +0 -42
  123. package/src/css/css.config.js +0 -220
  124. package/src/css/paramour.css +0 -2347
  125. package/src/css/styles.css +0 -11
  126. package/src/hooks/auth/useHandleCallback.test.tsx +0 -16
  127. package/src/hooks/auth/useLoginUrls.test.tsx +0 -68
  128. package/src/hooks/auth/useLoginUrls.tsx +0 -51
  129. package/src/hooks/client/useClient.test.tsx +0 -130
  130. package/src/hooks/documentCollection/useDocuments.test.ts +0 -130
  131. package/src/hooks/documentCollection/useDocuments.ts +0 -87
@@ -1,7 +1,7 @@
1
1
  import {type DocumentHandle, getPreviewState, type PreviewValue, resolvePreview} from '@sanity/sdk'
2
2
  import {act, render, screen} from '@testing-library/react'
3
- import {Suspense, useState} from 'react'
4
- import type {Mock} from 'vitest'
3
+ import {Suspense, useRef} from 'react'
4
+ import {type Mock} from 'vitest'
5
5
 
6
6
  import {usePreview} from './usePreview'
7
7
 
@@ -39,19 +39,19 @@ vi.mock('../context/useSanityInstance', () => ({
39
39
  }))
40
40
 
41
41
  const mockDocument: DocumentHandle = {
42
- _id: 'doc1',
43
- _type: 'exampleType',
42
+ documentId: 'doc1',
43
+ documentType: 'exampleType',
44
44
  }
45
45
 
46
- function TestComponent({document}: {document: DocumentHandle}) {
47
- const [ref, setRef] = useState<HTMLElement | null>(null)
48
- const [previewValue, pending] = usePreview({document, ref})
46
+ function TestComponent(docHandle: DocumentHandle) {
47
+ const ref = useRef(null)
48
+ const {data, isPending} = usePreview({...docHandle, ref})
49
49
 
50
50
  return (
51
- <div ref={setRef}>
52
- <h1>{previewValue.title}</h1>
53
- <p>{previewValue.subtitle}</p>
54
- {pending && <div>Pending...</div>}
51
+ <div ref={ref}>
52
+ <h1>{data?.title}</h1>
53
+ <p>{data?.subtitle}</p>
54
+ {isPending && <div>Pending...</div>}
55
55
  </div>
56
56
  )
57
57
  }
@@ -74,13 +74,16 @@ describe('usePreview', () => {
74
74
 
75
75
  test('it only subscribes when element is visible', async () => {
76
76
  // Setup initial state
77
- getCurrent.mockReturnValue([{title: 'Initial Title', subtitle: 'Initial Subtitle'}, false])
77
+ getCurrent.mockReturnValue({
78
+ data: {title: 'Initial Title', subtitle: 'Initial Subtitle'},
79
+ isPending: false,
80
+ })
78
81
  const eventsUnsubscribe = vi.fn()
79
82
  subscribe.mockImplementation(() => eventsUnsubscribe)
80
83
 
81
84
  render(
82
85
  <Suspense fallback={<div>Loading...</div>}>
83
- <TestComponent document={mockDocument} />
86
+ <TestComponent {...mockDocument} />
84
87
  </Suspense>,
85
88
  )
86
89
 
@@ -125,7 +128,7 @@ describe('usePreview', () => {
125
128
 
126
129
  render(
127
130
  <Suspense fallback={<div>Loading...</div>}>
128
- <TestComponent document={mockDocument} />
131
+ <TestComponent {...mockDocument} />
129
132
  </Suspense>,
130
133
  )
131
134
 
@@ -135,7 +138,10 @@ describe('usePreview', () => {
135
138
  await act(async () => {
136
139
  intersectionObserverCallback([{isIntersecting: true} as IntersectionObserverEntry])
137
140
  await resolvePromise
138
- getCurrent.mockReturnValue([{title: 'Resolved Title', subtitle: 'Resolved Subtitle'}, false])
141
+ getCurrent.mockReturnValue({
142
+ data: {title: 'Resolved Title', subtitle: 'Resolved Subtitle'},
143
+ isPending: false,
144
+ })
139
145
  subscriber?.()
140
146
  })
141
147
 
@@ -149,12 +155,15 @@ describe('usePreview', () => {
149
155
  // @ts-expect-error - Intentionally removing IntersectionObserver
150
156
  delete window.IntersectionObserver
151
157
 
152
- getCurrent.mockReturnValue([{title: 'Fallback Title', subtitle: 'Fallback Subtitle'}, false])
158
+ getCurrent.mockReturnValue({
159
+ data: {title: 'Fallback Title', subtitle: 'Fallback Subtitle'},
160
+ isPending: false,
161
+ })
153
162
  subscribe.mockImplementation(() => vi.fn())
154
163
 
155
164
  render(
156
165
  <Suspense fallback={<div>Loading...</div>}>
157
- <TestComponent document={mockDocument} />
166
+ <TestComponent {...mockDocument} />
158
167
  </Suspense>,
159
168
  )
160
169
 
@@ -163,4 +172,63 @@ describe('usePreview', () => {
163
172
  // Restore IntersectionObserver
164
173
  window.IntersectionObserver = originalIntersectionObserver
165
174
  })
175
+
176
+ test('it subscribes immediately when no ref is provided', async () => {
177
+ getCurrent.mockReturnValue({
178
+ data: {title: 'Title', subtitle: 'Subtitle'},
179
+ isPending: false,
180
+ })
181
+ const eventsUnsubscribe = vi.fn()
182
+ subscribe.mockImplementation(() => eventsUnsubscribe)
183
+
184
+ function NoRefComponent(docHandle: DocumentHandle) {
185
+ const {data} = usePreview(docHandle) // No ref provided
186
+ return (
187
+ <div>
188
+ <h1>{data?.title}</h1>
189
+ <p>{data?.subtitle}</p>
190
+ </div>
191
+ )
192
+ }
193
+
194
+ render(
195
+ <Suspense fallback={<div>Loading...</div>}>
196
+ <NoRefComponent {...mockDocument} />
197
+ </Suspense>,
198
+ )
199
+
200
+ // Should subscribe immediately without waiting for intersection
201
+ expect(subscribe).toHaveBeenCalled()
202
+ expect(screen.getByText('Title')).toBeInTheDocument()
203
+ })
204
+
205
+ test('it subscribes immediately when ref.current is not an HTML element', async () => {
206
+ getCurrent.mockReturnValue({
207
+ data: {title: 'Title', subtitle: 'Subtitle'},
208
+ isPending: false,
209
+ })
210
+ const eventsUnsubscribe = vi.fn()
211
+ subscribe.mockImplementation(() => eventsUnsubscribe)
212
+
213
+ function NonHtmlRefComponent(docHandle: DocumentHandle) {
214
+ const ref = useRef({}) // ref.current is not an HTML element
215
+ const {data} = usePreview({...docHandle, ref})
216
+ return (
217
+ <div>
218
+ <h1>{data?.title}</h1>
219
+ <p>{data?.subtitle}</p>
220
+ </div>
221
+ )
222
+ }
223
+
224
+ render(
225
+ <Suspense fallback={<div>Loading...</div>}>
226
+ <NonHtmlRefComponent {...mockDocument} />
227
+ </Suspense>,
228
+ )
229
+
230
+ // Should subscribe immediately without waiting for intersection
231
+ expect(subscribe).toHaveBeenCalled()
232
+ expect(screen.getByText('Title')).toBeInTheDocument()
233
+ })
166
234
  })
@@ -1,43 +1,102 @@
1
1
  import {type DocumentHandle, getPreviewState, type PreviewValue, resolvePreview} from '@sanity/sdk'
2
- import {useCallback, useMemo, useSyncExternalStore} from 'react'
2
+ import {useCallback, useSyncExternalStore} from 'react'
3
3
  import {distinctUntilChanged, EMPTY, Observable, startWith, switchMap} from 'rxjs'
4
4
 
5
5
  import {useSanityInstance} from '../context/useSanityInstance'
6
6
 
7
7
  /**
8
- * @alpha
8
+ * @beta
9
+ * @category Types
9
10
  */
10
- export interface UsePreviewOptions {
11
- document: DocumentHandle
12
- ref: HTMLElement | null
11
+ export interface UsePreviewOptions extends DocumentHandle {
12
+ /**
13
+ * Optional ref object to track visibility. When provided, preview resolution
14
+ * only occurs when the referenced element is visible in the viewport.
15
+ */
16
+ ref?: React.RefObject<unknown>
13
17
  }
14
18
 
15
19
  /**
16
- * @alpha
20
+ * @beta
21
+ * @category Types
17
22
  */
18
- export function usePreview({
19
- document: {_id, _type},
20
- ref,
21
- }: UsePreviewOptions): [PreviewValue, boolean] {
22
- const instance = useSanityInstance()
23
+ export interface UsePreviewResults {
24
+ /** The results of resolving the document’s preview values */
25
+ data: PreviewValue
26
+ /** True when preview values are being refreshed */
27
+ isPending: boolean
28
+ }
23
29
 
24
- const stateSource = useMemo(
25
- () => getPreviewState(instance, {document: {_id, _type}}),
26
- [instance, _id, _type],
27
- )
30
+ /**
31
+ * @beta
32
+ *
33
+ * Returns the preview values of a document (specified via a `DocumentHandle`),
34
+ * including the document’s `title`, `subtitle`, `media`, and `status`. These values are live and will update in realtime.
35
+ * To reduce unnecessary network requests for resolving the preview values, an optional `ref` can be passed to the hook so that preview
36
+ * resolution will only occur if the `ref` is intersecting the current viewport.
37
+ *
38
+ * @category Documents
39
+ * @param options - The document handle for the document you want to resolve preview values for, and an optional ref
40
+ * @returns The preview values for the given document and a boolean to indicate whether the resolution is pending
41
+ *
42
+ * @example Combining with useDocuments to render a collection of document previews
43
+ * ```
44
+ * // PreviewComponent.jsx
45
+ * export default function PreviewComponent({ document }) {
46
+ * const { data: { title, subtitle, media }, isPending } = usePreview({ document })
47
+ * return (
48
+ * <article style={{ opacity: isPending ? 0.5 : 1}}>
49
+ * {media?.type === 'image-asset' ? <img src={media.url} alt='' /> : ''}
50
+ * <h2>{title}</h2>
51
+ * <p>{subtitle}</p>
52
+ * </article>
53
+ * )
54
+ * }
55
+ *
56
+ * // DocumentList.jsx
57
+ * const { data } = useDocuments({ filter: '_type == "movie"' })
58
+ * return (
59
+ * <div>
60
+ * <h1>Movies</h1>
61
+ * <ul>
62
+ * {data.map(movie => (
63
+ * <li key={movie._id}>
64
+ * <Suspense fallback='Loading…'>
65
+ * <PreviewComponent document={movie} />
66
+ * </Suspense>
67
+ * </li>
68
+ * ))}
69
+ * </ul>
70
+ * </div>
71
+ * )
72
+ * ```
73
+ */
74
+ export function usePreview({ref, ...docHandle}: UsePreviewOptions): UsePreviewResults {
75
+ const instance = useSanityInstance()
76
+ const stateSource = getPreviewState(instance, docHandle)
28
77
 
29
78
  // Create subscribe function for useSyncExternalStore
30
79
  const subscribe = useCallback(
31
80
  (onStoreChanged: () => void) => {
32
81
  const subscription = new Observable<boolean>((observer) => {
33
- // for environments that don't have an intersection observer
34
- if (typeof IntersectionObserver === 'undefined') return
82
+ // For environments that don't have an intersection observer (e.g. server-side),
83
+ // we pass true to always subscribe since we can't detect visibility
84
+ if (typeof IntersectionObserver === 'undefined' || typeof HTMLElement === 'undefined') {
85
+ observer.next(true)
86
+ return
87
+ }
35
88
 
36
89
  const intersectionObserver = new IntersectionObserver(
37
90
  ([entry]) => observer.next(entry.isIntersecting),
38
91
  {rootMargin: '0px', threshold: 0},
39
92
  )
40
- if (ref) intersectionObserver.observe(ref)
93
+ if (ref?.current && ref.current instanceof HTMLElement) {
94
+ intersectionObserver.observe(ref.current)
95
+ } else {
96
+ // If no ref is provided or ref.current isn't an HTML element,
97
+ // pass true to always subscribe since we can't track visibility
98
+ observer.next(true)
99
+ }
41
100
  return () => intersectionObserver.disconnect()
42
101
  })
43
102
  .pipe(
@@ -60,10 +119,10 @@ export function usePreview({
60
119
 
61
120
  // Create getSnapshot function to return current state
62
121
  const getSnapshot = useCallback(() => {
63
- const previewTuple = stateSource.getCurrent()
64
- if (!previewTuple[0]) throw resolvePreview(instance, {document: {_id, _type}})
65
- return previewTuple as [PreviewValue, boolean]
66
- }, [_id, _type, instance, stateSource])
122
+ const currentState = stateSource.getCurrent()
123
+ if (currentState.data === null) throw resolvePreview(instance, docHandle)
124
+ return currentState as UsePreviewResults
125
+ }, [docHandle, instance, stateSource])
67
126
 
68
127
  return useSyncExternalStore(subscribe, getSnapshot)
69
128
  }
@@ -0,0 +1,283 @@
1
+ import {
2
+ type DocumentHandle,
3
+ getProjectionState,
4
+ resolveProjection,
5
+ type ValidProjection,
6
+ } from '@sanity/sdk'
7
+ import {act, render, screen} from '@testing-library/react'
8
+ import {Suspense, useRef} from 'react'
9
+ import {type Mock} from 'vitest'
10
+
11
+ import {useProjection} from './useProjection'
12
+
13
+ // Mock IntersectionObserver
14
+ const mockIntersectionObserver = vi.fn()
15
+ let intersectionObserverCallback: (entries: IntersectionObserverEntry[]) => void
16
+
17
+ beforeAll(() => {
18
+ vi.stubGlobal(
19
+ 'IntersectionObserver',
20
+ class {
21
+ constructor(callback: (entries: IntersectionObserverEntry[]) => void) {
22
+ intersectionObserverCallback = callback
23
+ mockIntersectionObserver(callback)
24
+ }
25
+ observe = vi.fn()
26
+ disconnect = vi.fn()
27
+ },
28
+ )
29
+ })
30
+
31
+ // Mock the projection store
32
+ vi.mock('@sanity/sdk', () => {
33
+ const getCurrent = vi.fn()
34
+ const subscribe = vi.fn()
35
+
36
+ return {
37
+ resolveProjection: vi.fn(),
38
+ getProjectionState: vi.fn().mockReturnValue({getCurrent, subscribe}),
39
+ }
40
+ })
41
+
42
+ vi.mock('../context/useSanityInstance', () => ({
43
+ useSanityInstance: () => ({}),
44
+ }))
45
+
46
+ const mockDocument: DocumentHandle = {
47
+ documentId: 'doc1',
48
+ documentType: 'exampleType',
49
+ }
50
+
51
+ interface ProjectionResult {
52
+ title: string
53
+ description: string
54
+ }
55
+
56
+ function TestComponent({
57
+ document,
58
+ projection,
59
+ }: {
60
+ document: DocumentHandle
61
+ projection: ValidProjection
62
+ }) {
63
+ const ref = useRef(null)
64
+ const {data, isPending} = useProjection<ProjectionResult>({...document, projection, ref})
65
+
66
+ return (
67
+ <div ref={ref}>
68
+ <h1>{data.title}</h1>
69
+ <p>{data.description}</p>
70
+ {isPending && <div>Pending...</div>}
71
+ </div>
72
+ )
73
+ }
74
+
75
+ describe('useProjection', () => {
76
+ let getCurrent: Mock
77
+ let subscribe: Mock
78
+
79
+ beforeEach(() => {
80
+ // @ts-expect-error mock does not need param
81
+ getCurrent = getProjectionState().getCurrent as Mock
82
+ // @ts-expect-error mock does not need param
83
+ subscribe = getProjectionState().subscribe as Mock
84
+
85
+ // Reset all mocks between tests
86
+ getCurrent.mockReset()
87
+ subscribe.mockReset()
88
+ mockIntersectionObserver.mockReset()
89
+ })
90
+
91
+ test('it only subscribes when element is visible', async () => {
92
+ // Setup initial state
93
+ getCurrent.mockReturnValue({
94
+ data: {title: 'Initial Title', description: 'Initial Description'},
95
+ isPending: false,
96
+ })
97
+ const eventsUnsubscribe = vi.fn()
98
+ subscribe.mockImplementation(() => eventsUnsubscribe)
99
+
100
+ render(
101
+ <Suspense fallback={<div>Loading...</div>}>
102
+ <TestComponent document={mockDocument} projection="{name, description}" />
103
+ </Suspense>,
104
+ )
105
+
106
+ // Initially, element is not intersecting
107
+ expect(screen.getByText('Initial Title')).toBeInTheDocument()
108
+ expect(subscribe).not.toHaveBeenCalled()
109
+
110
+ // Simulate element becoming visible
111
+ await act(async () => {
112
+ intersectionObserverCallback([{isIntersecting: true} as IntersectionObserverEntry])
113
+ })
114
+
115
+ // After element becomes visible, events subscription should be active
116
+ expect(subscribe).toHaveBeenCalled()
117
+ expect(eventsUnsubscribe).not.toHaveBeenCalled()
118
+
119
+ // Simulate element becoming hidden
120
+ await act(async () => {
121
+ intersectionObserverCallback([{isIntersecting: false} as IntersectionObserverEntry])
122
+ })
123
+
124
+ // When hidden, should maintain last known state
125
+ expect(screen.getByText('Initial Title')).toBeInTheDocument()
126
+ expect(eventsUnsubscribe).toHaveBeenCalled()
127
+ })
128
+
129
+ test('it suspends and resolves data when element becomes visible', async () => {
130
+ // Mock the initial state to trigger suspense
131
+ getCurrent.mockReturnValueOnce({
132
+ data: null,
133
+ isPending: true,
134
+ })
135
+
136
+ const resolvedData = {
137
+ data: {title: 'Resolved Title', description: 'Resolved Description'},
138
+ isPending: false,
139
+ }
140
+
141
+ // Mock resolveProjection to return a promise that resolves immediately
142
+ ;(resolveProjection as Mock).mockReturnValueOnce(Promise.resolve(resolvedData))
143
+
144
+ // After suspense resolves, return the resolved data
145
+ getCurrent.mockReturnValue(resolvedData)
146
+
147
+ // Setup subscription that does nothing (we'll manually trigger updates)
148
+ subscribe.mockReturnValue(() => {})
149
+
150
+ render(
151
+ <Suspense fallback={<div>Loading...</div>}>
152
+ <TestComponent document={mockDocument} projection="{title, description}" />
153
+ </Suspense>,
154
+ )
155
+
156
+ await act(async () => {
157
+ intersectionObserverCallback([{isIntersecting: true} as IntersectionObserverEntry])
158
+ await Promise.resolve()
159
+ })
160
+
161
+ expect(screen.getByText('Resolved Title')).toBeInTheDocument()
162
+ expect(screen.getByText('Resolved Description')).toBeInTheDocument()
163
+ })
164
+
165
+ test('it handles environments without IntersectionObserver', async () => {
166
+ // Temporarily remove IntersectionObserver
167
+ const originalIntersectionObserver = window.IntersectionObserver
168
+ // @ts-expect-error - Intentionally removing IntersectionObserver
169
+ delete window.IntersectionObserver
170
+
171
+ getCurrent.mockReturnValue({
172
+ data: {title: 'Fallback Title', description: 'Fallback Description'},
173
+ isPending: false,
174
+ })
175
+ subscribe.mockImplementation(() => vi.fn())
176
+
177
+ render(
178
+ <Suspense fallback={<div>Loading...</div>}>
179
+ <TestComponent document={mockDocument} projection="{title, description}" />
180
+ </Suspense>,
181
+ )
182
+
183
+ expect(screen.getByText('Fallback Title')).toBeInTheDocument()
184
+
185
+ // Restore IntersectionObserver
186
+ window.IntersectionObserver = originalIntersectionObserver
187
+ })
188
+
189
+ test('it updates when projection changes', async () => {
190
+ getCurrent.mockReturnValue({
191
+ data: {title: 'Initial Title'},
192
+ isPending: false,
193
+ })
194
+ const eventsUnsubscribe = vi.fn()
195
+ subscribe.mockImplementation(() => eventsUnsubscribe)
196
+
197
+ const {rerender} = render(
198
+ <Suspense fallback={<div>Loading...</div>}>
199
+ <TestComponent document={mockDocument} projection="{title}" />
200
+ </Suspense>,
201
+ )
202
+
203
+ // Change projection
204
+ getCurrent.mockReturnValue({
205
+ data: {title: 'Updated Title', description: 'Added Description'},
206
+ isPending: false,
207
+ })
208
+
209
+ rerender(
210
+ <Suspense fallback={<div>Loading...</div>}>
211
+ <TestComponent document={mockDocument} projection="{title, description}" />
212
+ </Suspense>,
213
+ )
214
+
215
+ expect(screen.getByText('Updated Title')).toBeInTheDocument()
216
+ expect(screen.getByText('Added Description')).toBeInTheDocument()
217
+ })
218
+
219
+ test('it subscribes immediately when no ref is provided', async () => {
220
+ getCurrent.mockReturnValue({
221
+ data: {title: 'Title', description: 'Description'},
222
+ isPending: false,
223
+ })
224
+ const eventsUnsubscribe = vi.fn()
225
+ subscribe.mockImplementation(() => eventsUnsubscribe)
226
+
227
+ function NoRefComponent({
228
+ projection,
229
+ ...docHandle
230
+ }: DocumentHandle & {projection: ValidProjection}) {
231
+ const {data} = useProjection<ProjectionResult>({...docHandle, projection}) // No ref provided
232
+ return (
233
+ <div>
234
+ <h1>{data.title}</h1>
235
+ <p>{data.description}</p>
236
+ </div>
237
+ )
238
+ }
239
+
240
+ render(
241
+ <Suspense fallback={<div>Loading...</div>}>
242
+ <NoRefComponent {...mockDocument} projection="{title, description}" />
243
+ </Suspense>,
244
+ )
245
+
246
+ // Should subscribe immediately without waiting for intersection
247
+ expect(subscribe).toHaveBeenCalled()
248
+ expect(screen.getByText('Title')).toBeInTheDocument()
249
+ })
250
+
251
+ test('it subscribes immediately when ref.current is not an HTML element', async () => {
252
+ getCurrent.mockReturnValue({
253
+ data: {title: 'Title', description: 'Description'},
254
+ isPending: false,
255
+ })
256
+ const eventsUnsubscribe = vi.fn()
257
+ subscribe.mockImplementation(() => eventsUnsubscribe)
258
+
259
+ function NonHtmlRefComponent({
260
+ projection,
261
+ ...docHandle
262
+ }: DocumentHandle & {projection: ValidProjection}) {
263
+ const ref = useRef({}) // ref.current is not an HTML element
264
+ const {data} = useProjection<ProjectionResult>({...docHandle, projection, ref})
265
+ return (
266
+ <div>
267
+ <h1>{data.title}</h1>
268
+ <p>{data.description}</p>
269
+ </div>
270
+ )
271
+ }
272
+
273
+ render(
274
+ <Suspense fallback={<div>Loading...</div>}>
275
+ <NonHtmlRefComponent {...mockDocument} projection="{title, description}" />
276
+ </Suspense>,
277
+ )
278
+
279
+ // Should subscribe immediately without waiting for intersection
280
+ expect(subscribe).toHaveBeenCalled()
281
+ expect(screen.getByText('Title')).toBeInTheDocument()
282
+ })
283
+ })