@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
@@ -1,65 +1,124 @@
1
1
  import {
2
- DEFAULT_RESOURCE_NAME,
3
2
  type DocumentResource,
4
3
  isDatasetResource,
4
+ isImportError,
5
5
  type SanityConfig,
6
6
  } from '@sanity/sdk'
7
- import {type ReactElement, type ReactNode, useMemo} from 'react'
7
+ import {type ReactElement, type ReactNode, useEffect, useMemo} from 'react'
8
+ import {ErrorBoundary, type FallbackProps} from 'react-error-boundary'
8
9
 
10
+ import {DEFAULT_RESOURCE_NAME} from '../constants'
11
+ import {OrganizationResourcesProvider} from '../context/OrganizationResourcesProvider'
9
12
  import {ResourceProvider} from '../context/ResourceProvider'
10
- import {ResourcesContext} from '../context/ResourcesContext'
11
13
  import {AuthBoundary, type AuthBoundaryProps} from './auth/AuthBoundary'
14
+ import {ChunkLoadError} from './errors/ChunkLoadError'
15
+ import {clearChunkReloadFlag} from './errors/chunkReloadStorage'
12
16
 
13
17
  /**
14
18
  * @internal
15
19
  */
16
20
  export interface SDKProviderProps extends AuthBoundaryProps {
17
21
  children: ReactNode
18
- config: SanityConfig
19
- /**
20
- * Named document resources map. Provided to `ResourcesContext` for
21
- * name-based resource resolution in hooks.
22
- */
23
- resources?: Record<string, DocumentResource>
22
+ config: SanityConfig | SanityConfig[]
24
23
  fallback: ReactNode
24
+ resources?: Record<string, DocumentResource>
25
+ /** When set, automatically fetches and registers the organization's media library and canvas as named resources. */
26
+ inferMediaLibraryAndCanvas?: boolean
25
27
  }
26
28
 
27
29
  /**
28
- * Collects unique project IDs from a resources map.
30
+ * Clears the chunk-reload flag once children render successfully past the
31
+ * top-level boundary, so a future incident in the same session can trigger
32
+ * another automatic reload.
29
33
  */
30
- function collectProjectIds(resources: Record<string, DocumentResource>): string[] {
31
- const ids = new Set<string>()
32
- for (const res of Object.values(resources)) {
33
- if (isDatasetResource(res)) ids.add(res.projectId)
34
- }
35
- return [...ids]
34
+ function ResetChunkReloadFlagOnMount(): null {
35
+ useEffect(() => {
36
+ clearChunkReloadFlag()
37
+ }, [])
38
+ return null
36
39
  }
37
40
 
38
41
  /**
39
42
  * @internal
40
43
  *
41
44
  * Top-level context provider that provides access to the Sanity SDK.
42
- *
43
- * Creates a single `ResourceProvider` (and therefore a single `SanityInstance`)
44
- * for the given config. Resource resolution is handled by `ResourcesContext`
45
- * and the `"default"` named resource.
46
45
  */
47
46
  export function SDKProvider({
48
47
  children,
49
48
  config,
50
- resources = {},
51
49
  fallback,
50
+ inferMediaLibraryAndCanvas,
52
51
  ...props
53
52
  }: SDKProviderProps): ReactElement {
54
- const projectIds = useMemo(() => collectProjectIds(resources), [resources])
53
+ // For legacy config support, extract the first config object as a default resource,
54
+ // matching the legacy behavior nesting ResourceProviders with the first config closest to users' application code.
55
+ // This should be removed when we remove legacy config support.
56
+ const defaultConfig = Array.isArray(config) ? config[0] : config
57
+ const defaultProjectId = defaultConfig?.projectId
58
+ const defaultDataset = defaultConfig?.dataset
55
59
 
56
- const rootResource = useMemo(() => resources[DEFAULT_RESOURCE_NAME], [resources])
60
+ const resourcesValue = useMemo(() => {
61
+ const explicit = props.resources ?? {}
62
+ if (defaultProjectId && defaultDataset && !Object.hasOwn(explicit, DEFAULT_RESOURCE_NAME)) {
63
+ return {
64
+ [DEFAULT_RESOURCE_NAME]: {
65
+ projectId: defaultProjectId,
66
+ dataset: defaultDataset,
67
+ },
68
+ ...explicit,
69
+ }
70
+ }
71
+ return explicit
72
+ }, [defaultProjectId, defaultDataset, props.resources])
73
+
74
+ // Collect all projectIds from both resources and config, deduped so
75
+ // AuthBoundary doesn't verify the same project more than once.
76
+ const projectIds = useMemo(
77
+ () =>
78
+ Array.from(
79
+ new Set(
80
+ [
81
+ ...Object.values(resourcesValue)
82
+ .filter(isDatasetResource)
83
+ .map((resource) => resource.projectId),
84
+ ...(Array.isArray(config) ? config : [config]).map((configObj) => configObj.projectId),
85
+ ].filter((id): id is string => !!id),
86
+ ),
87
+ ),
88
+ [resourcesValue, config],
89
+ )
57
90
 
58
91
  return (
59
- <ResourceProvider {...config} resource={rootResource} fallback={fallback}>
60
- <AuthBoundary {...props} projectIds={projectIds}>
61
- <ResourcesContext.Provider value={resources}>{children}</ResourcesContext.Provider>
62
- </AuthBoundary>
63
- </ResourceProvider>
92
+ <ErrorBoundary FallbackComponent={ChunkAwareFallback}>
93
+ <ResetChunkReloadFlagOnMount />
94
+ {/* Spread the first config so SanityInstance can use the auth,
95
+ perspective, and other config fields; `resource` lets an explicitly
96
+ provided `default` resource override the config-derived resource. */}
97
+ <ResourceProvider
98
+ {...defaultConfig}
99
+ resource={resourcesValue[DEFAULT_RESOURCE_NAME]}
100
+ fallback={fallback}
101
+ >
102
+ <AuthBoundary {...props} projectIds={projectIds}>
103
+ {/* OrganizationResourcesProvider wraps ResourcesContext.
104
+ It merges explicit resources with lazily inferred org resources (media
105
+ library, canvas) and provides the combined map to the subtree. */}
106
+ <OrganizationResourcesProvider
107
+ resources={resourcesValue}
108
+ inferMediaLibraryAndCanvas={inferMediaLibraryAndCanvas}
109
+ >
110
+ {children}
111
+ </OrganizationResourcesProvider>
112
+ </AuthBoundary>
113
+ </ResourceProvider>
114
+ </ErrorBoundary>
64
115
  )
65
116
  }
117
+
118
+ function ChunkAwareFallback(fallbackProps: FallbackProps): ReactElement {
119
+ if (isImportError(fallbackProps.error)) {
120
+ return <ChunkLoadError {...fallbackProps} />
121
+ }
122
+ // Re-throw so downstream boundaries (e.g. AuthBoundary) handle other errors.
123
+ throw fallbackProps.error
124
+ }
@@ -58,11 +58,14 @@ describe('SanityApp', () => {
58
58
  mockSDKProviderComponent.mockClear()
59
59
  })
60
60
 
61
- it('renders SDKProvider with a single resource', () => {
62
- const singleResource = {projectId: 'test-project', dataset: 'production'}
61
+ it('renders SDKProvider with a single config', () => {
62
+ const singleConfig = {
63
+ projectId: 'test-project',
64
+ dataset: 'production',
65
+ }
63
66
 
64
67
  render(
65
- <SanityApp resources={{default: singleResource}} fallback={<div>Loading...</div>}>
68
+ <SanityApp config={singleConfig} fallback={<div>Loading...</div>}>
66
69
  <div>Child Content</div>
67
70
  </SanityApp>,
68
71
  )
@@ -77,17 +80,60 @@ describe('SanityApp', () => {
77
80
  expect(firstCallArgs1).toBeDefined()
78
81
  expect(firstCallArgs1.length).toBeGreaterThan(0)
79
82
  const props = firstCallArgs1[0] as unknown as SDKProviderProps
83
+ const config = props?.config
80
84
 
81
- expect(props.resources).toEqual({default: singleResource})
85
+ // Config is now passed directly as an object for single configs
86
+ expect(config).toEqual(singleConfig)
82
87
  expect(props.fallback).toBeTruthy()
83
88
  })
84
89
 
90
+ it('renders SDKProvider with multiple configs in original order', () => {
91
+ const multipleConfigs = [
92
+ {
93
+ projectId: 'project-1',
94
+ dataset: 'production',
95
+ },
96
+ {
97
+ projectId: 'project-2',
98
+ dataset: 'staging',
99
+ },
100
+ {
101
+ projectId: 'project-3',
102
+ dataset: 'development',
103
+ },
104
+ ]
105
+
106
+ render(
107
+ <SanityApp config={multipleConfigs} fallback={<div>Loading...</div>}>
108
+ <div>Child Content</div>
109
+ </SanityApp>,
110
+ )
111
+
112
+ // Check that the SDKProvider is rendered
113
+ expect(screen.getByTestId('sdk-provider')).toBeInTheDocument()
114
+
115
+ // Verify SDKProvider was called with the correct props
116
+ expect(mockSDKProviderComponent).toHaveBeenCalledTimes(1)
117
+ const sdkProviderCalls = mockSDKProviderComponent.mock.calls
118
+ const firstCallArgs2 = sdkProviderCalls[0]
119
+ expect(firstCallArgs2).toBeDefined()
120
+ expect(firstCallArgs2.length).toBeGreaterThan(0)
121
+ const props = firstCallArgs2[0] as unknown as SDKProviderProps
122
+ const config = props?.config
123
+
124
+ // Config should be passed directly to SDKProvider
125
+ expect(config).toEqual(multipleConfigs)
126
+ })
127
+
85
128
  it('handles iframe environment correctly', async () => {
86
129
  // Mock window.self and window.top to simulate iframe environment
87
130
  const originalTop = window.top
88
131
  const originalSelf = window.self
89
132
 
90
- const mockSanityConfig: SanityConfig = {}
133
+ const mockSanityConfig: SanityConfig = {
134
+ projectId: 'test-project',
135
+ dataset: 'test-dataset',
136
+ }
91
137
 
92
138
  const mockTop = {}
93
139
  Object.defineProperty(window, 'top', {
@@ -100,7 +146,7 @@ describe('SanityApp', () => {
100
146
  })
101
147
 
102
148
  render(
103
- <SanityApp config={mockSanityConfig} fallback={<div>Fallback</div>}>
149
+ <SanityApp config={[mockSanityConfig]} fallback={<div>Fallback</div>}>
104
150
  <div>Test Child</div>
105
151
  </SanityApp>,
106
152
  )
@@ -131,7 +177,10 @@ describe('SanityApp', () => {
131
177
  href: 'http://sanity-test.app',
132
178
  }
133
179
 
134
- const mockSanityConfig: SanityConfig = {}
180
+ const mockSanityConfig: SanityConfig = {
181
+ projectId: 'test-project',
182
+ dataset: 'test-dataset',
183
+ }
135
184
 
136
185
  Object.defineProperty(window, 'location', {
137
186
  value: mockLocation,
@@ -139,7 +188,7 @@ describe('SanityApp', () => {
139
188
  })
140
189
 
141
190
  render(
142
- <SanityApp config={mockSanityConfig} fallback={<div>Fallback</div>}>
191
+ <SanityApp config={[mockSanityConfig]} fallback={<div>Fallback</div>}>
143
192
  <div>Test Child</div>
144
193
  </SanityApp>,
145
194
  )
@@ -192,7 +241,10 @@ describe('SanityApp', () => {
192
241
  it('does not redirect to core if not inside iframe and local url', async () => {
193
242
  const originalLocation = window.location
194
243
 
195
- const mockSanityConfig: SanityConfig = {}
244
+ const mockSanityConfig: SanityConfig = {
245
+ projectId: 'test-project',
246
+ dataset: 'test-dataset',
247
+ }
196
248
 
197
249
  const mockLocation = {
198
250
  replace: vi.fn(),
@@ -205,7 +257,7 @@ describe('SanityApp', () => {
205
257
  })
206
258
 
207
259
  render(
208
- <SanityApp config={mockSanityConfig} fallback={<div>Fallback</div>}>
260
+ <SanityApp config={[mockSanityConfig]} fallback={<div>Fallback</div>}>
209
261
  <div>Test Child</div>
210
262
  </SanityApp>,
211
263
  )
@@ -233,6 +285,8 @@ describe('SanityApp', () => {
233
285
  }
234
286
 
235
287
  const mockSanityConfig: SanityConfig = {
288
+ projectId: 'test-project',
289
+ dataset: 'test-dataset',
236
290
  studio: {},
237
291
  }
238
292
 
@@ -242,7 +296,7 @@ describe('SanityApp', () => {
242
296
  })
243
297
 
244
298
  render(
245
- <SanityApp config={mockSanityConfig} fallback={<div>Fallback</div>}>
299
+ <SanityApp config={[mockSanityConfig]} fallback={<div>Fallback</div>}>
246
300
  <div>Test Child</div>
247
301
  </SanityApp>,
248
302
  )
@@ -271,6 +325,8 @@ describe('SanityApp', () => {
271
325
  }
272
326
 
273
327
  const mockSanityConfig: SanityConfig = {
328
+ projectId: 'test-project',
329
+ dataset: 'test-dataset',
274
330
  studio: {},
275
331
  }
276
332
 
@@ -280,7 +336,7 @@ describe('SanityApp', () => {
280
336
  })
281
337
 
282
338
  render(
283
- <SanityApp config={mockSanityConfig} fallback={<div>Fallback</div>}>
339
+ <SanityApp config={[mockSanityConfig]} fallback={<div>Fallback</div>}>
284
340
  <div>Test Child</div>
285
341
  </SanityApp>,
286
342
  )
@@ -1,9 +1,5 @@
1
- import {
2
- DEFAULT_RESOURCE_NAME,
3
- type DocumentResource,
4
- isStudioConfig,
5
- type SanityConfig,
6
- } from '@sanity/sdk'
1
+ import {type DocumentResource, type SanityConfig} from '@sanity/sdk'
2
+ import {isStudioConfig} from '@sanity/sdk/_internal'
7
3
  import {type ReactElement, useContext, useEffect, useMemo} from 'react'
8
4
 
9
5
  import {SDKStudioContext, type StudioWorkspaceHandle} from '../context/SDKStudioContext'
@@ -16,41 +12,61 @@ import {isInIframe, isLocalUrl} from './utils'
16
12
  */
17
13
  export interface SanityAppProps {
18
14
  /**
19
- * Core configuration for the SDK instance (auth, studio, perspective).
20
- * Optional when `SanityApp` is rendered inside an `SDKStudioContext`
21
- * provider (e.g. inside Sanity Studio) — the config is derived from
22
- * the workspace automatically.
23
- */
24
- config?: SanityConfig
25
- /**
26
- * Named document resources for the application. The resource keyed `"default"`
27
- * is used automatically when no explicit resource is specified in hooks.
15
+ * One or more SanityConfig objects providing a project ID and dataset name.
16
+ * Optional when `SanityApp` is rendered inside an `SDKStudioContext` provider
17
+ * (e.g. inside Sanity Studio) — the config is derived from the workspace
18
+ * automatically.
28
19
  */
20
+ config?: SanityConfig | SanityConfig[]
29
21
  resources?: Record<string, DocumentResource>
30
22
  children: React.ReactNode
31
23
  /* Fallback content to show when child components are suspending. Same as the `fallback` prop for React Suspense. */
32
24
  fallback: React.ReactNode
25
+ /**
26
+ * Set this to automatically fetch and register the organization's media library and canvas as named resources.
27
+ * These resources will be available to hooks as `media-library` and `canvas`.
28
+ *
29
+ * The SDK App must be running in the organization's Dashboard to use this feature.
30
+ *
31
+ * @example
32
+ * ```tsx
33
+ *
34
+ * const MyApp = () => {
35
+ * // should "just work" because of inferMediaLibraryAndCanvas.
36
+ * const {data: assets} = useDocuments({
37
+ * documentType: 'sanity.asset',
38
+ * resourceName: 'media-library',
39
+ * })
40
+ * return (
41
+ * <div>
42
+ * {assets.map((asset) => (
43
+ * <div key={asset._id}>{asset.originalFilename}</div>
44
+ * ))}
45
+ * </div>
46
+ * )
47
+ * }
48
+ * <SanityApp inferMediaLibraryAndCanvas>
49
+ * <MyApp />
50
+ * </SanityApp>
51
+ * ```
52
+ */
53
+ inferMediaLibraryAndCanvas?: boolean
33
54
  }
34
55
 
35
56
  const REDIRECT_URL = 'https://sanity.io/welcome'
36
57
 
37
58
  /**
38
- * Derive a SanityConfig and resources map from a Studio workspace handle.
59
+ * Derive a SanityConfig from a Studio workspace handle.
60
+ * Maps the workspace's projectId, dataset, and reactive auth token into
61
+ * the SDK's config shape.
39
62
  */
40
- function deriveFromWorkspace(workspace: StudioWorkspaceHandle): {
41
- config: SanityConfig
42
- resources: Record<string, DocumentResource>
43
- } {
63
+ function deriveConfigFromWorkspace(workspace: StudioWorkspaceHandle): SanityConfig {
44
64
  return {
45
- config: {
46
- studio: {
47
- authenticated: workspace.authenticated,
48
- auth: workspace.auth.token ? {token: workspace.auth.token} : undefined,
49
- projectId: workspace.projectId,
50
- },
51
- },
52
- resources: {
53
- [DEFAULT_RESOURCE_NAME]: {projectId: workspace.projectId, dataset: workspace.dataset},
65
+ projectId: workspace.projectId,
66
+ dataset: workspace.dataset,
67
+ studio: {
68
+ authenticated: workspace.authenticated,
69
+ auth: workspace.auth.token ? {token: workspace.auth.token} : undefined,
54
70
  },
55
71
  }
56
72
  }
@@ -62,49 +78,57 @@ function deriveFromWorkspace(workspace: StudioWorkspaceHandle): {
62
78
  * as well as application context and state which is used by the Sanity React hooks. Your application
63
79
  * must be wrapped with the SanityApp component to function properly.
64
80
  *
65
- * The `config` prop accepts a {@link SanityConfig} object. Use the `resources` prop to declare
66
- * one or more named data resources for your app.
81
+ * The `config` prop on the SanityApp component accepts either a single {@link SanityConfig} object, or an array of them.
82
+ * This allows your app to work with one or more of your organization's datasets.
67
83
  *
68
- * When rendered inside a Sanity Studio that provides `SDKStudioContext`, the `config` and `resources`
69
- * props are optional — `SanityApp` will automatically derive them from the Studio workspace.
84
+ * When rendered inside a Sanity Studio that provides `SDKStudioContext`, the `config` prop is
85
+ * optional — `SanityApp` will automatically derive `projectId`, `dataset`, and auth from the
86
+ * Studio workspace.
70
87
  *
71
- * When both `config` and `SDKStudioContext` are available, the explicit props take precedence.
88
+ * @remarks
89
+ * When passing multiple SanityConfig objects to the `config` prop, the first configuration in the array becomes the default
90
+ * configuration used by the App SDK Hooks.
91
+ *
92
+ * When both `config` and `SDKStudioContext` are available, the explicit `config` takes precedence.
72
93
  *
73
94
  * @category Components
74
95
  * @param props - Your Sanity configuration and the React children to render
75
96
  * @returns Your Sanity application, integrated with your Sanity configuration and application context
76
97
  *
77
- * @example Single project
98
+ * @example
78
99
  * ```tsx
79
- * import { SanityApp } from '@sanity/sdk-react'
100
+ * import { SanityApp, type SanityConfig } from '@sanity/sdk-react'
80
101
  *
81
- * export default function MyApp() {
82
- * return (
83
- * <SanityApp
84
- * resources={{
85
- * default: { projectId: 'my-project-id', dataset: 'production' },
86
- * }}
87
- * fallback={<div>Loading…</div>}
88
- * >
89
- * <MyAppRoot />
90
- * </SanityApp>
91
- * )
102
+ * import MyAppRoot from './Root'
103
+ *
104
+ * // Single project configuration
105
+ * const mySanityConfig: SanityConfig = {
106
+ * projectId: 'my-project-id',
107
+ * dataset: 'production',
92
108
  * }
93
- * ```
94
109
  *
95
- * @example Multiple resources
96
- * ```tsx
97
- * import { SanityApp } from '@sanity/sdk-react'
110
+ * // Or multiple project configurations
111
+ * const multipleConfigs: SanityConfig[] = [
112
+ * // Configuration for your main project. This will be used as the default project for hooks.
113
+ * {
114
+ * projectId: 'marketing-website-project',
115
+ * dataset: 'production',
116
+ * },
117
+ * // Configuration for a separate blog project
118
+ * {
119
+ * projectId: 'blog-project',
120
+ * dataset: 'production',
121
+ * },
122
+ * // Configuration for a separate ecommerce project
123
+ * {
124
+ * projectId: 'ecommerce-project',
125
+ * dataset: 'production',
126
+ * }
127
+ * ]
98
128
  *
99
129
  * export default function MyApp() {
100
130
  * return (
101
- * <SanityApp
102
- * resources={{
103
- * default: { projectId: 'abc123', dataset: 'production' },
104
- * 'blog-project': { projectId: 'def456', dataset: 'production' },
105
- * }}
106
- * fallback={<div>Loading…</div>}
107
- * >
131
+ * <SanityApp config={mySanityConfig} fallback={<div>Loading…</div>}>
108
132
  * <MyAppRoot />
109
133
  * </SanityApp>
110
134
  * )
@@ -115,42 +139,29 @@ export function SanityApp({
115
139
  children,
116
140
  fallback,
117
141
  config: configProp,
118
- resources: resourcesProp,
119
142
  ...props
120
143
  }: SanityAppProps): ReactElement {
121
144
  const studioWorkspace = useContext(SDKStudioContext)
122
145
 
123
- const derived = useMemo(() => {
124
- if (studioWorkspace && !configProp && !resourcesProp) {
125
- return deriveFromWorkspace(studioWorkspace)
126
- }
127
- return null
128
- }, [configProp, resourcesProp, studioWorkspace])
129
-
130
- const resolvedConfig = useMemo<SanityConfig>(() => {
131
- if (configProp) {
132
- return configProp
133
- }
134
- if (derived) return derived.config
135
- return {}
136
- }, [configProp, derived])
137
-
138
- const resolvedResources = useMemo<Record<string, DocumentResource>>(() => {
139
- if (resourcesProp) return resourcesProp
140
- if (derived) return derived.resources
141
- return {}
142
- }, [resourcesProp, derived])
146
+ // Derive config: explicit config takes precedence, then Studio context
147
+ const resolvedConfig = useMemo(() => {
148
+ if (configProp) return configProp
149
+ if (studioWorkspace) return deriveConfigFromWorkspace(studioWorkspace)
150
+ return []
151
+ }, [configProp, studioWorkspace])
143
152
 
144
153
  useEffect(() => {
145
154
  let timeout: NodeJS.Timeout | undefined
155
+ const primaryConfig = Array.isArray(resolvedConfig) ? resolvedConfig[0] : resolvedConfig
146
156
  const shouldRedirectWithoutConfig =
147
- configProp === undefined && !studioWorkspace && !resolvedConfig
157
+ configProp === undefined && !studioWorkspace && !primaryConfig
148
158
 
149
159
  if (
150
160
  !isInIframe() &&
151
161
  !isLocalUrl(window) &&
152
- (shouldRedirectWithoutConfig || (!!resolvedConfig && !isStudioConfig(resolvedConfig)))
162
+ (shouldRedirectWithoutConfig || (!!primaryConfig && !isStudioConfig(primaryConfig)))
153
163
  ) {
164
+ // If the app is not running in an iframe and is not a local url, redirect to core.
154
165
  timeout = setTimeout(() => {
155
166
  // eslint-disable-next-line no-console
156
167
  console.warn('Redirecting to core', REDIRECT_URL)
@@ -161,12 +172,7 @@ export function SanityApp({
161
172
  }, [configProp, resolvedConfig, studioWorkspace])
162
173
 
163
174
  return (
164
- <SDKProvider
165
- {...props}
166
- fallback={fallback}
167
- config={resolvedConfig}
168
- resources={resolvedResources}
169
- >
175
+ <SDKProvider {...props} fallback={fallback} config={resolvedConfig}>
170
176
  {children}
171
177
  </SDKProvider>
172
178
  )
@@ -0,0 +1,86 @@
1
+ import {AuthStateType, getIsInDashboardState} from '@sanity/sdk'
2
+ import {render, screen, waitFor} from '@testing-library/react'
3
+ import {beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
4
+
5
+ import {ResourceProvider} from '../../context/ResourceProvider'
6
+ import {useAuthState} from '../../hooks/auth/useAuthState'
7
+ import {AuthBoundary} from './AuthBoundary'
8
+
9
+ // NOTE: unlike AuthBoundary.test.tsx this file does NOT mock react-error-boundary
10
+ // — we want the REAL ErrorBoundary so we can observe whether it recovers when the
11
+ // auth store transitions back to LOGGED_IN (e.g. after a successful silent token
12
+ // refresh via ComlinkTokenRefresh -> setAuthToken).
13
+
14
+ vi.mock('@sanity/sdk', async () => {
15
+ const actual = await vi.importActual('@sanity/sdk')
16
+ return {
17
+ ...actual,
18
+ getIsInDashboardState: vi.fn(() => ({getCurrent: () => true})),
19
+ }
20
+ })
21
+
22
+ vi.mock('../../hooks/auth/useAuthState', () => ({useAuthState: vi.fn()}))
23
+ vi.mock('../../hooks/auth/useLoginUrl', () => ({
24
+ useLoginUrl: vi.fn(() => 'https://example.com/login'),
25
+ }))
26
+ vi.mock('../../hooks/auth/useVerifyOrgProjects', () => ({useVerifyOrgProjects: vi.fn(() => null)}))
27
+ vi.mock('../../hooks/auth/useLogOut', () => ({useLogOut: vi.fn(() => async () => {})}))
28
+ vi.mock('../../hooks/auth/useHandleAuthCallback', () => ({
29
+ useHandleAuthCallback: vi.fn(() => async () => {}),
30
+ }))
31
+ vi.mock('../../hooks/comlink/useWindowConnection', () => ({
32
+ useWindowConnection: vi.fn(() => ({fetch: vi.fn().mockResolvedValue({token: 'fresh-token'})})),
33
+ }))
34
+ // Avoid injecting the SanityOS bridge.js <script> during import.
35
+ vi.mock('../utils', () => ({isInIframe: vi.fn(() => false)}))
36
+
37
+ const mockUseAuthState = useAuthState as Mock
38
+
39
+ describe('AuthBoundary — recovery after silent token refresh (dashboard)', () => {
40
+ beforeEach(() => {
41
+ vi.clearAllMocks()
42
+ ;(getIsInDashboardState as Mock).mockReturnValue({getCurrent: () => true})
43
+ })
44
+
45
+ it('renders the app again once the session is re-established (LOGGED_IN)', async () => {
46
+ // 1. Session expires: a request 401s and the auth store goes to ERROR.
47
+ mockUseAuthState.mockReturnValue({
48
+ type: AuthStateType.ERROR,
49
+ error: Object.assign(new Error('Unauthorized'), {statusCode: 401}),
50
+ })
51
+
52
+ const {rerender} = render(
53
+ <ResourceProvider projectId="p" dataset="d" fallback={null}>
54
+ <AuthBoundary projectIds={['p']}>
55
+ <div>Protected Content</div>
56
+ </AuthBoundary>
57
+ </ResourceProvider>,
58
+ )
59
+
60
+ // The error boundary catches the AuthError and shows the auth-error screen.
61
+ await waitFor(() => {
62
+ expect(screen.getByText('Authentication Error')).toBeInTheDocument()
63
+ })
64
+
65
+ // 2. ComlinkTokenRefresh fetches a fresh token from the Dashboard, setAuthToken
66
+ // lands it, /users/me re-fetches and the store returns to LOGGED_IN.
67
+ mockUseAuthState.mockReturnValue({
68
+ type: AuthStateType.LOGGED_IN,
69
+ currentUser: null,
70
+ token: 'fresh-token',
71
+ })
72
+ rerender(
73
+ <ResourceProvider projectId="p" dataset="d" fallback={null}>
74
+ <AuthBoundary projectIds={['p']}>
75
+ <div>Protected Content</div>
76
+ </AuthBoundary>
77
+ </ResourceProvider>,
78
+ )
79
+
80
+ // EXPECTED (desired) behaviour: the app recovers automatically, no Retry click.
81
+ await waitFor(() => {
82
+ expect(screen.getByText('Protected Content')).toBeInTheDocument()
83
+ })
84
+ expect(screen.queryByText('Authentication Error')).not.toBeInTheDocument()
85
+ })
86
+ })