@sanity/sdk-react 0.0.0-alpha.2 → 0.0.0-alpha.21

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 (116) hide show
  1. package/README.md +38 -67
  2. package/dist/index.d.ts +4811 -2
  3. package/dist/index.js +1069 -2
  4. package/dist/index.js.map +1 -1
  5. package/package.json +27 -58
  6. package/src/_exports/index.ts +66 -10
  7. package/src/components/Login/LoginLinks.test.tsx +4 -14
  8. package/src/components/Login/LoginLinks.tsx +16 -31
  9. package/src/components/SDKProvider.test.tsx +79 -0
  10. package/src/components/SDKProvider.tsx +42 -0
  11. package/src/components/SanityApp.test.tsx +156 -0
  12. package/src/components/SanityApp.tsx +90 -0
  13. package/src/components/auth/AuthBoundary.test.tsx +6 -19
  14. package/src/components/auth/AuthBoundary.tsx +20 -4
  15. package/src/components/auth/Login.test.tsx +2 -16
  16. package/src/components/auth/Login.tsx +11 -30
  17. package/src/components/auth/LoginCallback.test.tsx +5 -20
  18. package/src/components/auth/LoginCallback.tsx +9 -14
  19. package/src/components/auth/LoginError.test.tsx +2 -17
  20. package/src/components/auth/LoginError.tsx +11 -16
  21. package/src/components/auth/LoginFooter.test.tsx +2 -16
  22. package/src/components/auth/LoginFooter.tsx +8 -24
  23. package/src/components/auth/LoginLayout.test.tsx +2 -16
  24. package/src/components/auth/LoginLayout.tsx +8 -38
  25. package/src/components/auth/authTestHelpers.tsx +11 -0
  26. package/src/components/utils.ts +22 -0
  27. package/src/context/SanityInstanceContext.ts +4 -0
  28. package/src/{components/context → context}/SanityProvider.test.tsx +2 -2
  29. package/src/context/SanityProvider.tsx +50 -0
  30. package/src/hooks/_synchronous-groq-js.mjs +4 -0
  31. package/src/hooks/auth/useAuthState.tsx +4 -5
  32. package/src/hooks/auth/useAuthToken.tsx +1 -1
  33. package/src/hooks/auth/useCurrentUser.tsx +28 -4
  34. package/src/hooks/auth/useDashboardOrganizationId.test.tsx +42 -0
  35. package/src/hooks/auth/useDashboardOrganizationId.tsx +29 -0
  36. package/src/hooks/auth/useHandleAuthCallback.test.tsx +16 -0
  37. package/src/hooks/auth/{useHandleCallback.tsx → useHandleAuthCallback.tsx} +7 -6
  38. package/src/hooks/auth/useLogOut.test.tsx +2 -2
  39. package/src/hooks/auth/useLogOut.tsx +1 -1
  40. package/src/hooks/auth/useLoginUrls.tsx +1 -0
  41. package/src/hooks/client/useClient.ts +9 -30
  42. package/src/hooks/comlink/useFrameConnection.test.tsx +167 -0
  43. package/src/hooks/comlink/useFrameConnection.ts +107 -0
  44. package/src/hooks/comlink/useManageFavorite.test.ts +111 -0
  45. package/src/hooks/comlink/useManageFavorite.ts +130 -0
  46. package/src/hooks/comlink/useRecordDocumentHistoryEvent.test.ts +81 -0
  47. package/src/hooks/comlink/useRecordDocumentHistoryEvent.ts +106 -0
  48. package/src/hooks/comlink/useWindowConnection.test.ts +135 -0
  49. package/src/hooks/comlink/useWindowConnection.ts +122 -0
  50. package/src/hooks/context/useSanityInstance.test.tsx +2 -2
  51. package/src/hooks/context/useSanityInstance.ts +24 -8
  52. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +178 -0
  53. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +123 -0
  54. package/src/hooks/dashboard/useStudioWorkspacesByResourceId.test.tsx +278 -0
  55. package/src/hooks/dashboard/useStudioWorkspacesByResourceId.ts +92 -0
  56. package/src/hooks/datasets/useDatasets.ts +40 -0
  57. package/src/hooks/document/useApplyDocumentActions.test.ts +25 -0
  58. package/src/hooks/document/useApplyDocumentActions.ts +75 -0
  59. package/src/hooks/document/useDocument.test.ts +81 -0
  60. package/src/hooks/document/useDocument.ts +107 -0
  61. package/src/hooks/document/useDocumentEvent.test.ts +63 -0
  62. package/src/hooks/document/useDocumentEvent.ts +54 -0
  63. package/src/hooks/document/useDocumentPermissions.ts +84 -0
  64. package/src/hooks/document/useDocumentSyncStatus.test.ts +16 -0
  65. package/src/hooks/document/useDocumentSyncStatus.ts +33 -0
  66. package/src/hooks/document/useEditDocument.test.ts +179 -0
  67. package/src/hooks/document/useEditDocument.ts +195 -0
  68. package/src/hooks/documents/useDocuments.test.tsx +152 -0
  69. package/src/hooks/documents/useDocuments.ts +174 -0
  70. package/src/hooks/helpers/createCallbackHook.tsx +3 -2
  71. package/src/hooks/helpers/createStateSourceHook.test.tsx +66 -0
  72. package/src/hooks/helpers/createStateSourceHook.tsx +29 -10
  73. package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +259 -0
  74. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +290 -0
  75. package/src/hooks/preview/usePreview.test.tsx +19 -10
  76. package/src/hooks/preview/usePreview.tsx +67 -13
  77. package/src/hooks/projection/useProjection.test.tsx +218 -0
  78. package/src/hooks/projection/useProjection.ts +147 -0
  79. package/src/hooks/projects/useProject.ts +48 -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 +103 -0
  83. package/src/hooks/users/useUsers.test.ts +163 -0
  84. package/src/hooks/users/useUsers.ts +107 -0
  85. package/src/utils/getEnv.ts +21 -0
  86. package/src/version.ts +8 -0
  87. package/src/vite-env.d.ts +10 -0
  88. package/dist/_chunks-es/useLogOut.js +0 -44
  89. package/dist/_chunks-es/useLogOut.js.map +0 -1
  90. package/dist/assets/bundle-CcAyERuZ.css +0 -11
  91. package/dist/components.d.ts +0 -257
  92. package/dist/components.js +0 -316
  93. package/dist/components.js.map +0 -1
  94. package/dist/hooks.d.ts +0 -187
  95. package/dist/hooks.js +0 -81
  96. package/dist/hooks.js.map +0 -1
  97. package/src/_exports/components.ts +0 -13
  98. package/src/_exports/hooks.ts +0 -9
  99. package/src/components/DocumentGridLayout/DocumentGridLayout.stories.tsx +0 -113
  100. package/src/components/DocumentGridLayout/DocumentGridLayout.test.tsx +0 -42
  101. package/src/components/DocumentGridLayout/DocumentGridLayout.tsx +0 -21
  102. package/src/components/DocumentListLayout/DocumentListLayout.stories.tsx +0 -105
  103. package/src/components/DocumentListLayout/DocumentListLayout.test.tsx +0 -42
  104. package/src/components/DocumentListLayout/DocumentListLayout.tsx +0 -12
  105. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.md +0 -49
  106. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.stories.tsx +0 -39
  107. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.test.tsx +0 -30
  108. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.tsx +0 -171
  109. package/src/components/context/SanityProvider.tsx +0 -42
  110. package/src/css/css.config.js +0 -220
  111. package/src/css/paramour.css +0 -2347
  112. package/src/css/styles.css +0 -11
  113. package/src/hooks/auth/useHandleCallback.test.tsx +0 -16
  114. package/src/hooks/client/useClient.test.tsx +0 -130
  115. package/src/hooks/documentCollection/useDocuments.test.ts +0 -130
  116. 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
3
  import {Suspense, useRef} from 'react'
4
- import type {Mock} from 'vitest'
4
+ import {type Mock} from 'vitest'
5
5
 
6
6
  import {usePreview} from './usePreview'
7
7
 
@@ -44,14 +44,14 @@ const mockDocument: DocumentHandle = {
44
44
  }
45
45
 
46
46
  function TestComponent({document}: {document: DocumentHandle}) {
47
- const ref = useRef<HTMLDivElement>(null)
48
- const [previewValue, pending] = usePreview({document, ref})
47
+ const ref = useRef(null)
48
+ const {data, isPending} = usePreview({document, ref})
49
49
 
50
50
  return (
51
51
  <div ref={ref}>
52
- <h1>{previewValue.title}</h1>
53
- <p>{previewValue.subtitle}</p>
54
- {pending && <div>Pending...</div>}
52
+ <h1>{data?.title}</h1>
53
+ <p>{data?.subtitle}</p>
54
+ {isPending && <div>Pending...</div>}
55
55
  </div>
56
56
  )
57
57
  }
@@ -74,7 +74,10 @@ 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
 
@@ -107,7 +110,7 @@ describe('usePreview', () => {
107
110
  expect(eventsUnsubscribe).toHaveBeenCalled()
108
111
  })
109
112
 
110
- test('it suspends and resolves data when element becomes visible', async () => {
113
+ test.skip('it suspends and resolves data when element becomes visible', async () => {
111
114
  // Initial setup with pending state
112
115
  getCurrent.mockReturnValueOnce([null, true])
113
116
  const resolvePromise = Promise.resolve<PreviewValue>({
@@ -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,7 +155,10 @@ 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(
@@ -1,24 +1,74 @@
1
1
  import {type DocumentHandle, getPreviewState, type PreviewValue, resolvePreview} from '@sanity/sdk'
2
- import {type RefObject, useCallback, useMemo, useSyncExternalStore} from 'react'
2
+ import {useCallback, useMemo, 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
11
  export interface UsePreviewOptions {
11
12
  document: DocumentHandle
12
- ref?: RefObject<HTMLElement>
13
+ ref?: React.RefObject<unknown>
13
14
  }
14
15
 
15
16
  /**
16
- * @alpha
17
+ * @beta
18
+ * @category Types
17
19
  */
18
- export function usePreview({
19
- document: {_id, _type},
20
- ref,
21
- }: UsePreviewOptions): [PreviewValue, boolean] {
20
+ export interface UsePreviewResults {
21
+ /** The results of resolving the document’s preview values */
22
+ data: PreviewValue
23
+ /** True when preview values are being refreshed */
24
+ isPending: boolean
25
+ }
26
+
27
+ /**
28
+ * @beta
29
+ *
30
+ * Returns the preview values of a document (specified via a `DocumentHandle`),
31
+ * including the document’s `title`, `subtitle`, `media`, and `status`. These values are live and will update in realtime.
32
+ * To reduce unnecessary network requests for resolving the preview values, an optional `ref` can be passed to the hook so that preview
33
+ * resolution will only occur if the `ref` is intersecting the current viewport.
34
+ *
35
+ * @category Documents
36
+ * @param options - The document handle for the document you want to resolve preview values for, and an optional ref
37
+ * @returns The preview values for the given document and a boolean to indicate whether the resolution is pending
38
+ *
39
+ * @example Combining with useDocuments to render a collection of document previews
40
+ * ```
41
+ * // PreviewComponent.jsx
42
+ * export default function PreviewComponent({ document }) {
43
+ * const { data: { title, subtitle, media }, isPending } = usePreview({ document })
44
+ * return (
45
+ * <article style={{ opacity: isPending ? 0.5 : 1}}>
46
+ * {media?.type === 'image-asset' ? <img src={media.url} alt='' /> : ''}
47
+ * <h2>{title}</h2>
48
+ * <p>{subtitle}</p>
49
+ * </article>
50
+ * )
51
+ * }
52
+ *
53
+ * // DocumentList.jsx
54
+ * const { data } = useDocuments({ filter: '_type == "movie"' })
55
+ * return (
56
+ * <div>
57
+ * <h1>Movies</h1>
58
+ * <ul>
59
+ * {data.map(movie => (
60
+ * <li key={movie._id}>
61
+ * <Suspense fallback='Loading…'>
62
+ * <PreviewComponent document={movie} />
63
+ * </Suspense>
64
+ * </li>
65
+ * ))}
66
+ * </ul>
67
+ * </div>
68
+ * )
69
+ * ```
70
+ */
71
+ export function usePreview({document: {_id, _type}, ref}: UsePreviewOptions): UsePreviewResults {
22
72
  const instance = useSanityInstance()
23
73
 
24
74
  const stateSource = useMemo(
@@ -31,13 +81,17 @@ export function usePreview({
31
81
  (onStoreChanged: () => void) => {
32
82
  const subscription = new Observable<boolean>((observer) => {
33
83
  // for environments that don't have an intersection observer
34
- if (typeof IntersectionObserver === 'undefined') return
84
+ if (typeof IntersectionObserver === 'undefined' || typeof HTMLElement === 'undefined') {
85
+ return
86
+ }
35
87
 
36
88
  const intersectionObserver = new IntersectionObserver(
37
89
  ([entry]) => observer.next(entry.isIntersecting),
38
90
  {rootMargin: '0px', threshold: 0},
39
91
  )
40
- if (ref?.current) intersectionObserver.observe(ref.current)
92
+ if (ref?.current && ref.current instanceof HTMLElement) {
93
+ intersectionObserver.observe(ref.current)
94
+ }
41
95
  return () => intersectionObserver.disconnect()
42
96
  })
43
97
  .pipe(
@@ -60,9 +114,9 @@ export function usePreview({
60
114
 
61
115
  // Create getSnapshot function to return current state
62
116
  const getSnapshot = useCallback(() => {
63
- const previewTuple = stateSource.getCurrent()
64
- if (!previewTuple[0]) throw resolvePreview(instance, {document: {_id, _type}})
65
- return previewTuple as [PreviewValue, boolean]
117
+ const currentState = stateSource.getCurrent()
118
+ if (currentState.data === null) throw resolvePreview(instance, {document: {_id, _type}})
119
+ return currentState as UsePreviewResults
66
120
  }, [_id, _type, instance, stateSource])
67
121
 
68
122
  return useSyncExternalStore(subscribe, getSnapshot)
@@ -0,0 +1,218 @@
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
+ _id: 'doc1',
48
+ _type: '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
+ })
@@ -0,0 +1,147 @@
1
+ import {
2
+ type DocumentHandle,
3
+ getProjectionState,
4
+ resolveProjection,
5
+ type ValidProjection,
6
+ } from '@sanity/sdk'
7
+ import {useCallback, useMemo, useSyncExternalStore} from 'react'
8
+ import {distinctUntilChanged, EMPTY, Observable, startWith, switchMap} from 'rxjs'
9
+
10
+ import {useSanityInstance} from '../context/useSanityInstance'
11
+
12
+ /**
13
+ * @public
14
+ * @category Types
15
+ */
16
+ export interface UseProjectionOptions {
17
+ document: DocumentHandle
18
+ projection: ValidProjection
19
+ ref?: React.RefObject<unknown>
20
+ }
21
+
22
+ /**
23
+ * @public
24
+ * @category Types
25
+ */
26
+ export interface UseProjectionResults<TResult extends object> {
27
+ data: TResult
28
+ isPending: boolean
29
+ }
30
+
31
+ /**
32
+ * @beta
33
+ *
34
+ * Returns the projection values of a document (specified via a `DocumentHandle`),
35
+ * based on the provided projection string. These values are live and will update in realtime.
36
+ * To reduce unnecessary network requests for resolving the projection values, an optional `ref` can be passed to the hook so that projection
37
+ * resolution will only occur if the `ref` is intersecting the current viewport.
38
+ *
39
+ * @category Documents
40
+ * @param options - The document handle for the document you want to project values from, the projection string, and an optional ref
41
+ * @returns The projection values for the given document and a boolean to indicate whether the resolution is pending
42
+ *
43
+ * @example Using a projection to render a preview of document
44
+ * ```
45
+ * // ProjectionComponent.jsx
46
+ * export default function ProjectionComponent({ document }) {
47
+ * const ref = useRef(null)
48
+ * const { results: { title, coverImage, authors }, isPending } = useProjection({
49
+ * document,
50
+ * ref,
51
+ * projection: `{
52
+ * title,
53
+ * 'coverImage': cover.asset->url,
54
+ * 'authors': array::join(authors[]->{'name': firstName + ' ' + lastName + ' '}.name, ', ')
55
+ * }`,
56
+ * })
57
+ *
58
+ * return (
59
+ * <article ref={ref} style={{ opacity: isPending ? 0.5 : 1}}>
60
+ * <h2>{title}</h2>
61
+ * <img src={coverImage} alt={title} />
62
+ * <p>{authors}</p>
63
+ * </article>
64
+ * )
65
+ * }
66
+ * ```
67
+ *
68
+ * @example Combining with useDocuments to render a collection with specific fields
69
+ * ```
70
+ * // DocumentList.jsx
71
+ * const { data } = useDocuments({ filter: '_type == "article"' })
72
+ * return (
73
+ * <div>
74
+ * <h1>Books</h1>
75
+ * <ul>
76
+ * {data.map(book => (
77
+ * <li key={book._id}>
78
+ * <Suspense fallback='Loading…'>
79
+ * <ProjectionComponent
80
+ * document={book}
81
+ * />
82
+ * </Suspense>
83
+ * </li>
84
+ * ))}
85
+ * </ul>
86
+ * </div>
87
+ * )
88
+ * ```
89
+ */
90
+ export function useProjection<TResult extends object>({
91
+ document: {_id, _type},
92
+ projection,
93
+ ref,
94
+ }: UseProjectionOptions): UseProjectionResults<TResult> {
95
+ const instance = useSanityInstance()
96
+
97
+ const stateSource = useMemo(
98
+ () => getProjectionState<TResult>(instance, {document: {_id, _type}, projection}),
99
+ [instance, _id, _type, projection],
100
+ )
101
+
102
+ // Create subscribe function for useSyncExternalStore
103
+ const subscribe = useCallback(
104
+ (onStoreChanged: () => void) => {
105
+ const subscription = new Observable<boolean>((observer) => {
106
+ // for environments that don't have an intersection observer
107
+ if (typeof IntersectionObserver === 'undefined' || typeof HTMLElement === 'undefined') {
108
+ return
109
+ }
110
+
111
+ const intersectionObserver = new IntersectionObserver(
112
+ ([entry]) => observer.next(entry.isIntersecting),
113
+ {rootMargin: '0px', threshold: 0},
114
+ )
115
+ if (ref?.current && ref.current instanceof HTMLElement) {
116
+ intersectionObserver.observe(ref.current)
117
+ }
118
+ return () => intersectionObserver.disconnect()
119
+ })
120
+ .pipe(
121
+ startWith(false),
122
+ distinctUntilChanged(),
123
+ switchMap((isVisible) =>
124
+ isVisible
125
+ ? new Observable<void>((obs) => {
126
+ return stateSource.subscribe(() => obs.next())
127
+ })
128
+ : EMPTY,
129
+ ),
130
+ )
131
+ .subscribe({next: onStoreChanged})
132
+
133
+ return () => subscription.unsubscribe()
134
+ },
135
+ [stateSource, ref],
136
+ )
137
+
138
+ // Create getSnapshot function to return current state
139
+ const getSnapshot = useCallback(() => {
140
+ const currentState = stateSource.getCurrent()
141
+ if (currentState.data === null)
142
+ throw resolveProjection(instance, {document: {_id, _type}, projection})
143
+ return currentState as UseProjectionResults<TResult>
144
+ }, [_id, _type, projection, instance, stateSource])
145
+
146
+ return useSyncExternalStore(subscribe, getSnapshot)
147
+ }
@@ -0,0 +1,48 @@
1
+ import {
2
+ getProjectState,
3
+ resolveProject,
4
+ type SanityInstance,
5
+ type SanityProject,
6
+ type StateSource,
7
+ } from '@sanity/sdk'
8
+
9
+ import {createStateSourceHook} from '../helpers/createStateSourceHook'
10
+
11
+ type UseProject = {
12
+ /**
13
+ *
14
+ * Returns metadata for a given project
15
+ *
16
+ * @category Projects
17
+ * @param projectId - The ID of the project to retrieve metadata for
18
+ * @returns The metadata for the project
19
+ * @example
20
+ * ```tsx
21
+ * function ProjectMetadata({ projectId }: { projectId: string }) {
22
+ * const project = useProject(projectId)
23
+ *
24
+ * return (
25
+ * <figure style={{ backgroundColor: project.metadata.color || 'lavender'}}>
26
+ * <h1>{project.displayName}</h1>
27
+ * </figure>
28
+ * )
29
+ * }
30
+ * ```
31
+ */
32
+ (projectId: string): SanityProject
33
+ }
34
+
35
+ /**
36
+ * @public
37
+ * @function
38
+ */
39
+ export const useProject: UseProject = createStateSourceHook({
40
+ // remove `undefined` since we're suspending when that is the case
41
+ getState: getProjectState as (
42
+ instance: SanityInstance,
43
+ projectId: string,
44
+ ) => StateSource<SanityProject>,
45
+ shouldSuspend: (instance, projectId) =>
46
+ getProjectState(instance, projectId).getCurrent() === undefined,
47
+ suspender: resolveProject,
48
+ })
@@ -0,0 +1,45 @@
1
+ import {type SanityProject} from '@sanity/client'
2
+ import {getProjectsState, resolveProjects, type SanityInstance, type StateSource} from '@sanity/sdk'
3
+
4
+ import {createStateSourceHook} from '../helpers/createStateSourceHook'
5
+
6
+ /**
7
+ * @public
8
+ * @category Types
9
+ * @interface
10
+ */
11
+ export type ProjectWithoutMembers = Omit<SanityProject, 'members'>
12
+
13
+ type UseProjects = {
14
+ /**
15
+ *
16
+ * Returns metadata for each project you have access to.
17
+ *
18
+ * @category Projects
19
+ * @returns An array of metadata (minus the projects’ members) for each project
20
+ * @example
21
+ * ```tsx
22
+ * const projects = useProjects()
23
+ *
24
+ * return (
25
+ * <select>
26
+ * {projects.map((project) => (
27
+ * <option key={project.id}>{project.displayName}</option>
28
+ * ))}
29
+ * </select>
30
+ * )
31
+ * ```
32
+ */
33
+ (): ProjectWithoutMembers[]
34
+ }
35
+
36
+ /**
37
+ * @public
38
+ * @function
39
+ */
40
+ export const useProjects: UseProjects = createStateSourceHook({
41
+ // remove `undefined` since we're suspending when that is the case
42
+ getState: getProjectsState as (instance: SanityInstance) => StateSource<ProjectWithoutMembers[]>,
43
+ shouldSuspend: (instance) => getProjectsState(instance).getCurrent() === undefined,
44
+ suspender: resolveProjects,
45
+ })