@sanity/sdk-react 3.0.0-rc.0 → 3.0.0-rc.1

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 (160) hide show
  1. package/README.md +103 -155
  2. package/dist/index.d.ts +1564 -682
  3. package/dist/index.js +1118 -612
  4. package/dist/index.js.map +1 -1
  5. package/package.json +51 -54
  6. package/src/_exports/index.ts +2 -4
  7. package/src/_exports/sdk-react.ts +26 -17
  8. package/src/components/SDKProvider.test.tsx +173 -32
  9. package/src/components/SDKProvider.tsx +87 -28
  10. package/src/components/SanityApp.test.tsx +68 -12
  11. package/src/components/SanityApp.tsx +94 -87
  12. package/src/components/auth/AuthBoundary.recovery.test.tsx +86 -0
  13. package/src/components/auth/AuthBoundary.test.tsx +26 -8
  14. package/src/components/auth/AuthBoundary.tsx +28 -6
  15. package/src/components/auth/DashboardAccessRequest.tsx +37 -0
  16. package/src/components/auth/LoginError.test.tsx +191 -5
  17. package/src/components/auth/LoginError.tsx +103 -59
  18. package/src/components/errors/ChunkLoadError.test.tsx +59 -0
  19. package/src/components/errors/ChunkLoadError.tsx +56 -0
  20. package/src/components/errors/CorsErrorComponent.tsx +2 -2
  21. package/src/components/errors/chunkReloadStorage.ts +57 -0
  22. package/src/config/handles.ts +29 -27
  23. package/src/constants.ts +5 -0
  24. package/src/context/ComlinkTokenRefresh.test.tsx +10 -27
  25. package/src/context/DefaultResourceContext.ts +3 -3
  26. package/src/context/OrganizationResourcesProvider.test.tsx +189 -0
  27. package/src/context/OrganizationResourcesProvider.tsx +111 -0
  28. package/src/context/PerspectiveContext.ts +3 -3
  29. package/src/context/ProjectContext.ts +15 -0
  30. package/src/context/ResourceProvider.test.tsx +72 -90
  31. package/src/context/ResourceProvider.tsx +59 -93
  32. package/src/context/SDKStudioContext.test.tsx +28 -33
  33. package/src/context/SanityInstanceProvider.test.tsx +100 -0
  34. package/src/context/SanityInstanceProvider.tsx +71 -0
  35. package/src/context/WorkbenchTokenRefresh.test.tsx +106 -0
  36. package/src/context/WorkbenchTokenRefresh.tsx +61 -0
  37. package/src/context/renderSanityApp.test.tsx +151 -49
  38. package/src/context/renderSanityApp.tsx +12 -8
  39. package/src/context/workbenchToken.ts +63 -0
  40. package/src/hooks/access/useCheckPermissions.test-d.ts +15 -0
  41. package/src/hooks/access/useCheckPermissions.test.tsx +53 -0
  42. package/src/hooks/access/useCheckPermissions.ts +24 -0
  43. package/src/hooks/agent/agentActions.test.tsx +1 -1
  44. package/src/hooks/agent/agentActions.ts +51 -71
  45. package/src/hooks/applications/useApplication.test-d.ts +30 -0
  46. package/src/hooks/applications/useApplication.ts +22 -0
  47. package/src/hooks/applications/useApplications.test-d.ts +31 -0
  48. package/src/hooks/applications/useApplications.ts +25 -0
  49. package/src/hooks/applications/useCreateUserApplication.test-d.ts +14 -0
  50. package/src/hooks/applications/useCreateUserApplication.ts +11 -0
  51. package/src/hooks/applications/useDeleteApplication.test-d.ts +12 -0
  52. package/src/hooks/applications/useDeleteApplication.ts +11 -0
  53. package/src/hooks/applications/useDeleteUserApplication.test-d.ts +14 -0
  54. package/src/hooks/applications/useDeleteUserApplication.ts +11 -0
  55. package/src/hooks/applications/useUpdateApplication.test-d.ts +12 -0
  56. package/src/hooks/applications/useUpdateApplication.ts +11 -0
  57. package/src/hooks/applications/useUpdateUserApplication.test-d.ts +14 -0
  58. package/src/hooks/applications/useUpdateUserApplication.ts +11 -0
  59. package/src/hooks/applications/useUserApplication.test-d.ts +11 -0
  60. package/src/hooks/applications/useUserApplication.ts +14 -0
  61. package/src/hooks/applications/useUserApplications.test-d.ts +11 -0
  62. package/src/hooks/applications/useUserApplications.ts +14 -0
  63. package/src/hooks/auth/useDashboardOrganizationId.test.tsx +2 -8
  64. package/src/hooks/auth/useVerifyOrgProjects.test.tsx +8 -32
  65. package/src/hooks/auth/useVerifyOrgProjects.tsx +13 -6
  66. package/src/hooks/client/useClient.test.tsx +1 -4
  67. package/src/hooks/client/useClient.ts +1 -0
  68. package/src/hooks/context/useResource.test.tsx +32 -0
  69. package/src/hooks/context/useResource.ts +24 -0
  70. package/src/hooks/context/useSanityInstance.test.tsx +72 -3
  71. package/src/hooks/context/useSanityInstance.ts +30 -8
  72. package/src/hooks/dashboard/useManageFavorite.test.tsx +110 -46
  73. package/src/hooks/dashboard/useManageFavorite.ts +10 -16
  74. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +5 -7
  75. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +2 -6
  76. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.test.ts +0 -2
  77. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.ts +1 -2
  78. package/src/hooks/dashboard/useWindowTitle.test.ts +244 -0
  79. package/src/hooks/dashboard/useWindowTitle.ts +114 -0
  80. package/src/hooks/datasets/useDatasets.test.tsx +116 -0
  81. package/src/hooks/datasets/useDatasets.ts +33 -13
  82. package/src/hooks/document/useApplyDocumentActions.test.tsx +48 -0
  83. package/src/hooks/document/useApplyDocumentActions.ts +66 -48
  84. package/src/hooks/document/useCreateDocument.test.tsx +83 -0
  85. package/src/hooks/document/useCreateDocument.ts +117 -0
  86. package/src/hooks/document/useDocument.test.tsx +37 -8
  87. package/src/hooks/document/useDocument.ts +131 -66
  88. package/src/hooks/document/useDocumentEvent.test.tsx +16 -4
  89. package/src/hooks/document/useDocumentEvent.ts +16 -10
  90. package/src/hooks/document/useDocumentPermissions.test.tsx +8 -7
  91. package/src/hooks/document/useDocumentPermissions.ts +71 -39
  92. package/src/hooks/document/useDocumentSyncStatus.test.ts +1 -0
  93. package/src/hooks/document/useDocumentSyncStatus.ts +13 -9
  94. package/src/hooks/document/useEditDocument.test.tsx +21 -14
  95. package/src/hooks/document/useEditDocument.ts +163 -17
  96. package/src/hooks/documents/useDocuments.test.tsx +64 -44
  97. package/src/hooks/documents/useDocuments.ts +36 -20
  98. package/src/hooks/helpers/createCallbackHook.test.tsx +13 -19
  99. package/src/hooks/helpers/createFetcherHook.test.tsx +180 -0
  100. package/src/hooks/helpers/createFetcherHook.ts +69 -0
  101. package/src/hooks/helpers/createMutationHook.test.tsx +125 -0
  102. package/src/hooks/helpers/createMutationHook.tsx +93 -0
  103. package/src/hooks/helpers/createStateSourceHook.test.tsx +10 -10
  104. package/src/hooks/helpers/createStateSourceHook.tsx +2 -1
  105. package/src/hooks/{document/useApplyDocumentActions.test.ts → helpers/useApplyActions.test.tsx} +92 -53
  106. package/src/hooks/helpers/useApplyActions.ts +68 -0
  107. package/src/hooks/helpers/useNormalizedResourceOptions.test.tsx +253 -0
  108. package/src/hooks/helpers/useNormalizedResourceOptions.ts +92 -50
  109. package/src/hooks/helpers/useResolvedProjectId.test.tsx +59 -0
  110. package/src/hooks/helpers/useResolvedProjectId.ts +35 -0
  111. package/src/hooks/helpers/useTrackHookUsage.ts +37 -0
  112. package/src/hooks/installations/useInstallation.test-d.ts +19 -0
  113. package/src/hooks/installations/useInstallation.ts +22 -0
  114. package/src/hooks/installations/useInstallations.test-d.ts +26 -0
  115. package/src/hooks/installations/useInstallations.ts +25 -0
  116. package/src/hooks/organizations/useOrganization.test-d.ts +60 -0
  117. package/src/hooks/organizations/useOrganization.test.ts +63 -0
  118. package/src/hooks/organizations/useOrganization.ts +34 -0
  119. package/src/hooks/organizations/useOrganizations.test-d.ts +68 -0
  120. package/src/hooks/organizations/useOrganizations.test.ts +63 -0
  121. package/src/hooks/organizations/useOrganizations.ts +39 -0
  122. package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +34 -27
  123. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +40 -22
  124. package/src/hooks/presence/usePresence.test.tsx +0 -15
  125. package/src/hooks/presence/usePresence.ts +9 -20
  126. package/src/hooks/preview/useDocumentPreview.test.tsx +0 -1
  127. package/src/hooks/preview/useDocumentPreview.tsx +10 -8
  128. package/src/hooks/projection/useDocumentProjection.test.tsx +78 -9
  129. package/src/hooks/projection/useDocumentProjection.ts +81 -2
  130. package/src/hooks/projects/useProject.test-d.ts +56 -0
  131. package/src/hooks/projects/useProject.test.tsx +120 -0
  132. package/src/hooks/projects/useProject.ts +42 -43
  133. package/src/hooks/projects/useProjects.test-d.ts +60 -0
  134. package/src/hooks/projects/useProjects.test.ts +45 -98
  135. package/src/hooks/projects/useProjects.ts +19 -26
  136. package/src/hooks/query/useQuery.test.tsx +6 -5
  137. package/src/hooks/query/useQuery.ts +91 -9
  138. package/src/hooks/releases/useActiveReleases.test.tsx +13 -7
  139. package/src/hooks/releases/useActiveReleases.ts +28 -24
  140. package/src/hooks/releases/useAllReleases.test.tsx +93 -0
  141. package/src/hooks/releases/useAllReleases.ts +62 -0
  142. package/src/hooks/releases/useApplyReleaseActions.test.tsx +66 -0
  143. package/src/hooks/releases/useApplyReleaseActions.ts +82 -0
  144. package/src/hooks/releases/usePerspective.test.tsx +18 -10
  145. package/src/hooks/releases/usePerspective.ts +13 -17
  146. package/src/hooks/users/useUser.test.tsx +3 -9
  147. package/src/hooks/users/useUser.ts +2 -0
  148. package/src/hooks/users/useUsers.test.tsx +103 -7
  149. package/src/hooks/users/useUsers.ts +37 -3
  150. package/src/utils/resolveOrgResources.test.ts +111 -0
  151. package/src/utils/resolveOrgResources.ts +69 -0
  152. package/src/hooks/context/useDefaultResource.test.tsx +0 -25
  153. package/src/hooks/context/useDefaultResource.ts +0 -30
  154. package/src/hooks/dashboard/useDispatchIntent.test.ts +0 -254
  155. package/src/hooks/dashboard/useDispatchIntent.ts +0 -157
  156. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.test.ts +0 -107
  157. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +0 -39
  158. package/src/hooks/datasets/useDatasets.test.ts +0 -66
  159. package/src/hooks/helpers/useNormalizedResourceOptions.test.ts +0 -65
  160. package/src/hooks/projects/useProject.test.ts +0 -79
@@ -23,39 +23,35 @@ import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOpti
23
23
  * @example
24
24
  * ```tsx
25
25
  * import {usePerspective, useQuery} from '@sanity/sdk-react'
26
- *
27
- * const perspective = usePerspective({
28
- * perspective: 'rxg1346',
29
- * resource: {projectId: 'abc123', dataset: 'production'},
30
- * })
31
- * const {data} = useQuery<Movie[]>({
32
- * query: '*[_type == "movie"]',
33
- * perspective,
26
+
27
+ * const perspective = usePerspective({perspective: 'rxg1346', projectId: 'abc123', dataset: 'production'})
28
+ * const {data} = useQuery<Movie[]>('*[_type == "movie"]', {
29
+ * perspective: perspective,
34
30
  * })
35
31
  * ```
36
32
  *
37
33
  * @returns The perspective for the given perspective handle.
38
34
  */
39
- type UsePerspective = {
40
- (perspectiveHandle?: ResourceHandle): string | string[]
35
+ type UsePerspectiveValue = {
36
+ (perspectiveHandle: {resource?: DocumentResource}): string | string[]
41
37
  }
42
38
 
43
- const usePerspectiveValue = createStateSourceHook({
39
+ const usePerspectiveValue: UsePerspectiveValue = createStateSourceHook({
44
40
  getState: getPerspectiveState as (
45
41
  instance: SanityInstance,
46
- perspectiveHandle: {resource: DocumentResource; perspective?: unknown},
42
+ perspectiveHandle?: {resource?: DocumentResource},
47
43
  ) => StateSource<string | string[]>,
48
- shouldSuspend: (instance: SanityInstance, options: {resource: DocumentResource}): boolean =>
44
+ shouldSuspend: (instance: SanityInstance, options: {resource?: DocumentResource}): boolean =>
49
45
  getPerspectiveState(instance, options).getCurrent() === undefined,
50
- suspender: (instance: SanityInstance, _options?: {resource: DocumentResource}) =>
51
- firstValueFrom(getPerspectiveState(instance, _options).observable.pipe(filter(Boolean))),
46
+ suspender: (instance: SanityInstance, _options?: {resource?: DocumentResource}) =>
47
+ firstValueFrom(getPerspectiveState(instance, _options ?? {}).observable.pipe(filter(Boolean))),
52
48
  })
53
49
 
54
50
  /**
55
51
  * @public
56
52
  * @function
57
53
  */
58
- export const usePerspective: UsePerspective = (options: ResourceHandle | undefined) => {
59
- const normalizedOptions = useNormalizedResourceOptions(options ?? {})
54
+ export function usePerspective(perspectiveHandle?: ResourceHandle): string | string[] {
55
+ const normalizedOptions = useNormalizedResourceOptions(perspectiveHandle ?? {})
60
56
  return usePerspectiveValue(normalizedOptions)
61
57
  }
@@ -88,7 +88,7 @@ describe('useUser', () => {
88
88
  }
89
89
 
90
90
  render(
91
- <ResourceProvider resource={{projectId: 'p', dataset: 'production'}} fallback={null}>
91
+ <ResourceProvider projectId="p" fallback={null}>
92
92
  <TestComponent />
93
93
  </ResourceProvider>,
94
94
  )
@@ -340,10 +340,7 @@ describe('useUser', () => {
340
340
  }
341
341
 
342
342
  render(
343
- <ResourceProvider
344
- resource={{projectId: 'test-project', dataset: 'production'}}
345
- fallback={null}
346
- >
343
+ <ResourceProvider projectId="test-project" fallback={null}>
347
344
  <TestComponent />
348
345
  </ResourceProvider>,
349
346
  )
@@ -390,10 +387,7 @@ describe('useUser', () => {
390
387
  }
391
388
 
392
389
  render(
393
- <ResourceProvider
394
- resource={{projectId: 'test-project', dataset: 'production'}}
395
- fallback={null}
396
- >
390
+ <ResourceProvider projectId="test-project" fallback={null}>
397
391
  <WrapperComponent />
398
392
  </ResourceProvider>,
399
393
  )
@@ -9,6 +9,7 @@ import {
9
9
  import {useEffect, useMemo, useState, useSyncExternalStore, useTransition} from 'react'
10
10
 
11
11
  import {useSanityInstance} from '../context/useSanityInstance'
12
+ import {trackHookUsage} from '../helpers/useTrackHookUsage'
12
13
 
13
14
  /**
14
15
  * @public
@@ -59,6 +60,7 @@ export interface UserResult {
59
60
  */
60
61
  export function useUser(options: GetUserOptions): UserResult {
61
62
  const instance = useSanityInstance()
63
+ trackHookUsage(instance, 'useUser')
62
64
  // Use React's useTransition to avoid UI jank when user options change
63
65
  const [isPending, startTransition] = useTransition()
64
66
 
@@ -1,4 +1,5 @@
1
1
  import {
2
+ createSanityInstance,
2
3
  getUsersState,
3
4
  loadMoreUsers,
4
5
  resolveUsers,
@@ -6,12 +7,14 @@ import {
6
7
  type StateSource,
7
8
  type UserProfile,
8
9
  } from '@sanity/sdk'
9
- import {act, fireEvent, render, screen} from '@testing-library/react'
10
- import {useState} from 'react'
10
+ import {act, fireEvent, render, renderHook, screen} from '@testing-library/react'
11
+ import {type ReactNode, useState} from 'react'
11
12
  import {type Observable, Subject} from 'rxjs'
12
13
  import {describe, expect, it, vi} from 'vitest'
13
14
 
14
15
  import {ResourceProvider} from '../../context/ResourceProvider'
16
+ import {SanityInstanceContext} from '../../context/SanityInstanceContext'
17
+ import {useSanityInstance} from '../context/useSanityInstance'
15
18
  import {useUsers} from './useUsers'
16
19
 
17
20
  // Mock the functions from '@sanity/sdk'
@@ -313,10 +316,7 @@ describe('useUsers', () => {
313
316
  }
314
317
 
315
318
  render(
316
- <ResourceProvider
317
- resource={{projectId: 'p', dataset: 'production'}}
318
- fallback={<div data-testid="fallback">Loading...</div>}
319
- >
319
+ <ResourceProvider projectId="p" fallback={<div data-testid="fallback">Loading...</div>}>
320
320
  <TestComponent />
321
321
  </ResourceProvider>,
322
322
  )
@@ -330,7 +330,7 @@ describe('useUsers', () => {
330
330
 
331
331
  // Verify that loadMoreUsers was called with the correct arguments
332
332
  expect(loadMoreUsers).toHaveBeenCalledWith(
333
- expect.objectContaining({instanceId: expect.any(String)}),
333
+ expect.objectContaining({config: {projectId: 'p'}}),
334
334
  {
335
335
  resourceType: 'organization',
336
336
  organizationId: 'test-org',
@@ -338,4 +338,100 @@ describe('useUsers', () => {
338
338
  },
339
339
  )
340
340
  })
341
+
342
+ it('resolves the projectId from the default resource for a project-scoped query', () => {
343
+ vi.mocked(getUsersState).mockReturnValue({
344
+ getCurrent: vi.fn().mockReturnValue({data: mockUsers, hasMore: false, totalCount: 2}),
345
+ subscribe: vi.fn(),
346
+ get observable(): Observable<unknown> {
347
+ throw new Error('Not implemented')
348
+ },
349
+ } as unknown as StateSource<
350
+ {data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined
351
+ >)
352
+
353
+ const {
354
+ result: {current: instance},
355
+ } = renderHook(
356
+ () => {
357
+ useUsers({resourceType: 'project'})
358
+ return useSanityInstance()
359
+ },
360
+ {
361
+ wrapper: ({children}: {children: ReactNode}) => (
362
+ <ResourceProvider
363
+ resource={{projectId: 'resource-project', dataset: 'production'}}
364
+ fallback={null}
365
+ >
366
+ {children}
367
+ </ResourceProvider>
368
+ ),
369
+ },
370
+ )
371
+
372
+ expect(getUsersState).toHaveBeenLastCalledWith(
373
+ instance,
374
+ expect.objectContaining({projectId: 'resource-project', resourceType: 'project'}),
375
+ )
376
+ })
377
+
378
+ it('does not inject a projectId for an organization-scoped query', () => {
379
+ vi.mocked(getUsersState).mockReturnValue({
380
+ getCurrent: vi.fn().mockReturnValue({data: mockUsers, hasMore: false, totalCount: 2}),
381
+ subscribe: vi.fn(),
382
+ get observable(): Observable<unknown> {
383
+ throw new Error('Not implemented')
384
+ },
385
+ } as unknown as StateSource<
386
+ {data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined
387
+ >)
388
+
389
+ function TestComponent() {
390
+ useUsers({resourceType: 'organization', organizationId: 'test-org'})
391
+ return null
392
+ }
393
+
394
+ render(
395
+ <ResourceProvider
396
+ resource={{projectId: 'resource-project', dataset: 'production'}}
397
+ fallback={null}
398
+ >
399
+ <TestComponent />
400
+ </ResourceProvider>,
401
+ )
402
+
403
+ // Organization queries key off organizationId; the ambient project resource
404
+ // must not leak in as a projectId.
405
+ expect(vi.mocked(getUsersState).mock.lastCall?.[1]).not.toHaveProperty('projectId')
406
+ })
407
+
408
+ it('resolves a bare projectId from a ResourceProvider when the parent instance has no config', () => {
409
+ vi.mocked(getUsersState).mockReturnValue({
410
+ getCurrent: vi.fn().mockReturnValue({data: mockUsers, hasMore: false, totalCount: 2}),
411
+ subscribe: vi.fn(),
412
+ get observable(): Observable<unknown> {
413
+ throw new Error('Not implemented')
414
+ },
415
+ } as unknown as StateSource<
416
+ {data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined
417
+ >)
418
+
419
+ // An instance with no project/dataset config, then a
420
+ // projectId-only ResourceProvider.
421
+ const emptyInstance = createSanityInstance({})
422
+ renderHook(() => useUsers(), {
423
+ wrapper: ({children}: {children: ReactNode}) => (
424
+ <SanityInstanceContext.Provider value={emptyInstance}>
425
+ <ResourceProvider projectId="bare-project" fallback={null}>
426
+ {children}
427
+ </ResourceProvider>
428
+ </SanityInstanceContext.Provider>
429
+ ),
430
+ })
431
+
432
+ expect(getUsersState).toHaveBeenLastCalledWith(
433
+ emptyInstance,
434
+ expect.objectContaining({projectId: 'bare-project'}),
435
+ )
436
+ })
341
437
  })
@@ -10,6 +10,8 @@ import {
10
10
  import {useCallback, useEffect, useMemo, useState, useSyncExternalStore, useTransition} from 'react'
11
11
 
12
12
  import {useSanityInstance} from '../context/useSanityInstance'
13
+ import {useResolvedProjectId} from '../helpers/useResolvedProjectId'
14
+ import {trackHookUsage} from '../helpers/useTrackHookUsage'
13
15
 
14
16
  /**
15
17
  * @public
@@ -35,6 +37,23 @@ export interface UsersResult {
35
37
  loadMore: () => void
36
38
  }
37
39
 
40
+ /**
41
+ * Injects a resolved `projectId` into project-scoped users options. A missing
42
+ * `projectId` is a no-op, and organization-scoped queries (explicit
43
+ * `resourceType`/`organizationId`) or options that already carry a `projectId`
44
+ * are returned unchanged.
45
+ */
46
+ function withResolvedProjectId(
47
+ options: GetUsersOptions | undefined,
48
+ projectId: string | undefined,
49
+ ): GetUsersOptions | undefined {
50
+ if (!projectId) return options
51
+ if (!options) return {projectId}
52
+ const isOrgScoped = options.resourceType === 'organization' || !!options.organizationId
53
+ if (isOrgScoped || options.projectId) return options
54
+ return {...options, projectId}
55
+ }
56
+
38
57
  /**
39
58
  *
40
59
  * @public
@@ -66,14 +85,29 @@ export interface UsersResult {
66
85
  * </div>
67
86
  * )
68
87
  * ```
88
+ * @remarks
89
+ * For project-scoped queries the `projectId` is resolved in order from:
90
+ * 1. an explicit `projectId` option
91
+ * 2. A legacy ProjectContext (e.g. a `<ResourceProvider projectId="…">` with no dataset), then
92
+ * 3. The active resource (`ResourceProvider`/`SDKProvider`)
93
+ * 4. `instance.config`.
69
94
  */
70
95
  export function useUsers(options?: GetUsersOptions): UsersResult {
71
96
  const instance = useSanityInstance()
97
+ trackHookUsage(instance, 'useUsers')
72
98
  // Use React's useTransition to avoid UI jank when user options change
73
99
  const [isPending, startTransition] = useTransition()
74
100
 
101
+ // Resolve the projectId from the ambient project/resource context so a
102
+ // project-scoped users request can pick it up rather than the top-level config.
103
+ const resolvedProjectId = useResolvedProjectId(options)
104
+ const effectiveOptions = useMemo(
105
+ () => withResolvedProjectId(options, resolvedProjectId),
106
+ [options, resolvedProjectId],
107
+ )
108
+
75
109
  // Get the unique key for this users request and its options
76
- const key = getUsersKey(instance, options)
110
+ const key = getUsersKey(instance, effectiveOptions)
77
111
  // Use a deferred state to avoid immediate re-renders when the users request changes
78
112
  const [deferredKey, setDeferredKey] = useState(key)
79
113
  // Parse the deferred users key back into users options
@@ -113,8 +147,8 @@ export function useUsers(options?: GetUsersOptions): UsersResult {
113
147
  const {data, hasMore} = useSyncExternalStore(subscribe, getCurrent)!
114
148
 
115
149
  const loadMore = useCallback(() => {
116
- loadMoreUsers(instance, options)
117
- }, [instance, options])
150
+ loadMoreUsers(instance, effectiveOptions)
151
+ }, [instance, effectiveOptions])
118
152
 
119
153
  return {data, hasMore, isPending, loadMore}
120
154
  }
@@ -0,0 +1,111 @@
1
+ import {type SanityClient} from '@sanity/client'
2
+ import {getClientState, type SanityInstance, type StateSource} from '@sanity/sdk'
3
+ import {firstValueFrom} from 'rxjs'
4
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
5
+
6
+ import {resolveOrgResources} from './resolveOrgResources'
7
+
8
+ vi.mock('@sanity/sdk', () => ({
9
+ getClientState: vi.fn(),
10
+ }))
11
+
12
+ vi.mock('rxjs', () => ({
13
+ firstValueFrom: vi.fn(),
14
+ }))
15
+
16
+ const mockGetClientState = vi.mocked(getClientState)
17
+ const mockFirstValueFrom = vi.mocked(firstValueFrom)
18
+
19
+ const mockRequest = vi.fn()
20
+ const mockClient = {request: mockRequest}
21
+ const mockInstance = {} as SanityInstance
22
+
23
+ beforeEach(() => {
24
+ vi.clearAllMocks()
25
+ mockGetClientState.mockReturnValue({
26
+ observable: 'mock-observable',
27
+ } as unknown as StateSource<SanityClient>)
28
+ mockFirstValueFrom.mockResolvedValue(mockClient as unknown as SanityClient)
29
+ })
30
+
31
+ const ORG_ID = 'org-1'
32
+
33
+ describe('resolveOrgResources', () => {
34
+ it('returns both mediaLibrary and canvas when both requests succeed', async () => {
35
+ mockRequest
36
+ .mockResolvedValueOnce({data: [{id: 'ml-123'}]})
37
+ .mockResolvedValueOnce({data: [{id: 'canvas-456'}]})
38
+
39
+ const result = await resolveOrgResources(mockInstance, ORG_ID)
40
+
41
+ expect(result).toEqual({
42
+ mediaLibrary: {mediaLibraryId: 'ml-123'},
43
+ canvas: {canvasId: 'canvas-456'},
44
+ })
45
+ })
46
+
47
+ it('scopes both requests to the organization', async () => {
48
+ mockRequest
49
+ .mockResolvedValueOnce({data: [{id: 'ml-123'}]})
50
+ .mockResolvedValueOnce({data: [{id: 'canvas-456'}]})
51
+
52
+ await resolveOrgResources(mockInstance, ORG_ID)
53
+
54
+ expect(mockRequest).toHaveBeenCalledWith(
55
+ expect.objectContaining({uri: '/media-libraries', query: {organizationId: ORG_ID}}),
56
+ )
57
+ expect(mockRequest).toHaveBeenCalledWith(
58
+ expect.objectContaining({uri: '/canvases', query: {organizationId: ORG_ID}}),
59
+ )
60
+ })
61
+
62
+ it('returns only mediaLibrary when canvas request fails', async () => {
63
+ mockRequest
64
+ .mockResolvedValueOnce({data: [{id: 'ml-123'}]})
65
+ .mockRejectedValueOnce(new Error('canvas not found'))
66
+
67
+ const result = await resolveOrgResources(mockInstance, ORG_ID)
68
+
69
+ expect(result).toEqual({
70
+ mediaLibrary: {mediaLibraryId: 'ml-123'},
71
+ canvas: undefined,
72
+ })
73
+ })
74
+
75
+ it('returns only canvas when mediaLibrary request fails', async () => {
76
+ mockRequest
77
+ .mockRejectedValueOnce(new Error('media library not found'))
78
+ .mockResolvedValueOnce({data: [{id: 'canvas-456'}]})
79
+
80
+ const result = await resolveOrgResources(mockInstance, ORG_ID)
81
+
82
+ expect(result).toEqual({
83
+ mediaLibrary: undefined,
84
+ canvas: {canvasId: 'canvas-456'},
85
+ })
86
+ })
87
+
88
+ it('returns undefined for both when both requests fail', async () => {
89
+ mockRequest
90
+ .mockRejectedValueOnce(new Error('error 1'))
91
+ .mockRejectedValueOnce(new Error('error 2'))
92
+
93
+ const result = await resolveOrgResources(mockInstance, ORG_ID)
94
+
95
+ expect(result).toEqual({
96
+ mediaLibrary: undefined,
97
+ canvas: undefined,
98
+ })
99
+ })
100
+
101
+ it('returns undefined for both when data arrays are empty', async () => {
102
+ mockRequest.mockResolvedValueOnce({data: []}).mockResolvedValueOnce({data: []})
103
+
104
+ const result = await resolveOrgResources(mockInstance, ORG_ID)
105
+
106
+ expect(result).toEqual({
107
+ mediaLibrary: undefined,
108
+ canvas: undefined,
109
+ })
110
+ })
111
+ })
@@ -0,0 +1,69 @@
1
+ import {
2
+ type CanvasResource,
3
+ getClientState,
4
+ type MediaLibraryResource,
5
+ type SanityInstance,
6
+ } from '@sanity/sdk'
7
+ import {firstValueFrom} from 'rxjs'
8
+
9
+ const API_VERSION = 'v2026-07-09'
10
+
11
+ interface OrgResourcesApiItem {
12
+ id: string
13
+ }
14
+
15
+ interface OrgResourcesApiResponse {
16
+ data: OrgResourcesApiItem[]
17
+ }
18
+
19
+ interface OrgResources {
20
+ mediaLibrary?: MediaLibraryResource
21
+ canvas?: CanvasResource
22
+ }
23
+
24
+ /**
25
+ * Gets the id of the first resource from a settled request result. The request
26
+ * is already scoped to a single organization, so the first item is the one we want.
27
+ */
28
+ function getFirstResourceId(
29
+ result: PromiseSettledResult<OrgResourcesApiResponse>,
30
+ ): string | undefined {
31
+ if (result.status !== 'fulfilled') return undefined
32
+ return result.value.data?.[0]?.id
33
+ }
34
+
35
+ /**
36
+ * Fetches the media library and canvas resources for the given organization.
37
+ * Both requests are scoped to `organizationId` so each response contains only
38
+ * that org's resources. Each resource is fetched independently — a failure for
39
+ * one does not prevent the other from resolving.
40
+ */
41
+ export async function resolveOrgResources(
42
+ instance: SanityInstance,
43
+ organizationId: string,
44
+ ): Promise<OrgResources> {
45
+ const client = await firstValueFrom(
46
+ getClientState(instance, {apiVersion: API_VERSION, scope: 'global'}).observable,
47
+ )
48
+
49
+ const [mediaLibrariesResult, canvasesResult] = await Promise.allSettled([
50
+ client.request<OrgResourcesApiResponse>({
51
+ uri: `/media-libraries`,
52
+ query: {organizationId},
53
+ tag: 'org-resources.media-libraries',
54
+ }),
55
+ client.request<OrgResourcesApiResponse>({
56
+ uri: `/canvases`,
57
+ query: {organizationId},
58
+ tag: 'org-resources.canvases',
59
+ }),
60
+ ])
61
+
62
+ const mediaLibraryId = getFirstResourceId(mediaLibrariesResult)
63
+ const canvasId = getFirstResourceId(canvasesResult)
64
+
65
+ return {
66
+ mediaLibrary: mediaLibraryId ? {mediaLibraryId} : undefined,
67
+ canvas: canvasId ? {canvasId} : undefined,
68
+ }
69
+ }
@@ -1,25 +0,0 @@
1
- import {renderHook} from '@testing-library/react'
2
- import {describe, expect, it} from 'vitest'
3
-
4
- import {ResourceContext} from '../../context/DefaultResourceContext'
5
- import {useResource} from './useDefaultResource'
6
-
7
- describe('useResource', () => {
8
- it('returns the resource from context', () => {
9
- const resource = {projectId: 'my-project', dataset: 'my-dataset'}
10
-
11
- const {result} = renderHook(() => useResource(), {
12
- wrapper: ({children}) => (
13
- <ResourceContext.Provider value={resource}>{children}</ResourceContext.Provider>
14
- ),
15
- })
16
-
17
- expect(result.current).toBe(resource)
18
- })
19
-
20
- it('returns undefined when no resource is in context', () => {
21
- const {result} = renderHook(() => useResource())
22
-
23
- expect(result.current).toBeUndefined()
24
- })
25
- })
@@ -1,30 +0,0 @@
1
- import {type DocumentResource} from '@sanity/sdk'
2
- import {useContext} from 'react'
3
-
4
- import {ResourceContext} from '../../context/DefaultResourceContext'
5
-
6
- /**
7
- * Returns the current {@link DocumentResource} from context.
8
- *
9
- * @public
10
- *
11
- * @category Platform
12
- * @returns The resource set by the nearest `ResourceProvider`, or `undefined`
13
- *
14
- * @remarks
15
- * With the flat instance model, nested `ResourceProvider`s override the
16
- * active resource via React context rather than creating child instances.
17
- * Use this hook when you need the current project/dataset/resource for a
18
- * subtree instead of reading from `useSanityInstance().config`.
19
- *
20
- * @example Get the current resource
21
- * ```tsx
22
- * const resource = useResource()
23
- * if (resource && 'projectId' in resource) {
24
- * console.log(resource.projectId, resource.dataset)
25
- * }
26
- * ```
27
- */
28
- export function useResource(): DocumentResource | undefined {
29
- return useContext(ResourceContext)
30
- }