@sanity/sdk-react 3.0.0-rc.0 → 3.0.0-rc.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.
Files changed (168) hide show
  1. package/README.md +103 -155
  2. package/dist/index.d.ts +2488 -2019
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +4435 -1529
  5. package/dist/index.js.map +1 -1
  6. package/package.json +55 -59
  7. package/src/_exports/index.ts +2 -4
  8. package/src/_exports/sdk-react.ts +30 -18
  9. package/src/components/SDKProvider.test.tsx +173 -32
  10. package/src/components/SDKProvider.tsx +87 -28
  11. package/src/components/SanityApp.test.tsx +68 -12
  12. package/src/components/SanityApp.tsx +93 -87
  13. package/src/components/auth/AuthBoundary.recovery.test.tsx +86 -0
  14. package/src/components/auth/AuthBoundary.test.tsx +7 -7
  15. package/src/components/auth/AuthBoundary.tsx +29 -6
  16. package/src/components/auth/DashboardAccessRequest.tsx +37 -0
  17. package/src/components/auth/LoginError.test.tsx +191 -5
  18. package/src/components/auth/LoginError.tsx +104 -61
  19. package/src/components/errors/ChunkLoadError.test.tsx +59 -0
  20. package/src/components/errors/ChunkLoadError.tsx +56 -0
  21. package/src/components/errors/CorsErrorComponent.tsx +2 -2
  22. package/src/components/errors/chunkReloadStorage.ts +57 -0
  23. package/src/config/handles.ts +29 -27
  24. package/src/constants.ts +5 -0
  25. package/src/context/ComlinkTokenRefresh.test.tsx +10 -27
  26. package/src/context/ComlinkTokenRefresh.tsx +3 -5
  27. package/src/context/DefaultResourceContext.ts +3 -3
  28. package/src/context/OrganizationResourcesProvider.test.tsx +189 -0
  29. package/src/context/OrganizationResourcesProvider.tsx +111 -0
  30. package/src/context/PerspectiveContext.ts +3 -3
  31. package/src/context/ProjectContext.ts +15 -0
  32. package/src/context/ResourceProvider.test.tsx +93 -90
  33. package/src/context/ResourceProvider.tsx +62 -93
  34. package/src/context/SDKStudioContext.test.tsx +28 -33
  35. package/src/context/SanityInstanceProvider.test.tsx +100 -0
  36. package/src/context/SanityInstanceProvider.tsx +71 -0
  37. package/src/context/WorkbenchTokenRefresh.test.tsx +106 -0
  38. package/src/context/WorkbenchTokenRefresh.tsx +61 -0
  39. package/src/context/renderSanityApp.test.tsx +151 -49
  40. package/src/context/renderSanityApp.tsx +12 -8
  41. package/src/context/workbenchToken.ts +63 -0
  42. package/src/hooks/access/useCheckPermissions.test-d.ts +15 -0
  43. package/src/hooks/access/useCheckPermissions.test.tsx +53 -0
  44. package/src/hooks/access/useCheckPermissions.ts +24 -0
  45. package/src/hooks/agent/agentActions.test.tsx +2 -2
  46. package/src/hooks/agent/agentActions.ts +52 -72
  47. package/src/hooks/agent/useAgentResourceContext.ts +1 -1
  48. package/src/hooks/applications/useApplication.test-d.ts +30 -0
  49. package/src/hooks/applications/useApplication.ts +22 -0
  50. package/src/hooks/applications/useApplications.test-d.ts +31 -0
  51. package/src/hooks/applications/useApplications.ts +25 -0
  52. package/src/hooks/applications/useDeleteApplication.test-d.ts +12 -0
  53. package/src/hooks/applications/useDeleteApplication.ts +11 -0
  54. package/src/hooks/applications/useUpdateApplication.test-d.ts +12 -0
  55. package/src/hooks/applications/useUpdateApplication.ts +11 -0
  56. package/src/hooks/auth/useDashboardOrganizationId.test.tsx +2 -8
  57. package/src/hooks/auth/useVerifyOrgProjects.test.tsx +8 -32
  58. package/src/hooks/auth/useVerifyOrgProjects.tsx +13 -6
  59. package/src/hooks/client/useClient.test.tsx +1 -4
  60. package/src/hooks/client/useClient.ts +1 -0
  61. package/src/hooks/comlink/useFrameConnection.test.tsx +3 -2
  62. package/src/hooks/comlink/useFrameConnection.ts +1 -1
  63. package/src/hooks/comlink/useWindowConnection.test.tsx +4 -3
  64. package/src/hooks/comlink/useWindowConnection.ts +2 -3
  65. package/src/hooks/comments/useCommentActions.test.tsx +150 -0
  66. package/src/hooks/comments/useCommentActions.ts +109 -0
  67. package/src/hooks/comments/useCommentList.ts +79 -0
  68. package/src/hooks/comments/useCommentThreads.test.tsx +107 -0
  69. package/src/hooks/comments/useCommentThreads.ts +73 -0
  70. package/src/hooks/comments/useComments.test.tsx +242 -0
  71. package/src/hooks/comments/useComments.ts +59 -0
  72. package/src/hooks/context/useResource.test.tsx +32 -0
  73. package/src/hooks/context/useResource.ts +24 -0
  74. package/src/hooks/context/useSanityInstance.test.tsx +18 -1
  75. package/src/hooks/context/useSanityInstance.ts +6 -6
  76. package/src/hooks/dashboard/useManageFavorite.test.tsx +110 -46
  77. package/src/hooks/dashboard/useManageFavorite.ts +11 -17
  78. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +5 -7
  79. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +2 -6
  80. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.test.ts +0 -2
  81. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.ts +2 -2
  82. package/src/hooks/dashboard/useWindowTitle.test.ts +244 -0
  83. package/src/hooks/dashboard/useWindowTitle.ts +114 -0
  84. package/src/hooks/datasets/useDatasets.test.tsx +116 -0
  85. package/src/hooks/datasets/useDatasets.ts +33 -13
  86. package/src/hooks/document/useApplyDocumentActions.test.tsx +48 -0
  87. package/src/hooks/document/useApplyDocumentActions.ts +66 -48
  88. package/src/hooks/document/useCreateDocument.test.tsx +83 -0
  89. package/src/hooks/document/useCreateDocument.ts +117 -0
  90. package/src/hooks/document/useDocument.test.tsx +37 -8
  91. package/src/hooks/document/useDocument.ts +131 -66
  92. package/src/hooks/document/useDocumentEvent.test.tsx +16 -4
  93. package/src/hooks/document/useDocumentEvent.ts +16 -10
  94. package/src/hooks/document/useDocumentPermissions.test.tsx +8 -7
  95. package/src/hooks/document/useDocumentPermissions.ts +71 -39
  96. package/src/hooks/document/useDocumentSyncStatus.test.ts +1 -0
  97. package/src/hooks/document/useDocumentSyncStatus.ts +13 -9
  98. package/src/hooks/document/useEditDocument.test.tsx +21 -14
  99. package/src/hooks/document/useEditDocument.ts +163 -17
  100. package/src/hooks/documents/useDocuments.test.tsx +64 -44
  101. package/src/hooks/documents/useDocuments.ts +31 -20
  102. package/src/hooks/helpers/createCallbackHook.test.tsx +13 -19
  103. package/src/hooks/helpers/createFetcherHook.test.tsx +180 -0
  104. package/src/hooks/helpers/createFetcherHook.ts +69 -0
  105. package/src/hooks/helpers/createMutationHook.test.tsx +125 -0
  106. package/src/hooks/helpers/createMutationHook.tsx +93 -0
  107. package/src/hooks/helpers/createStateSourceHook.test.tsx +10 -10
  108. package/src/hooks/helpers/createStateSourceHook.tsx +2 -1
  109. package/src/hooks/{document/useApplyDocumentActions.test.ts → helpers/useApplyActions.test.tsx} +92 -53
  110. package/src/hooks/helpers/useApplyActions.ts +68 -0
  111. package/src/hooks/helpers/useNormalizedResourceOptions.test.tsx +226 -0
  112. package/src/hooks/helpers/useNormalizedResourceOptions.ts +79 -48
  113. package/src/hooks/helpers/useResolvedProjectId.test.tsx +59 -0
  114. package/src/hooks/helpers/useResolvedProjectId.ts +35 -0
  115. package/src/hooks/helpers/useTrackHookUsage.ts +37 -0
  116. package/src/hooks/installations/useInstallation.test-d.ts +19 -0
  117. package/src/hooks/installations/useInstallation.ts +22 -0
  118. package/src/hooks/installations/useInstallations.test-d.ts +26 -0
  119. package/src/hooks/installations/useInstallations.ts +25 -0
  120. package/src/hooks/organizations/useOrganization.test-d.ts +60 -0
  121. package/src/hooks/organizations/useOrganization.test.ts +63 -0
  122. package/src/hooks/organizations/useOrganization.ts +34 -0
  123. package/src/hooks/organizations/useOrganizations.test-d.ts +68 -0
  124. package/src/hooks/organizations/useOrganizations.test.ts +63 -0
  125. package/src/hooks/organizations/useOrganizations.ts +39 -0
  126. package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +34 -27
  127. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +35 -22
  128. package/src/hooks/presence/usePresence.test.tsx +0 -15
  129. package/src/hooks/presence/usePresence.ts +22 -20
  130. package/src/hooks/presence/usePresenceForDocument.test.tsx +141 -0
  131. package/src/hooks/presence/usePresenceForDocument.ts +104 -0
  132. package/src/hooks/presence/useReportPresence.test.tsx +202 -0
  133. package/src/hooks/presence/useReportPresence.ts +170 -0
  134. package/src/hooks/preview/useDocumentPreview.test.tsx +0 -1
  135. package/src/hooks/preview/useDocumentPreview.tsx +12 -14
  136. package/src/hooks/projection/useDocumentProjection.test.tsx +78 -9
  137. package/src/hooks/projection/useDocumentProjection.ts +81 -2
  138. package/src/hooks/projects/useProject.test-d.ts +56 -0
  139. package/src/hooks/projects/useProject.test.tsx +120 -0
  140. package/src/hooks/projects/useProject.ts +42 -43
  141. package/src/hooks/projects/useProjects.test-d.ts +60 -0
  142. package/src/hooks/projects/useProjects.test.ts +45 -98
  143. package/src/hooks/projects/useProjects.ts +17 -32
  144. package/src/hooks/query/useQuery.test.tsx +6 -5
  145. package/src/hooks/query/useQuery.ts +94 -17
  146. package/src/hooks/releases/useActiveReleases.test.tsx +13 -7
  147. package/src/hooks/releases/useActiveReleases.ts +28 -24
  148. package/src/hooks/releases/useAllReleases.test.tsx +93 -0
  149. package/src/hooks/releases/useAllReleases.ts +62 -0
  150. package/src/hooks/releases/useApplyReleaseActions.test.tsx +66 -0
  151. package/src/hooks/releases/useApplyReleaseActions.ts +82 -0
  152. package/src/hooks/releases/usePerspective.test.tsx +18 -10
  153. package/src/hooks/releases/usePerspective.ts +13 -17
  154. package/src/hooks/users/useUser.test.tsx +3 -9
  155. package/src/hooks/users/useUser.ts +4 -8
  156. package/src/hooks/users/useUsers.test.tsx +103 -7
  157. package/src/hooks/users/useUsers.ts +38 -5
  158. package/src/utils/resolveOrgResources.test.ts +111 -0
  159. package/src/utils/resolveOrgResources.ts +69 -0
  160. package/src/hooks/context/useDefaultResource.test.tsx +0 -25
  161. package/src/hooks/context/useDefaultResource.ts +0 -30
  162. package/src/hooks/dashboard/useDispatchIntent.test.ts +0 -254
  163. package/src/hooks/dashboard/useDispatchIntent.ts +0 -157
  164. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.test.ts +0 -107
  165. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +0 -39
  166. package/src/hooks/datasets/useDatasets.test.ts +0 -66
  167. package/src/hooks/helpers/useNormalizedResourceOptions.test.ts +0 -65
  168. package/src/hooks/projects/useProject.test.ts +0 -79
@@ -0,0 +1,56 @@
1
+ import {useEffect} from 'react'
2
+ import {type FallbackProps} from 'react-error-boundary'
3
+
4
+ import {clearChunkReloadFlag, readChunkReloadFlag, setChunkReloadFlag} from './chunkReloadStorage'
5
+ import {Error} from './Error'
6
+
7
+ function reload(): void {
8
+ try {
9
+ window.location.reload()
10
+ } catch {
11
+ // No-op: nothing useful we can do if reload itself throws.
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Default fallback rendered when a dynamic-import or chunk-load error
17
+ * bubbles up to the SDK's top-level error boundary.
18
+ *
19
+ * On the first occurrence in a session we set a flag and trigger
20
+ * window.location.reload(), since chunk-load errors almost always indicate a
21
+ * stale tab that simply needs a fresh index.html. If the flag is already set
22
+ * we render a manual reload UI instead, which prevents an infinite reload
23
+ * loop in the rare case the error is genuinely unrecoverable (network
24
+ * outage, CSP, etc.).
25
+ *
26
+ * @internal
27
+ */
28
+ export function ChunkLoadError(_props: FallbackProps): React.ReactNode {
29
+ const alreadyAttempted = readChunkReloadFlag()
30
+
31
+ useEffect(() => {
32
+ if (alreadyAttempted) return
33
+ setChunkReloadFlag()
34
+ reload()
35
+ }, [alreadyAttempted])
36
+
37
+ if (!alreadyAttempted) {
38
+ // Render nothing during the brief window before the page reloads so the
39
+ // user does not see a flash of error UI.
40
+ return null
41
+ }
42
+
43
+ return (
44
+ <Error
45
+ heading="A new version is available"
46
+ description="The page tried to load an asset that no longer exists. Reload to continue with the latest version."
47
+ cta={{
48
+ text: 'Reload page',
49
+ onClick: () => {
50
+ clearChunkReloadFlag()
51
+ reload()
52
+ },
53
+ }}
54
+ />
55
+ )
56
+ }
@@ -1,5 +1,5 @@
1
1
  import {useMemo} from 'react'
2
- import {type FallbackProps} from 'react-error-boundary'
2
+ import {type FallbackProps, getErrorMessage} from 'react-error-boundary'
3
3
 
4
4
  import {Error} from './Error'
5
5
 
@@ -30,7 +30,7 @@ export function CorsErrorComponent({projectId, error}: CorsErrorComponentProps):
30
30
  },
31
31
  }
32
32
  : {
33
- description: error?.message,
33
+ description: getErrorMessage(error),
34
34
  })}
35
35
  />
36
36
  )
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Session-storage key tracking whether the SDK has already attempted an
3
+ * automatic reload in response to a chunk-load error during this session.
4
+ *
5
+ * @internal
6
+ */
7
+ export const CHUNK_RELOAD_STORAGE_KEY = '__sanity_sdk_chunk_reload_attempted'
8
+
9
+ /**
10
+ * Returns true when this session has already triggered an automatic reload.
11
+ * Returns false if session storage is unreadable.
12
+ *
13
+ * @internal
14
+ */
15
+ export function readChunkReloadFlag(): boolean {
16
+ try {
17
+ if (typeof window === 'undefined' || typeof window.sessionStorage === 'undefined') {
18
+ return false
19
+ }
20
+ return window.sessionStorage.getItem(CHUNK_RELOAD_STORAGE_KEY) !== null
21
+ } catch {
22
+ return false
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Marks the session as having attempted an automatic reload, so the next
28
+ * chunk-load error renders the manual reload UI instead of looping.
29
+ *
30
+ * @internal
31
+ */
32
+ export function setChunkReloadFlag(): void {
33
+ try {
34
+ if (typeof window === 'undefined' || typeof window.sessionStorage === 'undefined') return
35
+ window.sessionStorage.setItem(CHUNK_RELOAD_STORAGE_KEY, '1')
36
+ } catch {
37
+ // Storage may be unavailable (private mode quotas, disabled cookies).
38
+ // Falling through means the user sees the manual-reload UI instead of an
39
+ // automatic reload, which is the correct degradation.
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Clears the chunk-reload flag. Called from SDKProvider once the SDK
45
+ * mounts successfully past the error boundary so a future incident in the
46
+ * same session can trigger another automatic reload.
47
+ *
48
+ * @internal
49
+ */
50
+ export function clearChunkReloadFlag(): void {
51
+ try {
52
+ if (typeof window === 'undefined' || typeof window.sessionStorage === 'undefined') return
53
+ window.sessionStorage.removeItem(CHUNK_RELOAD_STORAGE_KEY)
54
+ } catch {
55
+ // No-op: see setChunkReloadFlag.
56
+ }
57
+ }
@@ -1,53 +1,55 @@
1
- import {type DocumentResource, type PerspectiveHandle} from '@sanity/sdk'
2
-
3
- // React-layer types — shadow core equivalents when imported from @sanity/sdk-react.
4
- // resource is optional here and resolved from context by normalization,
5
- // whereas core's DocumentHandle/ResourceHandle require resource explicitly.
1
+ import {
2
+ type DatasetHandle,
3
+ type DocumentHandle as CoreDocumentHandle,
4
+ type DocumentTypeHandle as CoreDocumentTypeHandle,
5
+ } from '@sanity/sdk'
6
6
 
7
7
  /**
8
- * SDK React ResourceHandle with optional explicit resource field.
9
- * Resource is resolved from context when not provided.
10
- * When a `resourceName` is provided, the resource will be resolved from the context using the `ResourcesContext`,
11
- * if there is a matching resource by that name.
8
+ * React SDK resource handle extends the core DatasetHandle with `resourceName`
9
+ * for context-based resource resolution.
10
+ *
11
+ * Use this (or its subtypes) as the options type for custom hooks that need to
12
+ * accept a resource. It accepts a `resource` object, a `resourceName` registered
13
+ * via the `resources` prop on `<SanityApp>`, or a bare `projectId`/`dataset` pair
14
+ * for backward compatibility.
15
+ *
12
16
  * @public
13
17
  */
14
18
  export interface ResourceHandle<
15
- TProjectId extends string = string,
16
19
  TDataset extends string = string,
17
- > {
18
- resource?: DocumentResource<TProjectId, TDataset>
20
+ TProjectId extends string = string,
21
+ > extends DatasetHandle<TDataset, TProjectId> {
22
+ /**
23
+ * Name of a resource registered via the `resources` prop on `<SanityApp>`.
24
+ * Resolved to a `DocumentResource` at the React layer.
25
+ */
19
26
  resourceName?: string
20
- perspective?: PerspectiveHandle['perspective']
21
27
  }
22
28
 
23
29
  /**
24
- * SDK React DocumentTypeHandle with optional explicit resource field.
25
- * Resource is resolved from context when not provided.
26
- * When a `resourceName` is provided, the resource will be resolved from the context using the `ResourcesContext`,
27
- * if there is a matching resource by that name.
30
+ * React SDK document-type handle. Adds `resourceName` to the core `DocumentTypeHandle`.
28
31
  * @public
29
32
  */
30
33
  export interface DocumentTypeHandle<
31
34
  TDocumentType extends string = string,
32
35
  TDataset extends string = string,
33
36
  TProjectId extends string = string,
34
- > extends ResourceHandle<TProjectId, TDataset> {
35
- documentType: TDocumentType
36
- documentId?: string
37
- liveEdit?: boolean
37
+ > extends CoreDocumentTypeHandle<TDocumentType, TDataset, TProjectId> {
38
+ resourceName?: string
38
39
  }
39
40
 
40
41
  /**
41
- * SDK React DocumentHandle with optional explicit resource field.
42
- * Resource is resolved from context when not provided.
43
- * When a `resourceName` is provided, the resource will be resolved from the context using the `ResourcesContext`,
44
- * if there is a matching resource by that name.
42
+ * React SDK document handle. Adds `resourceName` to the core `DocumentHandle`.
43
+ *
44
+ * Import from `@sanity/sdk-react` (not `@sanity/sdk`) when writing option types
45
+ * for hooks this version understands `resourceName` resolution.
46
+ *
45
47
  * @public
46
48
  */
47
49
  export interface DocumentHandle<
48
50
  TDocumentType extends string = string,
49
51
  TDataset extends string = string,
50
52
  TProjectId extends string = string,
51
- > extends DocumentTypeHandle<TDocumentType, TDataset, TProjectId> {
52
- documentId: string
53
+ > extends CoreDocumentHandle<TDocumentType, TDataset, TProjectId> {
54
+ resourceName?: string
53
55
  }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The name of the resource automatically registered from a single-config `<SanityApp>`.
3
+ * Hooks that receive no resource information fall back to this named resource.
4
+ */
5
+ export const DEFAULT_RESOURCE_NAME = 'default'
@@ -56,10 +56,7 @@ describe('ComlinkTokenRefresh', () => {
56
56
  it('should not request new token on 401 if not in dashboard', async () => {
57
57
  mockUseAuthState.mockReturnValue({type: AuthStateType.LOGGED_IN})
58
58
  const {rerender} = render(
59
- <ResourceProvider
60
- resource={{projectId: 'test-project', dataset: 'test-dataset'}}
61
- fallback={null}
62
- >
59
+ <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
63
60
  <ComlinkTokenRefreshProvider>
64
61
  <div>Test</div>
65
62
  </ComlinkTokenRefreshProvider>
@@ -72,10 +69,7 @@ describe('ComlinkTokenRefresh', () => {
72
69
  })
73
70
  act(() => {
74
71
  rerender(
75
- <ResourceProvider
76
- resource={{projectId: 'test-project', dataset: 'test-dataset'}}
77
- fallback={null}
78
- >
72
+ <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
79
73
  <ComlinkTokenRefreshProvider>
80
74
  <div>Test</div>
81
75
  </ComlinkTokenRefreshProvider>
@@ -98,10 +92,7 @@ describe('ComlinkTokenRefresh', () => {
98
92
  it('should initialize useWindowConnection with correct parameters when not in studio mode', () => {
99
93
  // Simulate studio mode disabled by default
100
94
  render(
101
- <ResourceProvider
102
- resource={{projectId: 'test-project', dataset: 'test-dataset'}}
103
- fallback={null}
104
- >
95
+ <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
105
96
  <ComlinkTokenRefreshProvider>
106
97
  <div>Test</div>
107
98
  </ComlinkTokenRefreshProvider>
@@ -133,10 +124,7 @@ describe('ComlinkTokenRefresh', () => {
133
124
  document.body.appendChild(errorContainer)
134
125
 
135
126
  render(
136
- <ResourceProvider
137
- resource={{projectId: 'test-project', dataset: 'test-dataset'}}
138
- fallback={null}
139
- >
127
+ <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
140
128
  <ComlinkTokenRefreshProvider>
141
129
  <div>Test</div>
142
130
  </ComlinkTokenRefreshProvider>
@@ -150,10 +138,11 @@ describe('ComlinkTokenRefresh', () => {
150
138
  expect(mockSetAuthToken).toHaveBeenCalledWith(expect.any(Object), 'new-token')
151
139
  expect(mockFetch).toHaveBeenCalledTimes(1)
152
140
  expect(mockFetch).toHaveBeenCalledWith('dashboard/v1/auth/tokens/create')
153
- // Assert setAuthToken was called with a SanityInstance
141
+ // Assert setAuthToken was called with instance matching provider config
154
142
  const instanceArg = mockSetAuthToken.mock.calls[0][0]
155
- expect(instanceArg).toHaveProperty('instanceId')
156
- expect(instanceArg).toHaveProperty('config')
143
+ expect(instanceArg.config).toEqual(
144
+ expect.objectContaining({projectId: 'test-project', dataset: 'test-dataset'}),
145
+ )
157
146
  // Unauthorized error container should be removed
158
147
  expect(document.getElementById('__sanityError')).toBeNull()
159
148
  })
@@ -166,10 +155,7 @@ describe('ComlinkTokenRefresh', () => {
166
155
  mockFetch.mockResolvedValueOnce({token: null})
167
156
 
168
157
  render(
169
- <ResourceProvider
170
- resource={{projectId: 'test-project', dataset: 'test-dataset'}}
171
- fallback={null}
172
- >
158
+ <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
173
159
  <ComlinkTokenRefreshProvider>
174
160
  <div>Test</div>
175
161
  </ComlinkTokenRefreshProvider>
@@ -191,10 +177,7 @@ describe('ComlinkTokenRefresh', () => {
191
177
  mockFetch.mockRejectedValueOnce(new Error('Fetch failed'))
192
178
 
193
179
  render(
194
- <ResourceProvider
195
- resource={{projectId: 'test-project', dataset: 'test-dataset'}}
196
- fallback={null}
197
- >
180
+ <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
198
181
  <ComlinkTokenRefreshProvider>
199
182
  <div>Test</div>
200
183
  </ComlinkTokenRefreshProvider>
@@ -1,15 +1,13 @@
1
1
  import {type ClientError} from '@sanity/client'
2
2
  import {SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'
3
+ import {AuthStateType, getIsInDashboardState, setAuthToken} from '@sanity/sdk'
4
+ import {isStudioConfig} from '@sanity/sdk/_internal'
3
5
  import {
4
- AuthStateType,
5
6
  type FrameMessage,
6
- getIsInDashboardState,
7
- isStudioConfig,
8
7
  type NewTokenResponseMessage,
9
8
  type RequestNewTokenMessage,
10
- setAuthToken,
11
9
  type WindowMessage,
12
- } from '@sanity/sdk'
10
+ } from '@sanity/sdk/comlink'
13
11
  import React, {type PropsWithChildren, useCallback, useEffect, useMemo, useRef} from 'react'
14
12
 
15
13
  import {useAuthState} from '../hooks/auth/useAuthState'
@@ -2,9 +2,9 @@ import {type DocumentResource} from '@sanity/sdk'
2
2
  import {createContext} from 'react'
3
3
 
4
4
  /**
5
- * Provides the active resource for a subtree.
6
- * Set by `ResourceProvider` so hooks resolve the correct project/dataset
7
- * without requiring an explicit `resource` option.
5
+ * Provides the active DocumentResource for a subtree.
6
+ * Set by ResourceProvider; read by useNormalizedResourceOptions as a fallback
7
+ * when hooks receive no explicit resource or resourceName.
8
8
  * @internal
9
9
  */
10
10
  export const ResourceContext = createContext<DocumentResource | undefined>(undefined)
@@ -0,0 +1,189 @@
1
+ import {createSanityInstance, type DocumentResource} from '@sanity/sdk'
2
+ import {act, render} from '@testing-library/react'
3
+ import {type ReactNode, Suspense, useContext, useEffect} from 'react'
4
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
5
+
6
+ import {useDashboardOrganizationId} from '../hooks/auth/useDashboardOrganizationId'
7
+ import {resolveOrgResources} from '../utils/resolveOrgResources'
8
+ import {OrganizationResourcesProvider} from './OrganizationResourcesProvider'
9
+ import {ResourcesContext} from './ResourcesContext'
10
+
11
+ function promiseWithResolvers<T>(): {
12
+ promise: Promise<T>
13
+ resolve: (value: T) => void
14
+ } {
15
+ let resolve!: (value: T) => void
16
+ const promise = new Promise<T>((res) => {
17
+ resolve = res
18
+ })
19
+ return {promise, resolve}
20
+ }
21
+
22
+ // Reset to a fresh instance per test so the module-level WeakMap cache (keyed by
23
+ // instance) starts empty. `useSanityInstance` returns the same object across
24
+ // renders within a test, which is what the cache relies on.
25
+ let instance = createSanityInstance()
26
+
27
+ vi.mock('../hooks/context/useSanityInstance', () => {
28
+ return {useSanityInstance: () => instance}
29
+ })
30
+
31
+ vi.mock('../hooks/auth/useDashboardOrganizationId', () => ({
32
+ useDashboardOrganizationId: vi.fn(),
33
+ }))
34
+
35
+ vi.mock('../utils/resolveOrgResources', () => ({
36
+ resolveOrgResources: vi.fn(),
37
+ }))
38
+
39
+ const mockResolveOrgResources = vi.mocked(resolveOrgResources)
40
+ const mockUseDashboardOrganizationId = vi.mocked(useDashboardOrganizationId)
41
+
42
+ describe('OrganizationResourcesProvider', () => {
43
+ beforeEach(() => {
44
+ vi.clearAllMocks()
45
+ instance = createSanityInstance()
46
+ mockResolveOrgResources.mockResolvedValue({})
47
+ mockUseDashboardOrganizationId.mockReturnValue(undefined)
48
+ })
49
+
50
+ // Captures ResourcesContext via useEffect (side-effect territory, not render)
51
+ // to satisfy the react-compiler purity rule.
52
+ function makeCapture() {
53
+ const deferred = promiseWithResolvers<Record<string, DocumentResource>>()
54
+
55
+ const CaptureContexts = () => {
56
+ const resources = useContext(ResourcesContext)
57
+ useEffect(() => {
58
+ deferred.resolve(resources)
59
+ }, [resources])
60
+ return null
61
+ }
62
+
63
+ return {CaptureContexts, capturedResources: deferred.promise}
64
+ }
65
+
66
+ function renderProvider(
67
+ children: ReactNode,
68
+ options?: {
69
+ resources?: Record<string, DocumentResource>
70
+ inferMediaLibraryAndCanvas?: boolean
71
+ },
72
+ ) {
73
+ return render(
74
+ <Suspense fallback={<div>loading</div>}>
75
+ <OrganizationResourcesProvider
76
+ resources={options?.resources}
77
+ inferMediaLibraryAndCanvas={options?.inferMediaLibraryAndCanvas}
78
+ >
79
+ {children}
80
+ </OrganizationResourcesProvider>
81
+ </Suspense>,
82
+ )
83
+ }
84
+
85
+ it('does not suspend when inferMediaLibraryAndCanvas is not set', async () => {
86
+ const {CaptureContexts, capturedResources} = makeCapture()
87
+ renderProvider(<CaptureContexts />)
88
+ const resources = await capturedResources
89
+ expect(Object.keys(resources)).toHaveLength(0)
90
+ expect(mockResolveOrgResources).not.toHaveBeenCalled()
91
+ })
92
+
93
+ it('suspends children while org resources are being fetched, then renders with resolved resources', async () => {
94
+ mockUseDashboardOrganizationId.mockReturnValue('org-suspend-test')
95
+ const {promise, resolve} = promiseWithResolvers<void>()
96
+ mockResolveOrgResources.mockReturnValue(
97
+ promise.then(() => ({
98
+ mediaLibrary: {mediaLibraryId: 'ml-id'},
99
+ canvas: {canvasId: 'canvas-id'},
100
+ })) as ReturnType<typeof mockResolveOrgResources>,
101
+ )
102
+
103
+ const {CaptureContexts, capturedResources} = makeCapture()
104
+ await act(async () => {
105
+ renderProvider(<CaptureContexts />, {inferMediaLibraryAndCanvas: true})
106
+ })
107
+
108
+ await act(async () => {
109
+ resolve()
110
+ })
111
+
112
+ const resources = await capturedResources
113
+ expect(resources['media-library']).toEqual({mediaLibraryId: 'ml-id'})
114
+ expect(resources['canvas']).toEqual({canvasId: 'canvas-id'})
115
+ })
116
+
117
+ it('renders without inferred entries and logs a warning when resolveOrgResources rejects', async () => {
118
+ mockUseDashboardOrganizationId.mockReturnValue('org-warn-test')
119
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
120
+ mockResolveOrgResources.mockRejectedValue(new Error('fetch failed'))
121
+
122
+ const {CaptureContexts, capturedResources} = makeCapture()
123
+ await act(async () => {
124
+ renderProvider(<CaptureContexts />, {inferMediaLibraryAndCanvas: true})
125
+ })
126
+
127
+ const resources = await capturedResources
128
+ expect(resources['media-library']).toBeUndefined()
129
+ expect(resources['canvas']).toBeUndefined()
130
+ expect(consoleSpy).toHaveBeenCalledWith(
131
+ '[sanity/sdk] Failed to infer org resources:',
132
+ expect.any(Error),
133
+ )
134
+ consoleSpy.mockRestore()
135
+ })
136
+
137
+ it('caches Promise references across providers for the same instance', () => {
138
+ mockUseDashboardOrganizationId.mockReturnValue('org-cache-test')
139
+ render(
140
+ <Suspense fallback={null}>
141
+ <OrganizationResourcesProvider inferMediaLibraryAndCanvas>
142
+ {null}
143
+ </OrganizationResourcesProvider>
144
+ </Suspense>,
145
+ )
146
+ render(
147
+ <Suspense fallback={null}>
148
+ <OrganizationResourcesProvider inferMediaLibraryAndCanvas>
149
+ {null}
150
+ </OrganizationResourcesProvider>
151
+ </Suspense>,
152
+ )
153
+
154
+ // resolveOrgResources should only have been called once across both providers.
155
+ expect(mockResolveOrgResources).toHaveBeenCalledTimes(1)
156
+ })
157
+
158
+ it('explicit resources are passed through to ResourcesContext', async () => {
159
+ const explicitResources = {
160
+ default: {projectId: 'p1', dataset: 'prod'},
161
+ secondary: {projectId: 'p2', dataset: 'staging'},
162
+ }
163
+
164
+ const {CaptureContexts, capturedResources} = makeCapture()
165
+ renderProvider(<CaptureContexts />, {resources: explicitResources})
166
+
167
+ expect(await capturedResources).toEqual(explicitResources)
168
+ })
169
+
170
+ it('explicit resources take precedence over inferred ones', async () => {
171
+ mockUseDashboardOrganizationId.mockReturnValue('org-override-test')
172
+ mockResolveOrgResources.mockResolvedValue({
173
+ mediaLibrary: {mediaLibraryId: 'inferred-ml'},
174
+ canvas: {canvasId: 'inferred-canvas'},
175
+ })
176
+
177
+ const explicitResources = {'media-library': {mediaLibraryId: 'explicit-ml'}}
178
+ const {CaptureContexts, capturedResources} = makeCapture()
179
+ await act(async () => {
180
+ renderProvider(<CaptureContexts />, {
181
+ resources: explicitResources,
182
+ inferMediaLibraryAndCanvas: true,
183
+ })
184
+ })
185
+
186
+ const resources = await capturedResources
187
+ expect(resources['media-library']).toEqual({mediaLibraryId: 'explicit-ml'})
188
+ })
189
+ })
@@ -0,0 +1,111 @@
1
+ import {type DocumentResource, type SanityInstance} from '@sanity/sdk'
2
+ import {type ReactElement, type ReactNode, use, useMemo} from 'react'
3
+
4
+ import {useDashboardOrganizationId} from '../hooks/auth/useDashboardOrganizationId'
5
+ import {useSanityInstance} from '../hooks/context/useSanityInstance'
6
+ import {resolveOrgResources} from '../utils/resolveOrgResources'
7
+ import {ResourcesContext} from './ResourcesContext'
8
+
9
+ const DEFAULT_MEDIA_LIBRARY_RESOURCE_NAME = 'media-library'
10
+ const DEFAULT_CANVAS_RESOURCE_NAME = 'canvas'
11
+
12
+ type OrgResource = typeof DEFAULT_MEDIA_LIBRARY_RESOURCE_NAME | typeof DEFAULT_CANVAS_RESOURCE_NAME
13
+ type OrgResourcePromises = Map<OrgResource, PromiseLike<DocumentResource | undefined>>
14
+
15
+ // Module-level cache keyed by SanityInstance so Promise references are stable
16
+ // across Suspense unmount/remount cycles. React's use() tracks promises by
17
+ // identity, so stable references prevent unnecessary re-suspensions.
18
+ // WeakMap entries are GC'd when the instance is disposed.
19
+ // There is only ever one org at a time, so the instance alone is a sufficient
20
+ // key — organizationId only scopes the fetch, it doesn't need to key the cache.
21
+ const inferredResourceCache = new WeakMap<SanityInstance, OrgResourcePromises>()
22
+
23
+ function getOrgResourcePromises(
24
+ instance: SanityInstance,
25
+ organizationId: string,
26
+ ): OrgResourcePromises {
27
+ let promises = inferredResourceCache.get(instance)
28
+ if (!promises) {
29
+ const basePromise = resolveOrgResources(instance, organizationId).then(
30
+ (result) => result,
31
+ (error) => {
32
+ // eslint-disable-next-line no-console
33
+ console.warn('[sanity/sdk] Failed to infer org resources:', error)
34
+ return {mediaLibrary: undefined, canvas: undefined}
35
+ },
36
+ )
37
+ promises = new Map([
38
+ [DEFAULT_MEDIA_LIBRARY_RESOURCE_NAME, basePromise.then((r) => r.mediaLibrary)],
39
+ [DEFAULT_CANVAS_RESOURCE_NAME, basePromise.then((r) => r.canvas)],
40
+ ])
41
+ inferredResourceCache.set(instance, promises)
42
+ }
43
+
44
+ return promises
45
+ }
46
+
47
+ /**
48
+ * Inner component that suspends via use() until both inference promises settle.
49
+ * Requires a Suspense boundary from the caller (usually a top ResourceProvider).
50
+ */
51
+ function InferredResourcesProvider({
52
+ instance,
53
+ orgId,
54
+ explicitResources,
55
+ children,
56
+ }: {
57
+ instance: SanityInstance
58
+ orgId: string
59
+ explicitResources: Record<string, DocumentResource>
60
+ children: ReactNode
61
+ }): ReactElement {
62
+ const promises = getOrgResourcePromises(instance, orgId)
63
+ const ml = use(promises.get(DEFAULT_MEDIA_LIBRARY_RESOURCE_NAME)!)
64
+ const canvas = use(promises.get(DEFAULT_CANVAS_RESOURCE_NAME)!)
65
+
66
+ const resources = useMemo(() => {
67
+ const inferred: Record<string, DocumentResource> = {}
68
+ if (ml !== undefined) inferred[DEFAULT_MEDIA_LIBRARY_RESOURCE_NAME] = ml
69
+ if (canvas !== undefined) inferred[DEFAULT_CANVAS_RESOURCE_NAME] = canvas
70
+ return {...inferred, ...explicitResources}
71
+ }, [ml, canvas, explicitResources])
72
+
73
+ return <ResourcesContext.Provider value={resources}>{children}</ResourcesContext.Provider>
74
+ }
75
+
76
+ /**
77
+ * When `inferMediaLibraryAndCanvas` is set, this component suspends until
78
+ * inference resolves. The nearest Suspense boundary (e.g. from ResourceProvider)
79
+ * shows its fallback during that window.
80
+
81
+ * If a user explicitly names a 'media-library' or 'canvas' resource,
82
+ * this component will use that resource instead of inferring it.
83
+ */
84
+ export function OrganizationResourcesProvider({
85
+ resources: explicitResources = {},
86
+ inferMediaLibraryAndCanvas,
87
+ children,
88
+ }: {
89
+ resources?: Record<string, DocumentResource>
90
+ inferMediaLibraryAndCanvas?: boolean
91
+ children: ReactNode
92
+ }): ReactElement {
93
+ const instance = useSanityInstance()
94
+ const orgId = useDashboardOrganizationId()
95
+
96
+ if (!inferMediaLibraryAndCanvas || !orgId) {
97
+ return (
98
+ <ResourcesContext.Provider value={explicitResources}>{children}</ResourcesContext.Provider>
99
+ )
100
+ }
101
+
102
+ return (
103
+ <InferredResourcesProvider
104
+ instance={instance}
105
+ orgId={orgId}
106
+ explicitResources={explicitResources}
107
+ >
108
+ {children}
109
+ </InferredResourcesProvider>
110
+ )
111
+ }
@@ -2,9 +2,9 @@ import {type PerspectiveHandle} from '@sanity/sdk'
2
2
  import {createContext} from 'react'
3
3
 
4
4
  /**
5
- * Provides a perspective override for nested subtrees.
6
- * Set by `ResourceProvider` so hooks resolve the correct perspective
7
- * without requiring an explicit `perspective` option.
5
+ * Provides the active perspective for a subtree.
6
+ * Set by ResourceProvider; injected by useNormalizedResourceOptions when
7
+ * the hook's options don't include an explicit perspective.
8
8
  * @internal
9
9
  */
10
10
  export const PerspectiveContext = createContext<PerspectiveHandle['perspective'] | undefined>(
@@ -0,0 +1,15 @@
1
+ import {createContext} from 'react'
2
+
3
+ /**
4
+ * Carries a `projectId` for a subtree even when a complete
5
+ * `DatasetResource` can't be formed — e.g. a `<ResourceProvider projectId="…">`
6
+ * with no `dataset` and no parent resource to inherit one from.
7
+ *
8
+ * Project-scoped hooks (`useProject`, `useDatasets`, `useUsers`) read this as a
9
+ * fallback so they can still resolve a project in that case (the only hooks that would work with a config like this.)
10
+ *
11
+ * @remarks Temporary bridge for the dataset-less project scope.
12
+ * Remove in the next major version; those hooks should just always use a projectId or default to a resource available.
13
+ * @internal
14
+ */
15
+ export const ProjectContext = createContext<string | undefined>(undefined)