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

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 (71) hide show
  1. package/dist/index.d.ts +502 -3460
  2. package/dist/index.js +400 -465
  3. package/dist/index.js.map +1 -1
  4. package/package.json +17 -15
  5. package/src/_exports/index.ts +4 -5
  6. package/src/components/SDKProvider.test.tsx +78 -54
  7. package/src/components/SDKProvider.tsx +31 -26
  8. package/src/components/SanityApp.test.tsx +121 -15
  9. package/src/components/SanityApp.tsx +26 -15
  10. package/src/components/auth/AuthBoundary.test.tsx +32 -14
  11. package/src/components/auth/AuthBoundary.tsx +53 -23
  12. package/src/components/auth/LoginCallback.test.tsx +19 -6
  13. package/src/components/auth/LoginCallback.tsx +2 -11
  14. package/src/components/auth/LoginError.test.tsx +12 -4
  15. package/src/components/auth/LoginError.tsx +13 -21
  16. package/src/components/auth/LoginFooter.test.tsx +7 -3
  17. package/src/context/ResourceProvider.test.tsx +157 -0
  18. package/src/context/ResourceProvider.tsx +111 -0
  19. package/src/context/SanityInstanceContext.ts +1 -1
  20. package/src/hooks/auth/useLoginUrl.tsx +14 -0
  21. package/src/hooks/client/useClient.ts +2 -1
  22. package/src/hooks/comlink/useManageFavorite.test.ts +16 -8
  23. package/src/hooks/comlink/useManageFavorite.ts +37 -13
  24. package/src/hooks/comlink/useRecordDocumentHistoryEvent.test.ts +8 -4
  25. package/src/hooks/comlink/useRecordDocumentHistoryEvent.ts +10 -8
  26. package/src/hooks/context/useSanityInstance.test.tsx +157 -15
  27. package/src/hooks/context/useSanityInstance.ts +66 -26
  28. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +13 -31
  29. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +12 -15
  30. package/src/hooks/dashboard/{useStudioWorkspacesByResourceId.test.tsx → useStudioWorkspacesByProjectIdDataset.test.tsx} +13 -13
  31. package/src/hooks/dashboard/{useStudioWorkspacesByResourceId.ts → useStudioWorkspacesByProjectIdDataset.ts} +10 -9
  32. package/src/hooks/datasets/useDatasets.ts +15 -4
  33. package/src/hooks/document/useApplyDocumentActions.test.ts +4 -9
  34. package/src/hooks/document/useApplyDocumentActions.ts +6 -31
  35. package/src/hooks/document/useDocument.test.ts +2 -2
  36. package/src/hooks/document/useDocument.ts +40 -19
  37. package/src/hooks/document/useDocumentEvent.test.ts +2 -3
  38. package/src/hooks/document/useDocumentEvent.ts +7 -11
  39. package/src/hooks/document/useDocumentPermissions.test.ts +204 -0
  40. package/src/hooks/document/useDocumentPermissions.ts +31 -23
  41. package/src/hooks/document/useDocumentSyncStatus.ts +5 -4
  42. package/src/hooks/document/useEditDocument.test.ts +2 -3
  43. package/src/hooks/document/useEditDocument.ts +43 -29
  44. package/src/hooks/documents/useDocuments.test.tsx +30 -3
  45. package/src/hooks/documents/useDocuments.ts +20 -7
  46. package/src/hooks/helpers/createCallbackHook.test.tsx +2 -2
  47. package/src/hooks/helpers/createCallbackHook.tsx +2 -3
  48. package/src/hooks/helpers/createStateSourceHook.test.tsx +1 -1
  49. package/src/hooks/helpers/createStateSourceHook.tsx +5 -8
  50. package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +43 -18
  51. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +36 -50
  52. package/src/hooks/preview/usePreview.test.tsx +66 -7
  53. package/src/hooks/preview/usePreview.tsx +17 -12
  54. package/src/hooks/projection/useProjection.test.tsx +68 -3
  55. package/src/hooks/projection/useProjection.ts +21 -24
  56. package/src/hooks/projects/useProject.ts +7 -4
  57. package/src/hooks/query/useQuery.ts +32 -14
  58. package/src/hooks/users/useUsers.test.tsx +330 -0
  59. package/src/hooks/users/useUsers.ts +65 -52
  60. package/src/components/Login/LoginLinks.test.tsx +0 -90
  61. package/src/components/Login/LoginLinks.tsx +0 -58
  62. package/src/components/auth/Login.test.tsx +0 -27
  63. package/src/components/auth/Login.tsx +0 -39
  64. package/src/components/auth/LoginLayout.test.tsx +0 -19
  65. package/src/components/auth/LoginLayout.tsx +0 -69
  66. package/src/components/auth/authTestHelpers.tsx +0 -11
  67. package/src/context/SanityProvider.test.tsx +0 -25
  68. package/src/context/SanityProvider.tsx +0 -50
  69. package/src/hooks/auth/useLoginUrls.test.tsx +0 -68
  70. package/src/hooks/auth/useLoginUrls.tsx +0 -52
  71. package/src/hooks/users/useUsers.test.ts +0 -163
@@ -4,7 +4,7 @@ import {
4
4
  resolveProjection,
5
5
  type ValidProjection,
6
6
  } from '@sanity/sdk'
7
- import {useCallback, useMemo, useSyncExternalStore} from 'react'
7
+ import {useCallback, useSyncExternalStore} from 'react'
8
8
  import {distinctUntilChanged, EMPTY, Observable, startWith, switchMap} from 'rxjs'
9
9
 
10
10
  import {useSanityInstance} from '../context/useSanityInstance'
@@ -13,18 +13,17 @@ import {useSanityInstance} from '../context/useSanityInstance'
13
13
  * @public
14
14
  * @category Types
15
15
  */
16
- export interface UseProjectionOptions {
17
- document: DocumentHandle
18
- projection: ValidProjection
16
+ export interface UseProjectionOptions extends DocumentHandle {
19
17
  ref?: React.RefObject<unknown>
18
+ projection: ValidProjection
20
19
  }
21
20
 
22
21
  /**
23
22
  * @public
24
23
  * @category Types
25
24
  */
26
- export interface UseProjectionResults<TResult extends object> {
27
- data: TResult
25
+ export interface UseProjectionResults<TData extends object> {
26
+ data: TData
28
27
  isPending: boolean
29
28
  }
30
29
 
@@ -87,24 +86,26 @@ export interface UseProjectionResults<TResult extends object> {
87
86
  * )
88
87
  * ```
89
88
  */
90
- export function useProjection<TResult extends object>({
91
- document: {_id, _type},
92
- projection,
89
+ export function useProjection<TData extends object>({
93
90
  ref,
94
- }: UseProjectionOptions): UseProjectionResults<TResult> {
91
+ projection,
92
+ ...docHandle
93
+ }: UseProjectionOptions): UseProjectionResults<TData> {
95
94
  const instance = useSanityInstance()
95
+ const stateSource = getProjectionState<TData>(instance, {...docHandle, projection})
96
96
 
97
- const stateSource = useMemo(
98
- () => getProjectionState<TResult>(instance, {document: {_id, _type}, projection}),
99
- [instance, _id, _type, projection],
100
- )
97
+ if (stateSource.getCurrent().data === null) {
98
+ throw resolveProjection(instance, {...docHandle, projection})
99
+ }
101
100
 
102
101
  // Create subscribe function for useSyncExternalStore
103
102
  const subscribe = useCallback(
104
103
  (onStoreChanged: () => void) => {
105
104
  const subscription = new Observable<boolean>((observer) => {
106
- // for environments that don't have an intersection observer
105
+ // For environments that don't have an intersection observer (e.g. server-side),
106
+ // we pass true to always subscribe since we can't detect visibility
107
107
  if (typeof IntersectionObserver === 'undefined' || typeof HTMLElement === 'undefined') {
108
+ observer.next(true)
108
109
  return
109
110
  }
110
111
 
@@ -114,6 +115,10 @@ export function useProjection<TResult extends object>({
114
115
  )
115
116
  if (ref?.current && ref.current instanceof HTMLElement) {
116
117
  intersectionObserver.observe(ref.current)
118
+ } else {
119
+ // If no ref is provided or ref.current isn't an HTML element,
120
+ // pass true to always subscribe since we can't track visibility
121
+ observer.next(true)
117
122
  }
118
123
  return () => intersectionObserver.disconnect()
119
124
  })
@@ -135,13 +140,5 @@ export function useProjection<TResult extends object>({
135
140
  [stateSource, ref],
136
141
  )
137
142
 
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)
143
+ return useSyncExternalStore(subscribe, stateSource.getCurrent) as UseProjectionResults<TData>
147
144
  }
@@ -1,10 +1,12 @@
1
1
  import {
2
2
  getProjectState,
3
+ type ProjectHandle,
3
4
  resolveProject,
4
5
  type SanityInstance,
5
6
  type SanityProject,
6
7
  type StateSource,
7
8
  } from '@sanity/sdk'
9
+ import {identity} from 'rxjs'
8
10
 
9
11
  import {createStateSourceHook} from '../helpers/createStateSourceHook'
10
12
 
@@ -29,7 +31,7 @@ type UseProject = {
29
31
  * }
30
32
  * ```
31
33
  */
32
- (projectId: string): SanityProject
34
+ (projectHandle?: ProjectHandle): SanityProject
33
35
  }
34
36
 
35
37
  /**
@@ -40,9 +42,10 @@ export const useProject: UseProject = createStateSourceHook({
40
42
  // remove `undefined` since we're suspending when that is the case
41
43
  getState: getProjectState as (
42
44
  instance: SanityInstance,
43
- projectId: string,
45
+ projectHandle?: ProjectHandle,
44
46
  ) => StateSource<SanityProject>,
45
- shouldSuspend: (instance, projectId) =>
46
- getProjectState(instance, projectId).getCurrent() === undefined,
47
+ shouldSuspend: (instance, projectHandle) =>
48
+ getProjectState(instance, projectHandle).getCurrent() === undefined,
47
49
  suspender: resolveProject,
50
+ getConfig: identity,
48
51
  })
@@ -5,7 +5,7 @@ import {
5
5
  type QueryOptions,
6
6
  resolveQuery,
7
7
  } from '@sanity/sdk'
8
- import {useEffect, useMemo, useState, useSyncExternalStore, useTransition} from 'react'
8
+ import {useEffect, useMemo, useRef, useState, useSyncExternalStore, useTransition} from 'react'
9
9
 
10
10
  import {useSanityInstance} from '../context/useSanityInstance'
11
11
 
@@ -13,7 +13,7 @@ import {useSanityInstance} from '../context/useSanityInstance'
13
13
  * Executes GROQ queries against a Sanity dataset.
14
14
  *
15
15
  * This hook provides a convenient way to fetch and subscribe to real-time updates
16
- * for your Sanity content. Changes made to the datasets content will trigger
16
+ * for your Sanity content. Changes made to the dataset's content will trigger
17
17
  * automatic updates.
18
18
  *
19
19
  * @remarks
@@ -23,7 +23,7 @@ import {useSanityInstance} from '../context/useSanityInstance'
23
23
  * @beta
24
24
  * @category GROQ
25
25
  * @param query - GROQ query string to execute
26
- * @param options - Optional configuration for the query
26
+ * @param options - Optional configuration for the query, including projectId and dataset
27
27
  * @returns Object containing the query result and a pending state flag
28
28
  *
29
29
  * @example Basic usage
@@ -39,6 +39,15 @@ import {useSanityInstance} from '../context/useSanityInstance'
39
39
  * })
40
40
  * ```
41
41
  *
42
+ * @example Query from a specific project/dataset
43
+ * ```tsx
44
+ * // Specify which project and dataset to query
45
+ * const {data} = useQuery<Movie[]>('*[_type == "movie"]', {
46
+ * projectId: 'abc123',
47
+ * dataset: 'production'
48
+ * })
49
+ * ```
50
+ *
42
51
  * @example With a loading state for transitions
43
52
  * ```tsx
44
53
  * const {data, isPending} = useQuery<Movie[]>('*[_type == "movie"]')
@@ -54,7 +63,8 @@ import {useSanityInstance} from '../context/useSanityInstance'
54
63
  *
55
64
  */
56
65
  export function useQuery<T>(query: string, options?: QueryOptions): {data: T; isPending: boolean} {
57
- const instance = useSanityInstance(options?.resourceId)
66
+ const instance = useSanityInstance(options)
67
+
58
68
  // Use React's useTransition to avoid UI jank when queries change
59
69
  const [isPending, startTransition] = useTransition()
60
70
 
@@ -66,7 +76,7 @@ export function useQuery<T>(query: string, options?: QueryOptions): {data: T; is
66
76
  const deferred = useMemo(() => parseQueryKey(deferredQueryKey), [deferredQueryKey])
67
77
 
68
78
  // Create an AbortController to cancel in-flight requests when needed
69
- const [ref, setRef] = useState<AbortController>(new AbortController())
79
+ const ref = useRef<AbortController>(new AbortController())
70
80
 
71
81
  // When the query or options change, start a transition to update the query
72
82
  useEffect(() => {
@@ -74,14 +84,14 @@ export function useQuery<T>(query: string, options?: QueryOptions): {data: T; is
74
84
 
75
85
  startTransition(() => {
76
86
  // Abort any in-flight requests for the previous query
77
- if (ref && !ref.signal.aborted) {
78
- ref.abort()
79
- setRef(new AbortController())
87
+ if (ref && !ref.current.signal.aborted) {
88
+ ref.current.abort()
89
+ ref.current = new AbortController()
80
90
  }
81
91
 
82
92
  setDeferredQueryKey(queryKey)
83
93
  })
84
- }, [deferredQueryKey, queryKey, ref])
94
+ }, [deferredQueryKey, queryKey])
85
95
 
86
96
  // Get the state source for this query from the query store
87
97
  const {getCurrent, subscribe} = useMemo(
@@ -89,15 +99,23 @@ export function useQuery<T>(query: string, options?: QueryOptions): {data: T; is
89
99
  [instance, deferred],
90
100
  )
91
101
 
92
- // If data isn't available yet, suspend rendering until it is
93
- // This is the React Suspense integration - throwing a promise
94
- // will cause React to show the nearest Suspense fallback
102
+ // If data isn't available yet, suspend rendering
95
103
  if (getCurrent() === undefined) {
96
- throw resolveQuery(instance, deferred.query, {...deferred.options, signal: ref.signal})
104
+ // Normally, reading from a mutable ref during render can be risky in concurrent mode.
105
+ // However, it is safe here because:
106
+ // 1. React guarantees that while the component is suspended (via throwing a promise),
107
+ // no effects or state updates occur during that render pass.
108
+ // 2. We immediately capture the current abort signal in a local variable (currentSignal).
109
+ // 3. Even if a background render updates ref.current (for example, due to a query change),
110
+ // the captured signal remains unchanged for this suspended render.
111
+ // Thus, the promise thrown here uses a stable abort signal, ensuring correct behavior.
112
+ const currentSignal = ref.current.signal
113
+ // eslint-disable-next-line react-compiler/react-compiler
114
+ throw resolveQuery(instance, deferred.query, {...deferred.options, signal: currentSignal})
97
115
  }
98
116
 
99
117
  // Subscribe to updates and get the current data
100
118
  // useSyncExternalStore ensures the component re-renders when the data changes
101
119
  const data = useSyncExternalStore(subscribe, getCurrent) as T
102
- return {data, isPending}
120
+ return useMemo(() => ({data, isPending}), [data, isPending])
103
121
  }
@@ -0,0 +1,330 @@
1
+ import {
2
+ getUsersState,
3
+ loadMoreUsers,
4
+ resolveUsers,
5
+ type SanityUser,
6
+ type StateSource,
7
+ type UserProfile,
8
+ } from '@sanity/sdk'
9
+ import {act, fireEvent, render, screen} from '@testing-library/react'
10
+ import {Suspense, useState} from 'react'
11
+ import {type Observable, Subject} from 'rxjs'
12
+ import {describe, expect, it, vi} from 'vitest'
13
+
14
+ import {useUsers} from './useUsers'
15
+
16
+ // Mock the functions from '@sanity/sdk'
17
+ vi.mock('@sanity/sdk', async (importOriginal) => {
18
+ const original = await importOriginal<typeof import('@sanity/sdk')>()
19
+ return {
20
+ ...original,
21
+ getUsersState: vi.fn(),
22
+ resolveUsers: vi.fn(),
23
+ loadMoreUsers: vi.fn(),
24
+ }
25
+ })
26
+
27
+ // Mock the Sanity instance hook to return a dummy instance
28
+ vi.mock('../context/useSanityInstance', () => ({
29
+ useSanityInstance: vi.fn().mockReturnValue({config: {projectId: 'p'}}),
30
+ }))
31
+
32
+ describe('useUsers', () => {
33
+ // Create mock user profiles with all required fields
34
+ const mockUserProfile1: UserProfile = {
35
+ id: 'profile1',
36
+ displayName: 'User One',
37
+ email: 'user1@example.com',
38
+ imageUrl: 'https://example.com/user1.jpg',
39
+ provider: 'google',
40
+ createdAt: '2023-01-01T00:00:00Z',
41
+ }
42
+
43
+ const mockUserProfile2: UserProfile = {
44
+ id: 'profile2',
45
+ displayName: 'User Two',
46
+ email: 'user2@example.com',
47
+ imageUrl: 'https://example.com/user2.jpg',
48
+ provider: 'github',
49
+ createdAt: '2023-01-02T00:00:00Z',
50
+ }
51
+
52
+ const mockUsers: SanityUser[] = [
53
+ {
54
+ sanityUserId: 'user1',
55
+ profile: mockUserProfile1,
56
+ memberships: [],
57
+ },
58
+ {
59
+ sanityUserId: 'user2',
60
+ profile: mockUserProfile2,
61
+ memberships: [],
62
+ },
63
+ ]
64
+
65
+ it('should render users data immediately when available', () => {
66
+ const getCurrent = vi.fn().mockReturnValue({
67
+ data: mockUsers,
68
+ hasMore: false,
69
+ totalCount: 2,
70
+ })
71
+
72
+ // Type assertion to fix the StateSource type issue
73
+ vi.mocked(getUsersState).mockReturnValue({
74
+ getCurrent,
75
+ subscribe: vi.fn(),
76
+ get observable(): Observable<unknown> {
77
+ throw new Error('Not implemented')
78
+ },
79
+ } as unknown as StateSource<
80
+ {data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined
81
+ >)
82
+
83
+ function TestComponent() {
84
+ const {data, hasMore, isPending} = useUsers({
85
+ resourceType: 'organization',
86
+ organizationId: 'test-org',
87
+ batchSize: 10,
88
+ })
89
+ return (
90
+ <div data-testid="output">
91
+ {data.length} users - {hasMore ? 'has more' : 'no more'} -{' '}
92
+ {isPending ? 'pending' : 'not pending'}
93
+ </div>
94
+ )
95
+ }
96
+
97
+ render(<TestComponent />)
98
+
99
+ // Verify that the output contains the data and that isPending is false
100
+ expect(screen.getByTestId('output').textContent).toContain('2 users')
101
+ expect(screen.getByTestId('output').textContent).toContain('no more')
102
+ expect(screen.getByTestId('output').textContent).toContain('not pending')
103
+ })
104
+
105
+ it('should suspend rendering until users data is resolved via Suspense', async () => {
106
+ const ref = {
107
+ current: undefined as {data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined,
108
+ }
109
+ const getCurrent = vi.fn(() => ref.current)
110
+ const storeChanged$ = new Subject<void>()
111
+
112
+ // Type assertion to fix the StateSource type issue
113
+ vi.mocked(getUsersState).mockReturnValue({
114
+ getCurrent,
115
+ subscribe: vi.fn((cb) => {
116
+ const subscription = storeChanged$.subscribe(cb)
117
+ return () => subscription.unsubscribe()
118
+ }),
119
+ get observable(): Observable<unknown> {
120
+ throw new Error('Not implemented')
121
+ },
122
+ } as unknown as StateSource<
123
+ {data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined
124
+ >)
125
+
126
+ // Create a controllable promise to simulate the users resolution
127
+ let resolvePromise: () => void
128
+ // Mock resolveUsers to return our fake promise
129
+ vi.mocked(resolveUsers).mockReturnValue(
130
+ new Promise<{data: SanityUser[]; totalCount: number; hasMore: boolean}>((resolve) => {
131
+ resolvePromise = () => {
132
+ ref.current = {
133
+ data: mockUsers,
134
+ hasMore: true,
135
+ totalCount: 2,
136
+ }
137
+ storeChanged$.next()
138
+ resolve(ref.current)
139
+ }
140
+ }),
141
+ )
142
+
143
+ function TestComponent() {
144
+ const {data} = useUsers({
145
+ resourceType: 'organization',
146
+ organizationId: 'test-org',
147
+ batchSize: 10,
148
+ })
149
+ return (
150
+ <div data-testid="output">{data.map((user) => user.profile.displayName).join(', ')}</div>
151
+ )
152
+ }
153
+
154
+ render(
155
+ <Suspense fallback={<div data-testid="fallback">Loading...</div>}>
156
+ <TestComponent />
157
+ </Suspense>,
158
+ )
159
+
160
+ // Initially, since storeValue is undefined, the component should suspend and fallback is shown
161
+ expect(screen.getByTestId('fallback')).toBeInTheDocument()
162
+
163
+ // Now simulate that data becomes available
164
+ await act(async () => {
165
+ resolvePromise()
166
+ })
167
+
168
+ expect(screen.getByTestId('output').textContent).toContain('User One, User Two')
169
+ })
170
+
171
+ it('should display transition pending state during options change', async () => {
172
+ const ref = {
173
+ current: undefined as {data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined,
174
+ }
175
+ const getCurrent = vi.fn(() => ref.current)
176
+ const storeChanged$ = new Subject<void>()
177
+
178
+ // Use a more specific type for the mock implementation
179
+ vi.mocked(getUsersState).mockImplementation((_instance, options) => {
180
+ if (options?.organizationId === 'org1') {
181
+ return {
182
+ getCurrent: vi.fn().mockReturnValue({
183
+ data: [mockUsers[0]],
184
+ hasMore: false,
185
+ totalCount: 1,
186
+ }),
187
+ subscribe: vi.fn(),
188
+ get observable(): Observable<unknown> {
189
+ throw new Error('Not implemented')
190
+ },
191
+ } as unknown as StateSource<
192
+ {data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined
193
+ >
194
+ }
195
+
196
+ return {
197
+ getCurrent,
198
+ subscribe: vi.fn((cb) => {
199
+ const subscription = storeChanged$.subscribe(cb)
200
+ return () => subscription.unsubscribe()
201
+ }),
202
+ get observable(): Observable<unknown> {
203
+ throw new Error('Not implemented')
204
+ },
205
+ } as unknown as StateSource<
206
+ {data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined
207
+ >
208
+ })
209
+
210
+ // Create a controllable promise to simulate the users resolution
211
+ let resolvePromise: () => void
212
+ // Mock resolveUsers to return our fake promise
213
+ vi.mocked(resolveUsers).mockReturnValue(
214
+ new Promise<{data: SanityUser[]; totalCount: number; hasMore: boolean}>((resolve) => {
215
+ resolvePromise = () => {
216
+ ref.current = {
217
+ data: [mockUsers[1]],
218
+ hasMore: true,
219
+ totalCount: 1,
220
+ }
221
+ storeChanged$.next()
222
+ resolve(ref.current)
223
+ }
224
+ }),
225
+ )
226
+
227
+ function WrapperComponent() {
228
+ const [organizationId, setOrganizationId] = useState('org1')
229
+ const {data, hasMore, isPending} = useUsers({
230
+ resourceType: 'organization',
231
+ organizationId,
232
+ batchSize: 10,
233
+ })
234
+ return (
235
+ <div>
236
+ <div data-testid="output">
237
+ {data.map((user) => user.profile.displayName).join(', ')} -
238
+ {hasMore ? 'has more' : 'no more'} -{isPending ? 'pending' : 'not pending'}
239
+ </div>
240
+ <button data-testid="button" onClick={() => setOrganizationId('org2')}>
241
+ Change Organization
242
+ </button>
243
+ </div>
244
+ )
245
+ }
246
+
247
+ render(<WrapperComponent />)
248
+
249
+ // Initially, should show data for org1 and not pending
250
+ expect(screen.getByTestId('output').textContent).toContain('User One')
251
+ expect(screen.getByTestId('output').textContent).toContain('no more')
252
+ expect(screen.getByTestId('output').textContent).toContain('not pending')
253
+
254
+ // Trigger organization change to "org2"
255
+ act(() => {
256
+ screen.getByTestId('button').click()
257
+ })
258
+
259
+ // Immediately after clicking, deferredKey is still for org1,
260
+ // so the hook returns data from the previous org ('User One') but isPending should now be true.
261
+ expect(screen.getByTestId('output').textContent).toContain('User One')
262
+ expect(screen.getByTestId('output').textContent).toContain('pending')
263
+
264
+ // Simulate the completion of the transition.
265
+ await act(async () => {
266
+ resolvePromise()
267
+ })
268
+
269
+ // Now, the component should render with the new deferred options and display new data.
270
+ expect(screen.getByTestId('output').textContent).toContain('User Two')
271
+ expect(screen.getByTestId('output').textContent).toContain('has more')
272
+ expect(screen.getByTestId('output').textContent).toContain('not pending')
273
+ })
274
+
275
+ it('should call loadMoreUsers when loadMore is called', () => {
276
+ const getCurrent = vi.fn().mockReturnValue({
277
+ data: mockUsers,
278
+ hasMore: true,
279
+ totalCount: 2,
280
+ })
281
+
282
+ // Type assertion to fix the StateSource type issue
283
+ vi.mocked(getUsersState).mockReturnValue({
284
+ getCurrent,
285
+ subscribe: vi.fn(),
286
+ get observable(): Observable<unknown> {
287
+ throw new Error('Not implemented')
288
+ },
289
+ } as unknown as StateSource<
290
+ {data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined
291
+ >)
292
+
293
+ function TestComponent() {
294
+ const {data, hasMore, loadMore} = useUsers({
295
+ resourceType: 'organization',
296
+ organizationId: 'test-org',
297
+ batchSize: 10,
298
+ })
299
+ return (
300
+ <div>
301
+ <div data-testid="output">
302
+ {data.length} users - {hasMore ? 'has more' : 'no more'}
303
+ </div>
304
+ <button data-testid="load-more" onClick={loadMore}>
305
+ Load More
306
+ </button>
307
+ </div>
308
+ )
309
+ }
310
+
311
+ render(<TestComponent />)
312
+
313
+ // Verify initial state
314
+ expect(screen.getByTestId('output').textContent).toContain('2 users')
315
+ expect(screen.getByTestId('output').textContent).toContain('has more')
316
+
317
+ // Click the load more button
318
+ fireEvent.click(screen.getByTestId('load-more'))
319
+
320
+ // Verify that loadMoreUsers was called with the correct arguments
321
+ expect(loadMoreUsers).toHaveBeenCalledWith(
322
+ expect.objectContaining({config: {projectId: 'p'}}),
323
+ {
324
+ resourceType: 'organization',
325
+ organizationId: 'test-org',
326
+ batchSize: 10,
327
+ },
328
+ )
329
+ })
330
+ })