@sanity/sdk-react 2.1.0 → 2.1.2

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.
@@ -0,0 +1,387 @@
1
+ import {
2
+ getUsersState,
3
+ resolveUsers,
4
+ type SanityUser,
5
+ type StateSource,
6
+ type UserProfile,
7
+ } from '@sanity/sdk'
8
+ import {act, fireEvent, render, screen} from '@testing-library/react'
9
+ import {Suspense, useState} from 'react'
10
+ import {type Observable, Subject} from 'rxjs'
11
+ import {describe, expect, it, vi} from 'vitest'
12
+
13
+ import {useUser} from './useUser'
14
+
15
+ // Mock the functions from '@sanity/sdk'
16
+ vi.mock('@sanity/sdk', async (importOriginal) => {
17
+ const original = await importOriginal<typeof import('@sanity/sdk')>()
18
+ return {
19
+ ...original,
20
+ getUsersState: vi.fn(),
21
+ resolveUsers: vi.fn(),
22
+ }
23
+ })
24
+
25
+ // Mock the Sanity instance hook to return a dummy instance
26
+ vi.mock('../context/useSanityInstance', () => ({
27
+ useSanityInstance: vi.fn().mockReturnValue({config: {projectId: 'p'}}),
28
+ }))
29
+
30
+ describe('useUser', () => {
31
+ // Create mock user profiles with all required fields
32
+ const mockUserProfile: UserProfile = {
33
+ id: 'profile1',
34
+ displayName: 'John Doe',
35
+ email: 'john.doe@example.com',
36
+ imageUrl: 'https://example.com/john.jpg',
37
+ provider: 'google',
38
+ createdAt: '2023-01-01T00:00:00Z',
39
+ }
40
+
41
+ const mockUser: SanityUser = {
42
+ sanityUserId: 'gabc123',
43
+ profile: mockUserProfile,
44
+ memberships: [],
45
+ }
46
+
47
+ const mockUserProfile2: UserProfile = {
48
+ id: 'profile2',
49
+ displayName: 'Jane Smith',
50
+ email: 'jane.smith@example.com',
51
+ imageUrl: 'https://example.com/jane.jpg',
52
+ provider: 'github',
53
+ createdAt: '2023-01-02T00:00:00Z',
54
+ }
55
+
56
+ const mockUser2: SanityUser = {
57
+ sanityUserId: 'gdef456',
58
+ profile: mockUserProfile2,
59
+ memberships: [],
60
+ }
61
+
62
+ it('should render user data immediately when available', () => {
63
+ const getCurrent = vi.fn().mockReturnValue({
64
+ data: [mockUser],
65
+ hasMore: false,
66
+ totalCount: 1,
67
+ })
68
+
69
+ // Type assertion to fix the StateSource type issue
70
+ vi.mocked(getUsersState).mockReturnValue({
71
+ getCurrent,
72
+ subscribe: vi.fn(),
73
+ get observable(): Observable<unknown> {
74
+ throw new Error('Not implemented')
75
+ },
76
+ } as unknown as StateSource<
77
+ {data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined
78
+ >)
79
+
80
+ function TestComponent() {
81
+ const {data, isPending} = useUser({
82
+ userId: 'gabc123',
83
+ resourceType: 'organization',
84
+ organizationId: 'test-org',
85
+ })
86
+ return (
87
+ <div data-testid="output">
88
+ {data ? `${data.profile.displayName} (${data.sanityUserId})` : 'No user'} -{' '}
89
+ {isPending ? 'pending' : 'not pending'}
90
+ </div>
91
+ )
92
+ }
93
+
94
+ render(<TestComponent />)
95
+
96
+ // Verify that the output contains the user data and that isPending is false
97
+ expect(screen.getByTestId('output').textContent).toContain('John Doe (gabc123)')
98
+ expect(screen.getByTestId('output').textContent).toContain('not pending')
99
+ })
100
+
101
+ it('should suspend rendering until user data is resolved via Suspense', async () => {
102
+ const ref = {
103
+ current: undefined as {data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined,
104
+ }
105
+ const getCurrent = vi.fn(() => ref.current)
106
+ const storeChanged$ = new Subject<void>()
107
+
108
+ // Type assertion to fix the StateSource type issue
109
+ vi.mocked(getUsersState).mockReturnValue({
110
+ getCurrent,
111
+ subscribe: vi.fn((cb) => {
112
+ const subscription = storeChanged$.subscribe(cb)
113
+ return () => subscription.unsubscribe()
114
+ }),
115
+ get observable(): Observable<unknown> {
116
+ throw new Error('Not implemented')
117
+ },
118
+ } as unknown as StateSource<
119
+ {data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined
120
+ >)
121
+
122
+ // Create a controllable promise to simulate the user resolution
123
+ let resolvePromise: () => void
124
+ // Mock resolveUsers to return our fake promise
125
+ vi.mocked(resolveUsers).mockReturnValue(
126
+ new Promise<{data: SanityUser[]; totalCount: number; hasMore: boolean}>((resolve) => {
127
+ resolvePromise = () => {
128
+ ref.current = {
129
+ data: [mockUser],
130
+ hasMore: false,
131
+ totalCount: 1,
132
+ }
133
+ storeChanged$.next()
134
+ resolve(ref.current)
135
+ }
136
+ }),
137
+ )
138
+
139
+ function TestComponent() {
140
+ const {data} = useUser({
141
+ userId: 'gabc123',
142
+ resourceType: 'organization',
143
+ organizationId: 'test-org',
144
+ })
145
+ return <div data-testid="output">{data?.profile.displayName || 'No user'}</div>
146
+ }
147
+
148
+ render(
149
+ <Suspense fallback={<div data-testid="fallback">Loading...</div>}>
150
+ <TestComponent />
151
+ </Suspense>,
152
+ )
153
+
154
+ // Initially, since storeValue is undefined, the component should suspend and fallback is shown
155
+ expect(screen.getByTestId('fallback')).toBeInTheDocument()
156
+
157
+ // Now simulate that data becomes available
158
+ await act(async () => {
159
+ resolvePromise()
160
+ })
161
+
162
+ expect(screen.getByTestId('output').textContent).toContain('John Doe')
163
+ })
164
+
165
+ it('should display transition pending state during options change', async () => {
166
+ const ref = {
167
+ current: undefined as {data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined,
168
+ }
169
+ const getCurrent = vi.fn(() => ref.current)
170
+ const storeChanged$ = new Subject<void>()
171
+
172
+ // Use a more specific type for the mock implementation
173
+ vi.mocked(getUsersState).mockImplementation((_instance, options) => {
174
+ if (options?.userId === 'gabc123') {
175
+ return {
176
+ getCurrent: vi.fn().mockReturnValue({
177
+ data: [mockUser],
178
+ hasMore: false,
179
+ totalCount: 1,
180
+ }),
181
+ subscribe: vi.fn(),
182
+ get observable(): Observable<unknown> {
183
+ throw new Error('Not implemented')
184
+ },
185
+ } as unknown as StateSource<
186
+ {data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined
187
+ >
188
+ }
189
+
190
+ return {
191
+ getCurrent,
192
+ subscribe: vi.fn((cb) => {
193
+ const subscription = storeChanged$.subscribe(cb)
194
+ return () => subscription.unsubscribe()
195
+ }),
196
+ get observable(): Observable<unknown> {
197
+ throw new Error('Not implemented')
198
+ },
199
+ } as unknown as StateSource<
200
+ {data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined
201
+ >
202
+ })
203
+
204
+ // Create a controllable promise to simulate the user resolution
205
+ let resolvePromise: () => void
206
+ // Mock resolveUsers to return our fake promise
207
+ vi.mocked(resolveUsers).mockReturnValue(
208
+ new Promise<{data: SanityUser[]; totalCount: number; hasMore: boolean}>((resolve) => {
209
+ resolvePromise = () => {
210
+ ref.current = {
211
+ data: [mockUser2],
212
+ hasMore: false,
213
+ totalCount: 1,
214
+ }
215
+ storeChanged$.next()
216
+ resolve(ref.current)
217
+ }
218
+ }),
219
+ )
220
+
221
+ function WrapperComponent() {
222
+ const [userId, setUserId] = useState('gabc123')
223
+ const {data, isPending} = useUser({
224
+ userId,
225
+ resourceType: 'organization',
226
+ organizationId: 'test-org',
227
+ })
228
+ return (
229
+ <div>
230
+ <div data-testid="output">
231
+ {data?.profile.displayName || 'No user'} - {isPending ? 'pending' : 'not pending'}
232
+ </div>
233
+ <button data-testid="button" onClick={() => setUserId('gdef456')}>
234
+ Change User
235
+ </button>
236
+ </div>
237
+ )
238
+ }
239
+
240
+ render(<WrapperComponent />)
241
+
242
+ // Initially, should show data for first user and not pending
243
+ expect(screen.getByTestId('output').textContent).toContain('John Doe')
244
+ expect(screen.getByTestId('output').textContent).toContain('not pending')
245
+
246
+ // Change the user ID, which should trigger a transition
247
+ fireEvent.click(screen.getByTestId('button'))
248
+
249
+ // The isPending should become true during the transition
250
+ expect(screen.getByTestId('output').textContent).toContain('pending')
251
+
252
+ // Resolve the promise to complete the transition
253
+ await act(async () => {
254
+ resolvePromise()
255
+ })
256
+
257
+ // After transition, should show new user data and not pending
258
+ expect(screen.getByTestId('output').textContent).toContain('Jane Smith')
259
+ expect(screen.getByTestId('output').textContent).toContain('not pending')
260
+ })
261
+
262
+ it('should handle undefined user data gracefully', () => {
263
+ const getCurrent = vi.fn().mockReturnValue({
264
+ data: [], // Empty array means no user found
265
+ hasMore: false,
266
+ totalCount: 0,
267
+ })
268
+
269
+ vi.mocked(getUsersState).mockReturnValue({
270
+ getCurrent,
271
+ subscribe: vi.fn(),
272
+ get observable(): Observable<unknown> {
273
+ throw new Error('Not implemented')
274
+ },
275
+ } as unknown as StateSource<
276
+ {data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined
277
+ >)
278
+
279
+ function TestComponent() {
280
+ const {data, isPending} = useUser({
281
+ userId: 'nonexistent123',
282
+ resourceType: 'organization',
283
+ organizationId: 'test-org',
284
+ })
285
+ return (
286
+ <div data-testid="output">
287
+ {data ? `Found: ${data.profile.displayName}` : 'User not found'} -{' '}
288
+ {isPending ? 'pending' : 'not pending'}
289
+ </div>
290
+ )
291
+ }
292
+
293
+ render(<TestComponent />)
294
+
295
+ expect(screen.getByTestId('output').textContent).toContain('User not found')
296
+ expect(screen.getByTestId('output').textContent).toContain('not pending')
297
+ })
298
+
299
+ it('should work with project-scoped user IDs', () => {
300
+ const projectUser: SanityUser = {
301
+ ...mockUser,
302
+ sanityUserId: 'p12345',
303
+ }
304
+
305
+ const getCurrent = vi.fn().mockReturnValue({
306
+ data: [projectUser],
307
+ hasMore: false,
308
+ totalCount: 1,
309
+ })
310
+
311
+ vi.mocked(getUsersState).mockReturnValue({
312
+ getCurrent,
313
+ subscribe: vi.fn(),
314
+ get observable(): Observable<unknown> {
315
+ throw new Error('Not implemented')
316
+ },
317
+ } as unknown as StateSource<
318
+ {data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined
319
+ >)
320
+
321
+ function TestComponent() {
322
+ const {data} = useUser({
323
+ userId: 'p12345',
324
+ resourceType: 'project',
325
+ projectId: 'test-project',
326
+ })
327
+ return (
328
+ <div data-testid="output">
329
+ {data ? `${data.profile.displayName} (${data.sanityUserId})` : 'No user'}
330
+ </div>
331
+ )
332
+ }
333
+
334
+ render(<TestComponent />)
335
+
336
+ expect(screen.getByTestId('output').textContent).toContain('John Doe (p12345)')
337
+ })
338
+
339
+ it('should handle resource type changes', () => {
340
+ vi.mocked(getUsersState).mockImplementation((_instance, options) => {
341
+ const mockData = options?.resourceType === 'project' ? [mockUser] : [mockUser2]
342
+
343
+ return {
344
+ getCurrent: vi.fn().mockReturnValue({
345
+ data: mockData,
346
+ hasMore: false,
347
+ totalCount: 1,
348
+ }),
349
+ subscribe: vi.fn(),
350
+ get observable(): Observable<unknown> {
351
+ throw new Error('Not implemented')
352
+ },
353
+ } as unknown as StateSource<
354
+ {data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined
355
+ >
356
+ })
357
+
358
+ function WrapperComponent() {
359
+ const [resourceType, setResourceType] = useState<'project' | 'organization'>('project')
360
+ const {data} = useUser({
361
+ userId: 'gabc123',
362
+ resourceType,
363
+ projectId: resourceType === 'project' ? 'test-project' : undefined,
364
+ organizationId: resourceType === 'organization' ? 'test-org' : undefined,
365
+ })
366
+ return (
367
+ <div>
368
+ <div data-testid="output">{data?.profile.displayName || 'No user'}</div>
369
+ <button data-testid="toggle" onClick={() => setResourceType('organization')}>
370
+ Switch to Organization
371
+ </button>
372
+ </div>
373
+ )
374
+ }
375
+
376
+ render(<WrapperComponent />)
377
+
378
+ // Initially should show project user
379
+ expect(screen.getByTestId('output').textContent).toContain('John Doe')
380
+
381
+ // Switch resource type
382
+ fireEvent.click(screen.getByTestId('toggle'))
383
+
384
+ // Should now show organization user
385
+ expect(screen.getByTestId('output').textContent).toContain('Jane Smith')
386
+ })
387
+ })
@@ -0,0 +1,109 @@
1
+ import {
2
+ type GetUserOptions,
3
+ getUsersKey,
4
+ getUsersState,
5
+ parseUsersKey,
6
+ resolveUsers,
7
+ type SanityUser,
8
+ } from '@sanity/sdk'
9
+ import {useEffect, useMemo, useState, useSyncExternalStore, useTransition} from 'react'
10
+
11
+ import {useSanityInstance} from '../context/useSanityInstance'
12
+
13
+ /**
14
+ * @public
15
+ * @category Types
16
+ */
17
+ export interface UserResult {
18
+ /**
19
+ * The user data fetched, or undefined if not found.
20
+ */
21
+ data: SanityUser | undefined
22
+ /**
23
+ * Whether a user request is currently in progress
24
+ */
25
+ isPending: boolean
26
+ }
27
+
28
+ /**
29
+ *
30
+ * @public
31
+ *
32
+ * Retrieves a single user by ID for a given resource (either a project or an organization).
33
+ *
34
+ * @category Users
35
+ * @param options - The user ID, resource type, project ID, or organization ID
36
+ * @returns The user data and loading state
37
+ *
38
+ * @example
39
+ * ```
40
+ * const { data, isPending } = useUser({
41
+ * userId: 'gabc123',
42
+ * resourceType: 'project',
43
+ * projectId: 'my-project-id',
44
+ * })
45
+ *
46
+ * return (
47
+ * <div>
48
+ * {isPending && <p>Loading...</p>}
49
+ * {data && (
50
+ * <figure>
51
+ * <img src={data.profile.imageUrl} alt='' />
52
+ * <figcaption>{data.profile.displayName}</figcaption>
53
+ * <address>{data.profile.email}</address>
54
+ * </figure>
55
+ * )}
56
+ * </div>
57
+ * )
58
+ * ```
59
+ */
60
+ export function useUser(options: GetUserOptions): UserResult {
61
+ const instance = useSanityInstance(options)
62
+ // Use React's useTransition to avoid UI jank when user options change
63
+ const [isPending, startTransition] = useTransition()
64
+
65
+ // Get the unique key for this user request and its options
66
+ const key = getUsersKey(instance, options)
67
+ // Use a deferred state to avoid immediate re-renders when the user request changes
68
+ const [deferredKey, setDeferredKey] = useState(key)
69
+ // Parse the deferred user key back into user options
70
+ const deferred = useMemo(() => parseUsersKey(deferredKey), [deferredKey])
71
+
72
+ // Create an AbortController to cancel in-flight requests when needed
73
+ const [ref, setRef] = useState<AbortController>(new AbortController())
74
+
75
+ // When the user request or options change, start a transition to update the request
76
+ useEffect(() => {
77
+ if (key === deferredKey) return
78
+
79
+ startTransition(() => {
80
+ if (!ref.signal.aborted) {
81
+ ref.abort()
82
+ setRef(new AbortController())
83
+ }
84
+
85
+ setDeferredKey(key)
86
+ })
87
+ }, [deferredKey, key, ref])
88
+
89
+ // Get the state source for this user request from the users store
90
+ // We pass the userId as part of options to getUsersState
91
+ const {getCurrent, subscribe} = useMemo(() => {
92
+ return getUsersState(instance, deferred as GetUserOptions)
93
+ }, [instance, deferred])
94
+
95
+ // If data isn't available yet, suspend rendering until it is
96
+ // This is the React Suspense integration - throwing a promise
97
+ // will cause React to show the nearest Suspense fallback
98
+ if (getCurrent() === undefined) {
99
+ throw resolveUsers(instance, {...(deferred as GetUserOptions), signal: ref.signal})
100
+ }
101
+
102
+ // Subscribe to updates and get the current data
103
+ // useSyncExternalStore ensures the component re-renders when the data changes
104
+ // Extract the first user from the users array (since we're fetching by userId, there should be only one)
105
+ const result = useSyncExternalStore(subscribe, getCurrent)
106
+ const data = result?.data[0]
107
+
108
+ return {data, isPending}
109
+ }