@sanity/sdk-react 0.0.0-alpha.14 → 0.0.0-alpha.15

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 (38) hide show
  1. package/README.md +36 -33
  2. package/dist/_chunks-es/context.js +1 -1
  3. package/dist/_chunks-es/context.js.map +1 -1
  4. package/dist/_chunks-es/useLogOut.js +15 -4
  5. package/dist/_chunks-es/useLogOut.js.map +1 -1
  6. package/dist/components.d.ts +4 -4
  7. package/dist/components.js +27 -9
  8. package/dist/components.js.map +1 -1
  9. package/dist/context.d.ts +2 -2
  10. package/dist/hooks.d.ts +86 -8
  11. package/dist/hooks.js +56 -12
  12. package/dist/hooks.js.map +1 -1
  13. package/package.json +4 -4
  14. package/src/_exports/hooks.ts +1 -0
  15. package/src/components/Login/LoginLinks.test.tsx +1 -1
  16. package/src/components/SDKProvider.test.tsx +7 -7
  17. package/src/components/SDKProvider.tsx +6 -4
  18. package/src/components/SanityApp.test.tsx +103 -1
  19. package/src/components/SanityApp.tsx +32 -11
  20. package/src/components/auth/authTestHelpers.tsx +1 -1
  21. package/src/components/utils.ts +19 -0
  22. package/src/context/SanityProvider.test.tsx +1 -1
  23. package/src/context/SanityProvider.tsx +4 -4
  24. package/src/hooks/context/useSanityInstance.test.tsx +1 -1
  25. package/src/hooks/context/useSanityInstance.ts +19 -3
  26. package/src/hooks/document/useDocument.test.ts +2 -2
  27. package/src/hooks/document/useDocument.ts +8 -6
  28. package/src/hooks/document/useDocumentEvent.test.ts +13 -3
  29. package/src/hooks/document/useDocumentEvent.ts +11 -3
  30. package/src/hooks/document/useDocumentSyncStatus.ts +1 -1
  31. package/src/hooks/document/useEditDocument.test.ts +19 -12
  32. package/src/hooks/document/useEditDocument.ts +11 -9
  33. package/src/hooks/document/usePermissions.ts +19 -2
  34. package/src/hooks/documentCollection/useDocuments.ts +1 -1
  35. package/src/hooks/helpers/createStateSourceHook.tsx +8 -2
  36. package/src/hooks/projection/useProjection.test.tsx +218 -0
  37. package/src/hooks/projection/useProjection.ts +135 -0
  38. package/src/hooks/users/useUsers.ts +1 -1
@@ -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 {results, isPending} = useProjection<ProjectionResult>({document, projection, ref})
65
+
66
+ return (
67
+ <div ref={ref}>
68
+ <h1>{results.title}</h1>
69
+ <p>{results.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
+ results: {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
+ results: null,
133
+ isPending: true,
134
+ })
135
+
136
+ const resolvedData = {
137
+ results: {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
+ results: {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
+ results: {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
+ results: {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,135 @@
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
+ interface UseProjectionOptions {
13
+ document: DocumentHandle
14
+ projection: ValidProjection
15
+ ref?: React.RefObject<unknown>
16
+ }
17
+
18
+ interface UseProjectionResults<TResult extends object> {
19
+ results: TResult
20
+ isPending: boolean
21
+ }
22
+
23
+ /**
24
+ * @beta
25
+ *
26
+ * Returns the projection values of a document (specified via a `DocumentHandle`),
27
+ * based on the provided projection string. These values are live and will update in realtime.
28
+ * To reduce unnecessary network requests for resolving the projection values, an optional `ref` can be passed to the hook so that projection
29
+ * resolution will only occur if the `ref` is intersecting the current viewport.
30
+ *
31
+ * @category Documents
32
+ * @param options - The document handle for the document you want to project values from, the projection string, and an optional ref
33
+ * @returns The projection values for the given document and a boolean to indicate whether the resolution is pending
34
+ *
35
+ * @example Using a projection to display specific document fields
36
+ * ```
37
+ * // ProjectionComponent.jsx
38
+ * export default function ProjectionComponent({ document }) {
39
+ * const ref = useRef(null)
40
+ * const { results: { title, description, authors }, isPending } = useProjection({
41
+ * document,
42
+ * projection: '{title, "description": pt::text("description"), "authors": array::join(authors[]->name, ", ")}',
43
+ * ref
44
+ * })
45
+ *
46
+ * return (
47
+ * <article ref={ref} style={{ opacity: isPending ? 0.5 : 1}}>
48
+ * <h2>{title}</h2>
49
+ * <p>{description}</p>
50
+ * <p>{authors}</p>
51
+ * </article>
52
+ * )
53
+ * }
54
+ * ```
55
+ *
56
+ * @example Combining with useDocuments to render a collection with specific fields
57
+ * ```
58
+ * // DocumentList.jsx
59
+ * const { results, isPending } = useDocuments({ filter: '_type == "article"' })
60
+ * return (
61
+ * <div>
62
+ * <h1>Articles</h1>
63
+ * <ul>
64
+ * {isPending ? 'Loading…' : results.map(article => (
65
+ * <li key={article._id}>
66
+ * <Suspense fallback='Loading…'>
67
+ * <ProjectionComponent
68
+ * document={article}
69
+ * />
70
+ * </Suspense>
71
+ * </li>
72
+ * ))}
73
+ * </ul>
74
+ * </div>
75
+ * )
76
+ * ```
77
+ */
78
+ export function useProjection<TResult extends object>({
79
+ document: {_id, _type},
80
+ projection,
81
+ ref,
82
+ }: UseProjectionOptions): UseProjectionResults<TResult> {
83
+ const instance = useSanityInstance()
84
+
85
+ const stateSource = useMemo(
86
+ () => getProjectionState<TResult>(instance, {document: {_id, _type}, projection}),
87
+ [instance, _id, _type, projection],
88
+ )
89
+
90
+ // Create subscribe function for useSyncExternalStore
91
+ const subscribe = useCallback(
92
+ (onStoreChanged: () => void) => {
93
+ const subscription = new Observable<boolean>((observer) => {
94
+ // for environments that don't have an intersection observer
95
+ if (typeof IntersectionObserver === 'undefined' || typeof HTMLElement === 'undefined') {
96
+ return
97
+ }
98
+
99
+ const intersectionObserver = new IntersectionObserver(
100
+ ([entry]) => observer.next(entry.isIntersecting),
101
+ {rootMargin: '0px', threshold: 0},
102
+ )
103
+ if (ref?.current && ref.current instanceof HTMLElement) {
104
+ intersectionObserver.observe(ref.current)
105
+ }
106
+ return () => intersectionObserver.disconnect()
107
+ })
108
+ .pipe(
109
+ startWith(false),
110
+ distinctUntilChanged(),
111
+ switchMap((isVisible) =>
112
+ isVisible
113
+ ? new Observable<void>((obs) => {
114
+ return stateSource.subscribe(() => obs.next())
115
+ })
116
+ : EMPTY,
117
+ ),
118
+ )
119
+ .subscribe({next: onStoreChanged})
120
+
121
+ return () => subscription.unsubscribe()
122
+ },
123
+ [stateSource, ref],
124
+ )
125
+
126
+ // Create getSnapshot function to return current state
127
+ const getSnapshot = useCallback(() => {
128
+ const currentState = stateSource.getCurrent()
129
+ if (currentState.results === null)
130
+ throw resolveProjection(instance, {document: {_id, _type}, projection})
131
+ return currentState as UseProjectionResults<TResult>
132
+ }, [_id, _type, projection, instance, stateSource])
133
+
134
+ return useSyncExternalStore(subscribe, getSnapshot)
135
+ }
@@ -74,7 +74,7 @@ export interface UseUsersResult {
74
74
  * ```
75
75
  */
76
76
  export function useUsers(params: UseUsersParams): UseUsersResult {
77
- const instance = useSanityInstance()
77
+ const instance = useSanityInstance(params.resourceId)
78
78
  const [store] = useState(() => createUsersStore(instance))
79
79
 
80
80
  useEffect(() => {