@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
@@ -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,4 @@
1
- import {
2
- DEFAULT_RESOURCE_NAME,
3
- type DocumentResource,
4
- isStudioConfig,
5
- type SanityConfig,
6
- } from '@sanity/sdk'
1
+ import {type DocumentResource, isStudioConfig, type SanityConfig} from '@sanity/sdk'
7
2
  import {type ReactElement, useContext, useEffect, useMemo} from 'react'
8
3
 
9
4
  import {SDKStudioContext, type StudioWorkspaceHandle} from '../context/SDKStudioContext'
@@ -16,41 +11,63 @@ import {isInIframe, isLocalUrl} from './utils'
16
11
  */
17
12
  export interface SanityAppProps {
18
13
  /**
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.
14
+ * One or more SanityConfig objects providing a project ID and dataset name.
15
+ * Optional when `SanityApp` is rendered inside an `SDKStudioContext` provider
16
+ * (e.g. inside Sanity Studio) — the config is derived from the workspace
17
+ * automatically.
28
18
  */
19
+ config?: SanityConfig | SanityConfig[]
20
+ /** @deprecated use the `config` prop instead. */
21
+ sanityConfigs?: SanityConfig[]
29
22
  resources?: Record<string, DocumentResource>
30
23
  children: React.ReactNode
31
24
  /* Fallback content to show when child components are suspending. Same as the `fallback` prop for React Suspense. */
32
25
  fallback: React.ReactNode
26
+ /**
27
+ * Set this to automatically fetch and register the organization's media library and canvas as named resources.
28
+ * These resources will be available to hooks as `media-library` and `canvas`.
29
+ *
30
+ * The SDK App must be running in the organization's Dashboard to use this feature.
31
+ *
32
+ * @example
33
+ * ```tsx
34
+ *
35
+ * const MyApp = () => {
36
+ * // should "just work" because of inferMediaLibraryAndCanvas.
37
+ * const {data: assets} = useDocuments({
38
+ * documentType: 'sanity.asset',
39
+ * resourceName: 'media-library',
40
+ * })
41
+ * return (
42
+ * <div>
43
+ * {assets.map((asset) => (
44
+ * <div key={asset._id}>{asset.originalFilename}</div>
45
+ * ))}
46
+ * </div>
47
+ * )
48
+ * }
49
+ * <SanityApp inferMediaLibraryAndCanvas>
50
+ * <MyApp />
51
+ * </SanityApp>
52
+ * ```
53
+ */
54
+ inferMediaLibraryAndCanvas?: boolean
33
55
  }
34
56
 
35
57
  const REDIRECT_URL = 'https://sanity.io/welcome'
36
58
 
37
59
  /**
38
- * Derive a SanityConfig and resources map from a Studio workspace handle.
60
+ * Derive a SanityConfig from a Studio workspace handle.
61
+ * Maps the workspace's projectId, dataset, and reactive auth token into
62
+ * the SDK's config shape.
39
63
  */
40
- function deriveFromWorkspace(workspace: StudioWorkspaceHandle): {
41
- config: SanityConfig
42
- resources: Record<string, DocumentResource>
43
- } {
64
+ function deriveConfigFromWorkspace(workspace: StudioWorkspaceHandle): SanityConfig {
44
65
  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},
66
+ projectId: workspace.projectId,
67
+ dataset: workspace.dataset,
68
+ studio: {
69
+ authenticated: workspace.authenticated,
70
+ auth: workspace.auth.token ? {token: workspace.auth.token} : undefined,
54
71
  },
55
72
  }
56
73
  }
@@ -62,49 +79,57 @@ function deriveFromWorkspace(workspace: StudioWorkspaceHandle): {
62
79
  * as well as application context and state which is used by the Sanity React hooks. Your application
63
80
  * must be wrapped with the SanityApp component to function properly.
64
81
  *
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.
82
+ * The `config` prop on the SanityApp component accepts either a single {@link SanityConfig} object, or an array of them.
83
+ * This allows your app to work with one or more of your organization's datasets.
67
84
  *
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.
85
+ * When rendered inside a Sanity Studio that provides `SDKStudioContext`, the `config` prop is
86
+ * optional — `SanityApp` will automatically derive `projectId`, `dataset`, and auth from the
87
+ * Studio workspace.
70
88
  *
71
- * When both `config` and `SDKStudioContext` are available, the explicit props take precedence.
89
+ * @remarks
90
+ * When passing multiple SanityConfig objects to the `config` prop, the first configuration in the array becomes the default
91
+ * configuration used by the App SDK Hooks.
92
+ *
93
+ * When both `config` and `SDKStudioContext` are available, the explicit `config` takes precedence.
72
94
  *
73
95
  * @category Components
74
96
  * @param props - Your Sanity configuration and the React children to render
75
97
  * @returns Your Sanity application, integrated with your Sanity configuration and application context
76
98
  *
77
- * @example Single project
99
+ * @example
78
100
  * ```tsx
79
- * import { SanityApp } from '@sanity/sdk-react'
101
+ * import { SanityApp, type SanityConfig } from '@sanity/sdk-react'
80
102
  *
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
- * )
103
+ * import MyAppRoot from './Root'
104
+ *
105
+ * // Single project configuration
106
+ * const mySanityConfig: SanityConfig = {
107
+ * projectId: 'my-project-id',
108
+ * dataset: 'production',
92
109
  * }
93
- * ```
94
110
  *
95
- * @example Multiple resources
96
- * ```tsx
97
- * import { SanityApp } from '@sanity/sdk-react'
111
+ * // Or multiple project configurations
112
+ * const multipleConfigs: SanityConfig[] = [
113
+ * // Configuration for your main project. This will be used as the default project for hooks.
114
+ * {
115
+ * projectId: 'marketing-website-project',
116
+ * dataset: 'production',
117
+ * },
118
+ * // Configuration for a separate blog project
119
+ * {
120
+ * projectId: 'blog-project',
121
+ * dataset: 'production',
122
+ * },
123
+ * // Configuration for a separate ecommerce project
124
+ * {
125
+ * projectId: 'ecommerce-project',
126
+ * dataset: 'production',
127
+ * }
128
+ * ]
98
129
  *
99
130
  * export default function MyApp() {
100
131
  * 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
- * >
132
+ * <SanityApp config={mySanityConfig} fallback={<div>Loading…</div>}>
108
133
  * <MyAppRoot />
109
134
  * </SanityApp>
110
135
  * )
@@ -115,42 +140,29 @@ export function SanityApp({
115
140
  children,
116
141
  fallback,
117
142
  config: configProp,
118
- resources: resourcesProp,
119
143
  ...props
120
144
  }: SanityAppProps): ReactElement {
121
145
  const studioWorkspace = useContext(SDKStudioContext)
122
146
 
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])
147
+ // Derive config: explicit config takes precedence, then Studio context
148
+ const resolvedConfig = useMemo(() => {
149
+ if (configProp) return configProp
150
+ if (studioWorkspace) return deriveConfigFromWorkspace(studioWorkspace)
151
+ return []
152
+ }, [configProp, studioWorkspace])
143
153
 
144
154
  useEffect(() => {
145
155
  let timeout: NodeJS.Timeout | undefined
156
+ const primaryConfig = Array.isArray(resolvedConfig) ? resolvedConfig[0] : resolvedConfig
146
157
  const shouldRedirectWithoutConfig =
147
- configProp === undefined && !studioWorkspace && !resolvedConfig
158
+ configProp === undefined && !studioWorkspace && !primaryConfig
148
159
 
149
160
  if (
150
161
  !isInIframe() &&
151
162
  !isLocalUrl(window) &&
152
- (shouldRedirectWithoutConfig || (!!resolvedConfig && !isStudioConfig(resolvedConfig)))
163
+ (shouldRedirectWithoutConfig || (!!primaryConfig && !isStudioConfig(primaryConfig)))
153
164
  ) {
165
+ // If the app is not running in an iframe and is not a local url, redirect to core.
154
166
  timeout = setTimeout(() => {
155
167
  // eslint-disable-next-line no-console
156
168
  console.warn('Redirecting to core', REDIRECT_URL)
@@ -161,12 +173,7 @@ export function SanityApp({
161
173
  }, [configProp, resolvedConfig, studioWorkspace])
162
174
 
163
175
  return (
164
- <SDKProvider
165
- {...props}
166
- fallback={fallback}
167
- config={resolvedConfig}
168
- resources={resolvedResources}
169
- >
176
+ <SDKProvider {...props} fallback={fallback} config={resolvedConfig}>
170
177
  {children}
171
178
  </SDKProvider>
172
179
  )
@@ -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
+ })
@@ -1,4 +1,4 @@
1
- import {AuthStateType} from '@sanity/sdk'
1
+ import {AuthStateType, type SanityConfig} from '@sanity/sdk'
2
2
  import {render, screen, waitFor} from '@testing-library/react'
3
3
  import React from 'react'
4
4
  import {type FallbackProps} from 'react-error-boundary'
@@ -110,6 +110,24 @@ describe('AuthBoundary', () => {
110
110
  const mockUseVerifyOrgProjects = vi.mocked(useVerifyOrgProjects)
111
111
  const testProjectIds = ['proj-test'] // Example project ID for tests
112
112
 
113
+ // Mock Sanity instance
114
+ const mockSanityInstance = {
115
+ instanceId: 'test-instance-id',
116
+ config: {
117
+ projectId: 'test-project',
118
+ dataset: 'test-dataset',
119
+ },
120
+ isDisposed: () => false,
121
+ dispose: () => {},
122
+ onDispose: () => () => {},
123
+ getParent: () => undefined,
124
+ createChild: (config: SanityConfig) => ({
125
+ ...mockSanityInstance,
126
+ config: {...mockSanityInstance.config, ...config},
127
+ }),
128
+ match: () => undefined,
129
+ }
130
+
113
131
  beforeEach(() => {
114
132
  vi.clearAllMocks()
115
133
  consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -148,7 +166,7 @@ describe('AuthBoundary', () => {
148
166
  isExchangingToken: false,
149
167
  })
150
168
  const {container} = render(
151
- <ResourceProvider resource={{projectId: 'p', dataset: 'd'}} fallback={null}>
169
+ <ResourceProvider projectId="p" dataset="d" fallback={null}>
152
170
  <AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
153
171
  </ResourceProvider>,
154
172
  )
@@ -165,7 +183,7 @@ describe('AuthBoundary', () => {
165
183
  token: 'exampleToken',
166
184
  })
167
185
  render(
168
- <ResourceProvider resource={{projectId: 'p', dataset: 'd'}} fallback={null}>
186
+ <ResourceProvider projectId="p" dataset="d" fallback={null}>
169
187
  <AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
170
188
  </ResourceProvider>,
171
189
  )
@@ -179,7 +197,7 @@ describe('AuthBoundary', () => {
179
197
  error: new Error('test error'),
180
198
  })
181
199
  render(
182
- <ResourceProvider resource={{projectId: 'p', dataset: 'd'}} fallback={null}>
200
+ <ResourceProvider projectId="p" dataset="d" fallback={null}>
183
201
  <AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
184
202
  </ResourceProvider>,
185
203
  )
@@ -196,7 +214,7 @@ describe('AuthBoundary', () => {
196
214
 
197
215
  it('renders children when logged in and org verification passes', () => {
198
216
  render(
199
- <ResourceProvider resource={{projectId: 'p', dataset: 'd'}} fallback={null}>
217
+ <ResourceProvider projectId="p" dataset="d" fallback={null}>
200
218
  <AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
201
219
  </ResourceProvider>,
202
220
  )
@@ -218,7 +236,7 @@ describe('AuthBoundary', () => {
218
236
 
219
237
  // Need to catch the error thrown during render. ErrorBoundary mock handles this.
220
238
  render(
221
- <ResourceProvider resource={{projectId: 'p', dataset: 'd'}} fallback={null}>
239
+ <ResourceProvider projectId="p" dataset="d" fallback={null}>
222
240
  <AuthBoundary verifyOrganization={true} projectIds={testProjectIds}>
223
241
  <div>Protected Content</div>
224
242
  </AuthBoundary>
@@ -250,7 +268,7 @@ describe('AuthBoundary', () => {
250
268
  })
251
269
 
252
270
  render(
253
- <ResourceProvider resource={{projectId: 'p', dataset: 'd'}} fallback={null}>
271
+ <ResourceProvider projectId="p" dataset="d" fallback={null}>
254
272
  <AuthBoundary verifyOrganization={false} projectIds={testProjectIds}>
255
273
  <div>Protected Content</div>
256
274
  </AuthBoundary>
@@ -275,7 +293,7 @@ describe('AuthBoundary', () => {
275
293
  mockUseVerifyOrgProjects.mockImplementation(() => null)
276
294
 
277
295
  render(
278
- <ResourceProvider resource={{projectId: 'p', dataset: 'd'}} fallback={null}>
296
+ <ResourceProvider projectId="p" dataset="d" fallback={null}>
279
297
  <AuthBoundary projectIds={testProjectIds}>
280
298
  <div>Protected Content</div>
281
299
  </AuthBoundary>
@@ -1,13 +1,16 @@
1
1
  import {CorsOriginError} from '@sanity/client'
2
- import {AuthStateType, getCorsErrorProjectId, isStudioConfig} from '@sanity/sdk'
2
+ import {AuthStateType, getCorsErrorProjectId, isImportError, isStudioConfig} from '@sanity/sdk'
3
3
  import {useEffect, useMemo} from 'react'
4
4
  import {ErrorBoundary, type FallbackProps} from 'react-error-boundary'
5
5
 
6
6
  import {ComlinkTokenRefreshProvider} from '../../context/ComlinkTokenRefresh'
7
+ import {isWorkbenchEnvironment} from '../../context/workbenchToken'
8
+ import {WorkbenchTokenRefreshProvider} from '../../context/WorkbenchTokenRefresh'
7
9
  import {useAuthState} from '../../hooks/auth/useAuthState'
8
10
  import {useLoginUrl} from '../../hooks/auth/useLoginUrl'
9
11
  import {useVerifyOrgProjects} from '../../hooks/auth/useVerifyOrgProjects'
10
12
  import {useSanityInstance} from '../../hooks/context/useSanityInstance'
13
+ import {ChunkLoadError} from '../errors/ChunkLoadError'
11
14
  import {CorsErrorComponent} from '../errors/CorsErrorComponent'
12
15
  import {isInIframe} from '../utils'
13
16
  import {AuthError} from './AuthError'
@@ -108,8 +111,24 @@ export function AuthBoundary({
108
111
  LoginErrorComponent = LoginError,
109
112
  ...props
110
113
  }: AuthBoundaryProps): React.ReactNode {
114
+ /**
115
+ * When the session is re-established (e.g. ComlinkTokenRefresh silently mints a
116
+ * fresh token via setAuthToken), the auth store returns to LOGGED_IN but the
117
+ * ErrorBoundary stays latched on its fallback until reset. Keying it on the
118
+ * recovered session lets it clear automatically
119
+ */
120
+ const authState = useAuthState()
121
+ const sessionResetKey =
122
+ authState.type === AuthStateType.LOGGED_IN ? authState.token : authState.type
123
+
111
124
  const FallbackComponent = useMemo(() => {
112
125
  return function LoginComponentWithLayoutProps(fallbackProps: FallbackProps) {
126
+ // Chunk-load errors from any lazy-loaded code beneath this boundary
127
+ // (typically the consumer's app) get the chunk-aware fallback instead
128
+ // of being misreported as auth errors.
129
+ if (isImportError(fallbackProps.error)) {
130
+ return <ChunkLoadError {...fallbackProps} />
131
+ }
113
132
  if (fallbackProps.error instanceof CorsOriginError) {
114
133
  return (
115
134
  <CorsErrorComponent
@@ -124,9 +143,11 @@ export function AuthBoundary({
124
143
 
125
144
  return (
126
145
  <ComlinkTokenRefreshProvider>
127
- <ErrorBoundary FallbackComponent={FallbackComponent}>
128
- <AuthSwitch {...props} />
129
- </ErrorBoundary>
146
+ <WorkbenchTokenRefreshProvider>
147
+ <ErrorBoundary FallbackComponent={FallbackComponent} resetKeys={[sessionResetKey]}>
148
+ <AuthSwitch {...props} />
149
+ </ErrorBoundary>
150
+ </WorkbenchTokenRefreshProvider>
130
151
  </ComlinkTokenRefreshProvider>
131
152
  )
132
153
  }
@@ -165,8 +186,9 @@ function AuthSwitch({
165
186
  const loginUrl = useLoginUrl()
166
187
 
167
188
  useEffect(() => {
168
- if (isLoggedOut && !isInIframe() && !isStudio) {
169
- // We don't want to redirect to login if we're in the Dashboard nor in studio mode
189
+ if (isLoggedOut && !isInIframe() && !isStudio && !isWorkbenchEnvironment()) {
190
+ // We don't want to redirect to login if we're in the Dashboard, in studio
191
+ // mode, or in the workbench (the OS owns the session and mints the token)
170
192
  window.location.href = loginUrl
171
193
  }
172
194
  }, [isLoggedOut, loginUrl, isStudio])