@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,15 +1,16 @@
1
1
  import {type DocumentResource, type SanityConfig, type SanityInstance} from '@sanity/sdk'
2
2
  import {act, render, screen} from '@testing-library/react'
3
- import {StrictMode, use, useContext, useEffect} from 'react'
3
+ import {StrictMode, use, useEffect} from 'react'
4
4
  import {describe, expect, it, vi} from 'vitest'
5
5
 
6
6
  import {ResourceContext} from './DefaultResourceContext'
7
- import {PerspectiveContext} from './PerspectiveContext'
7
+ import {ProjectContext} from './ProjectContext'
8
8
  import {ResourceProvider} from './ResourceProvider'
9
9
  import {SanityInstanceContext} from './SanityInstanceContext'
10
10
 
11
- const testConfig = {
12
- resource: {projectId: 'test-project', dataset: 'test-dataset'} as DocumentResource,
11
+ const testConfig: SanityConfig = {
12
+ projectId: 'test-project',
13
+ dataset: 'test-dataset',
13
14
  }
14
15
 
15
16
  function promiseWithResolvers<T = void>(): {
@@ -58,121 +59,69 @@ describe('ResourceProvider', () => {
58
59
  consoleSpy.mockRestore()
59
60
  })
60
61
 
61
- it('creates root instance when no parent context exists', async () => {
62
- const {promise, resolve} = promiseWithResolvers<SanityInstance | null>()
63
-
64
- const CaptureInstance = () => {
65
- const instance = use(SanityInstanceContext)
66
- useEffect(() => resolve(instance), [instance])
67
- return null
62
+ it('renders nothing during loading when fallback is null', async () => {
63
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
64
+ const {promise, resolve} = promiseWithResolvers()
65
+ function SuspendingChild(): React.ReactNode {
66
+ throw promise
68
67
  }
69
68
 
70
- render(
69
+ const {container} = render(
71
70
  <ResourceProvider {...testConfig} fallback={null}>
72
- <CaptureInstance />
71
+ <SuspendingChild />
73
72
  </ResourceProvider>,
74
73
  )
75
74
 
76
- await expect(promise).resolves.toMatchObject({
77
- config: {} as SanityConfig,
78
- isDisposed: expect.any(Function),
75
+ expect(container).toBeEmptyDOMElement()
76
+ act(() => {
77
+ resolve()
79
78
  })
79
+ await new Promise((r) => setTimeout(r, 0))
80
+ consoleSpy.mockRestore()
80
81
  })
81
82
 
82
- it('provides ResourceContext and PerspectiveContext at root', async () => {
83
- const captured = promiseWithResolvers<{
84
- resource: DocumentResource | undefined
85
- perspective: unknown
86
- }>()
87
-
88
- const CaptureContexts = () => {
89
- const resource = useContext(ResourceContext)
90
- const perspective = useContext(PerspectiveContext)
91
- useEffect(() => captured.resolve({resource, perspective}), [resource, perspective])
92
- return null
93
- }
94
-
95
- render(
96
- <ResourceProvider
97
- resource={{projectId: 'abc', dataset: 'prod'}}
98
- perspective="drafts"
99
- fallback={null}
100
- >
101
- <CaptureContexts />
102
- </ResourceProvider>,
103
- )
104
-
105
- const result = await captured.promise
106
- expect(result.resource).toEqual({projectId: 'abc', dataset: 'prod'})
107
- expect(result.perspective).toBe('drafts')
108
- })
109
-
110
- it('nested provider overrides resource and perspective via context', async () => {
111
- const captured = promiseWithResolvers<{
112
- instance: SanityInstance | null
113
- resource: DocumentResource | undefined
114
- perspective: unknown
115
- }>()
83
+ it('creates root instance when no parent context exists', async () => {
84
+ const {promise, resolve} = promiseWithResolvers<SanityInstance | null>()
116
85
 
117
- const CaptureAll = () => {
86
+ const CaptureInstance = () => {
118
87
  const instance = use(SanityInstanceContext)
119
- const resource = useContext(ResourceContext)
120
- const perspective = useContext(PerspectiveContext)
121
- useEffect(
122
- () => captured.resolve({instance, resource, perspective}),
123
- [instance, resource, perspective],
124
- )
88
+ useEffect(() => resolve(instance), [instance])
125
89
  return null
126
90
  }
127
91
 
128
92
  render(
129
- <ResourceProvider resource={{projectId: 'parent-proj', dataset: 'parent-ds'}} fallback={null}>
130
- <ResourceProvider
131
- resource={{projectId: 'child-proj', dataset: 'child-ds'}}
132
- perspective="drafts"
133
- fallback={null}
134
- >
135
- <CaptureAll />
136
- </ResourceProvider>
93
+ <ResourceProvider {...testConfig} fallback={null}>
94
+ <CaptureInstance />
137
95
  </ResourceProvider>,
138
96
  )
139
97
 
140
- const result = await captured.promise
141
- // Instance is the parent's (nested provider does not create a new one)
142
- expect(result.instance?.instanceId).toBeDefined()
143
- // Resource and perspective come from the nested provider's context
144
- expect(result.resource).toEqual({projectId: 'child-proj', dataset: 'child-ds'})
145
- expect(result.perspective).toBe('drafts')
98
+ await expect(promise).resolves.toMatchObject({
99
+ config: testConfig,
100
+ isDisposed: expect.any(Function),
101
+ })
146
102
  })
147
103
 
148
- it('nested provider inherits parent context when not overridden', async () => {
149
- const captured = promiseWithResolvers<{
150
- resource: DocumentResource | undefined
151
- perspective: unknown
152
- }>()
104
+ it('reuses instance when parent context exists', async () => {
105
+ const parentConfig: SanityConfig = {...testConfig, dataset: 'parent-dataset'}
106
+ const child = promiseWithResolvers<SanityInstance | null>()
153
107
 
154
- const CaptureContexts = () => {
155
- const resource = useContext(ResourceContext)
156
- const perspective = useContext(PerspectiveContext)
157
- useEffect(() => captured.resolve({resource, perspective}), [resource, perspective])
108
+ const CaptureInstance = () => {
109
+ const childInstance = use(SanityInstanceContext)
110
+ useEffect(() => child.resolve(childInstance), [childInstance])
158
111
  return null
159
112
  }
160
113
 
161
114
  render(
162
- <ResourceProvider
163
- resource={{projectId: 'parent-proj', dataset: 'parent-ds'}}
164
- perspective="drafts"
165
- fallback={null}
166
- >
167
- <ResourceProvider fallback={null}>
168
- <CaptureContexts />
115
+ <ResourceProvider {...parentConfig} fallback={null}>
116
+ <ResourceProvider {...testConfig} fallback={null}>
117
+ <CaptureInstance />
169
118
  </ResourceProvider>
170
119
  </ResourceProvider>,
171
120
  )
172
121
 
173
- const result = await captured.promise
174
- expect(result.resource).toEqual({projectId: 'parent-proj', dataset: 'parent-ds'})
175
- expect(result.perspective).toBe('drafts')
122
+ const childInstance = await child.promise
123
+ expect(childInstance?.config).toEqual(parentConfig)
124
+ expect(childInstance?.isDisposed()).toBe(false)
176
125
  })
177
126
 
178
127
  it('disposes instance when unmounted', async () => {
@@ -238,4 +187,58 @@ describe('ResourceProvider', () => {
238
187
  await new Promise((r) => setTimeout(r, 0))
239
188
  consoleSpy.mockRestore()
240
189
  })
190
+
191
+ it('inherits the missing half of a partial nested config from the parent resource', async () => {
192
+ const {promise, resolve} = promiseWithResolvers<DocumentResource | undefined>()
193
+ const CaptureResource = () => {
194
+ const resource = use(ResourceContext)
195
+ useEffect(() => resolve(resource), [resource])
196
+ return null
197
+ }
198
+
199
+ render(
200
+ <ResourceProvider projectId="parent-project" dataset="parent-dataset" fallback={null}>
201
+ {/* Partial config: only `dataset`. It should inherit `projectId` from the
202
+ parent resource and resolve to a complete DatasetResource rather than
203
+ being dropped in favor of the parent's dataset. */}
204
+ <ResourceProvider dataset="child-dataset" fallback={null}>
205
+ <CaptureResource />
206
+ </ResourceProvider>
207
+ </ResourceProvider>,
208
+ )
209
+
210
+ await expect(promise).resolves.toEqual({
211
+ projectId: 'parent-project',
212
+ dataset: 'child-dataset',
213
+ })
214
+ })
215
+
216
+ it('does not merge a nested bare projectId with the enveloping dataset', async () => {
217
+ const captured = promiseWithResolvers<{
218
+ resource: DocumentResource | undefined
219
+ projectId: string | undefined
220
+ }>()
221
+ const Capture = () => {
222
+ const resource = use(ResourceContext)
223
+ const projectId = use(ProjectContext)
224
+ useEffect(() => captured.resolve({resource, projectId}), [resource, projectId])
225
+ return null
226
+ }
227
+
228
+ render(
229
+ <ResourceProvider projectId="parent-project" dataset="parent-dataset" fallback={null}>
230
+ {/* Bare `projectId`: switches project scope. It must surface as the
231
+ project but must NOT adopt the parent's dataset — that dataset belongs
232
+ to a different project. */}
233
+ <ResourceProvider projectId="child-project" fallback={null}>
234
+ <Capture />
235
+ </ResourceProvider>
236
+ </ResourceProvider>,
237
+ )
238
+
239
+ await expect(captured.promise).resolves.toEqual({
240
+ resource: undefined,
241
+ projectId: 'child-project',
242
+ })
243
+ })
241
244
  })
@@ -1,15 +1,19 @@
1
1
  import {
2
2
  createSanityInstance,
3
+ type DatasetResource,
3
4
  type DocumentResource,
4
- type PerspectiveHandle,
5
+ isDatasetResource,
5
6
  type SanityConfig,
6
7
  type SanityInstance,
7
8
  } from '@sanity/sdk'
8
- import {Suspense, useContext, useEffect, useMemo, useRef} from 'react'
9
+ import {initTelemetry} from '@sanity/sdk/_internal'
10
+ import {useContext, useEffect, useMemo, useRef, useState} from 'react'
9
11
 
10
12
  import {ResourceContext} from './DefaultResourceContext'
11
13
  import {PerspectiveContext} from './PerspectiveContext'
14
+ import {ProjectContext} from './ProjectContext'
12
15
  import {SanityInstanceContext} from './SanityInstanceContext'
16
+ import {SanityInstanceProvider} from './SanityInstanceProvider'
13
17
 
14
18
  const DEFAULT_FALLBACK = (
15
19
  <>
@@ -18,15 +22,10 @@ const DEFAULT_FALLBACK = (
18
22
  )
19
23
 
20
24
  /**
21
- * Props for the ResourceProvider component.
22
- *
23
- * Extends `SanityConfig` (minus `defaultResource`) so new config fields are
24
- * automatically forwarded. The `resource` prop replaces `defaultResource`
25
- * with a name that better describes its role at the React layer.
26
- *
25
+ * Props for the ResourceProvider component
27
26
  * @internal
28
27
  */
29
- export interface ResourceProviderProps extends Omit<SanityConfig, 'defaultResource'> {
28
+ export interface ResourceProviderProps extends SanityConfig {
30
29
  /**
31
30
  * The document resource (project/dataset, media library, or canvas)
32
31
  * for this subtree. Hooks that don't specify an explicit resource will
@@ -46,14 +45,7 @@ export interface ResourceProviderProps extends Omit<SanityConfig, 'defaultResour
46
45
  *
47
46
  * @internal
48
47
  *
49
- * @remarks
50
- * - **Root usage** (no parent instance): creates a `SanityInstance` with the
51
- * given config and provides it via `SanityInstanceContext`.
52
- * - **Nested usage** (inside an existing provider): sets
53
- * `ResourceContext` and `PerspectiveContext` so hooks in the subtree
54
- * resolve the correct resource/perspective without creating a new instance.
55
- *
56
- * @example Root provider
48
+ * @example
57
49
  * ```tsx
58
50
  * <ResourceProvider
59
51
  * resource={{ projectId: 'your-project-id', dataset: 'production' }}
@@ -62,57 +54,54 @@ export interface ResourceProviderProps extends Omit<SanityConfig, 'defaultResour
62
54
  * <YourApp />
63
55
  * </ResourceProvider>
64
56
  * ```
65
- *
66
- * @example Nested override
67
- * ```tsx
68
- * <ResourceProvider
69
- * resource={{ projectId: 'other-project', dataset: 'staging' }}
70
- * fallback={<LoadingSpinner />}
71
- * >
72
- * <SubSection />
73
- * </ResourceProvider>
74
- * ```
75
57
  */
76
58
  export function ResourceProvider({
77
59
  children,
78
60
  fallback,
79
61
  resource,
80
- ...rest
62
+ ...config
81
63
  }: ResourceProviderProps): React.ReactNode {
82
- const parent = useContext(SanityInstanceContext)
83
- const {perspective, auth, studio} = rest
84
- const config: SanityConfig = useMemo(
85
- () => ({perspective, auth, studio}),
86
- [perspective, auth, studio],
87
- )
64
+ const parentPerspective = useContext(PerspectiveContext)
65
+ const parentResource = useContext(ResourceContext)
66
+ const parentInstance = useContext(SanityInstanceContext)
67
+ const parentProjectId = useContext(ProjectContext)
88
68
 
89
- if (parent) {
90
- return (
91
- <NestedResourceProvider resource={resource} perspective={perspective} fallback={fallback}>
92
- {children}
93
- </NestedResourceProvider>
94
- )
95
- }
69
+ const {projectId, dataset, perspective} = config
96
70
 
97
- return (
98
- <RootResourceProvider config={config} resource={resource} fallback={fallback}>
99
- {children}
100
- </RootResourceProvider>
101
- )
102
- }
71
+ const [instance] = useState<SanityInstance>(() => parentInstance ?? createSanityInstance(config))
103
72
 
104
- function RootResourceProvider({
105
- children,
106
- fallback,
107
- config,
108
- resource,
109
- }: {
110
- children: React.ReactNode
111
- fallback: React.ReactNode
112
- config: SanityConfig
113
- resource?: DocumentResource
114
- }): React.ReactNode {
115
- const instance = useMemo(() => createSanityInstance(config), [config])
73
+ const configResource: DatasetResource | undefined = useMemo(() => {
74
+ // Historically, we allowed asymmetric merging: If you provided JUST a dataset, we'd merge it with the parent projectId.
75
+ //
76
+ // This backwards-compatible merging should be removed in the next major version.
77
+ if (projectId && dataset) return {projectId, dataset}
78
+ if (dataset && parentProjectId) return {projectId: parentProjectId, dataset}
79
+ return undefined
80
+ }, [projectId, dataset, parentProjectId])
81
+
82
+ const effectiveResource = useMemo(() => {
83
+ if (resource) return resource
84
+ if (configResource) return configResource
85
+ // A projectId with no dataset historically created its own scope, so no resource is needed.
86
+ if (projectId) return undefined
87
+ return parentResource
88
+ }, [resource, configResource, projectId, parentResource])
89
+
90
+ // Historically, ResourceProviders allowed a bare `projectId` (no dataset, no resource to complete it) to be provided.
91
+ // This keeps that behavior intact for backwards compatibility, even though we prefer resources everywhere.
92
+ //
93
+ // This should be removed in the next major version.
94
+ const effectiveProjectId = useMemo(() => {
95
+ if (effectiveResource && isDatasetResource(effectiveResource)) {
96
+ return effectiveResource.projectId
97
+ }
98
+ return projectId ?? parentProjectId
99
+ }, [effectiveResource, projectId, parentProjectId])
100
+
101
+ useEffect(() => {
102
+ if (effectiveResource && isDatasetResource(effectiveResource))
103
+ initTelemetry(instance, effectiveResource.projectId)
104
+ }, [instance, effectiveResource])
116
105
 
117
106
  // Ref to hold the scheduled disposal timer.
118
107
  const disposal = useRef<{
@@ -131,47 +120,27 @@ function RootResourceProvider({
131
120
  disposal.current = {
132
121
  instance,
133
122
  timeoutId: setTimeout(() => {
134
- if (!instance.isDisposed()) {
123
+ // don't dispose the parent instance when this unmounts
124
+ if (!instance.isDisposed() && instance !== parentInstance) {
135
125
  instance.dispose()
136
126
  }
137
127
  }, 0),
138
128
  }
139
129
  }
140
- }, [instance])
130
+ }, [instance, parentInstance])
141
131
 
142
132
  return (
143
- <SanityInstanceContext.Provider value={instance}>
144
- <ResourceContext.Provider value={resource}>
145
- <PerspectiveContext.Provider value={config.perspective}>
146
- <Suspense fallback={fallback ?? DEFAULT_FALLBACK}>{children}</Suspense>
147
- </PerspectiveContext.Provider>
133
+ <SanityInstanceProvider
134
+ instance={instance}
135
+ fallback={fallback === undefined ? DEFAULT_FALLBACK : fallback}
136
+ >
137
+ <ResourceContext.Provider value={effectiveResource}>
138
+ <ProjectContext.Provider value={effectiveProjectId}>
139
+ <PerspectiveContext.Provider value={perspective ?? parentPerspective}>
140
+ {children}
141
+ </PerspectiveContext.Provider>
142
+ </ProjectContext.Provider>
148
143
  </ResourceContext.Provider>
149
- </SanityInstanceContext.Provider>
150
- )
151
- }
152
-
153
- function NestedResourceProvider({
154
- children,
155
- fallback,
156
- resource,
157
- perspective,
158
- }: {
159
- children: React.ReactNode
160
- fallback: React.ReactNode
161
- resource?: DocumentResource
162
- perspective?: PerspectiveHandle['perspective']
163
- }): React.ReactNode {
164
- const parentResource = useContext(ResourceContext)
165
- const parentPerspective = useContext(PerspectiveContext)
166
-
167
- const resolvedResource = resource ?? parentResource
168
- const resolvedPerspective = perspective ?? parentPerspective
169
-
170
- return (
171
- <ResourceContext.Provider value={resolvedResource}>
172
- <PerspectiveContext.Provider value={resolvedPerspective}>
173
- <Suspense fallback={fallback ?? DEFAULT_FALLBACK}>{children}</Suspense>
174
- </PerspectiveContext.Provider>
175
- </ResourceContext.Provider>
144
+ </SanityInstanceProvider>
176
145
  )
177
146
  }
@@ -5,7 +5,7 @@ import {beforeEach, describe, expect, it, vi} from 'vitest'
5
5
  import {SanityApp} from '../components/SanityApp'
6
6
  import {SDKStudioContext, type StudioWorkspaceHandle} from './SDKStudioContext'
7
7
 
8
- // Mock SDKProvider to capture the config and resources it receives
8
+ // Mock SDKProvider to capture the config it receives
9
9
  const mockSDKProvider = vi.hoisted(() => vi.fn())
10
10
  vi.mock('../components/SDKProvider', () => ({
11
11
  SDKProvider: mockSDKProvider.mockImplementation(({children}) => (
@@ -45,31 +45,24 @@ describe('SDKStudioContext', () => {
45
45
 
46
46
  expect(mockSDKProvider).toHaveBeenCalled()
47
47
  const receivedConfig = mockSDKProvider.mock.calls[0][0].config as SanityConfig
48
- // config carries studio auth only; no resource fields
49
48
  expect(receivedConfig).toMatchObject({
49
+ projectId: 'studio-project-id',
50
+ dataset: 'production',
50
51
  studio: {
51
52
  auth: {token: mockWorkspace.auth.token},
52
53
  },
53
54
  })
54
- expect(receivedConfig).not.toHaveProperty('defaultResource')
55
- // resource goes into the resources map, not config
56
- const receivedResources = mockSDKProvider.mock.calls[0][0].resources
57
- expect(receivedResources).toEqual({
58
- default: {projectId: 'studio-project-id', dataset: 'production'},
59
- })
60
55
  })
61
56
 
62
57
  it('explicit config takes precedence over SDKStudioContext', () => {
63
- const explicitConfig: SanityConfig = {}
64
- const explicitResources = {default: {projectId: 'explicit-project', dataset: 'staging'}}
58
+ const explicitConfig: SanityConfig = {
59
+ projectId: 'explicit-project',
60
+ dataset: 'staging',
61
+ }
65
62
 
66
63
  render(
67
64
  <SDKStudioContext.Provider value={mockWorkspace}>
68
- <SanityApp
69
- config={explicitConfig}
70
- resources={explicitResources}
71
- fallback={<div>Loading</div>}
72
- >
65
+ <SanityApp config={explicitConfig} fallback={<div>Loading</div>}>
73
66
  <div>Child</div>
74
67
  </SanityApp>
75
68
  </SDKStudioContext.Provider>,
@@ -77,31 +70,32 @@ describe('SDKStudioContext', () => {
77
70
 
78
71
  expect(mockSDKProvider).toHaveBeenCalled()
79
72
  const receivedConfig = mockSDKProvider.mock.calls[0][0].config as SanityConfig
80
- expect(receivedConfig).toEqual(explicitConfig)
73
+ expect(receivedConfig).toMatchObject({
74
+ projectId: 'explicit-project',
75
+ dataset: 'staging',
76
+ })
77
+ // Should NOT have studio config from the context
81
78
  expect(receivedConfig.studio).toBeUndefined()
82
- const receivedResources = mockSDKProvider.mock.calls[0][0].resources
83
- expect(receivedResources).toEqual(explicitResources)
84
79
  })
85
80
 
86
81
  it('SanityApp works without SDKStudioContext (standalone mode)', () => {
87
- const standaloneConfig: SanityConfig = {}
88
- const standaloneResources = {default: {projectId: 'standalone-project', dataset: 'production'}}
82
+ const standaloneConfig: SanityConfig = {
83
+ projectId: 'standalone-project',
84
+ dataset: 'production',
85
+ }
89
86
 
90
87
  render(
91
- <SanityApp
92
- config={standaloneConfig}
93
- resources={standaloneResources}
94
- fallback={<div>Loading</div>}
95
- >
88
+ <SanityApp config={standaloneConfig} fallback={<div>Loading</div>}>
96
89
  <div>Child</div>
97
90
  </SanityApp>,
98
91
  )
99
92
 
100
93
  expect(mockSDKProvider).toHaveBeenCalled()
101
94
  const receivedConfig = mockSDKProvider.mock.calls[0][0].config as SanityConfig
102
- expect(receivedConfig).toEqual(standaloneConfig)
103
- const receivedResources = mockSDKProvider.mock.calls[0][0].resources
104
- expect(receivedResources).toEqual(standaloneResources)
95
+ expect(receivedConfig).toMatchObject({
96
+ projectId: 'standalone-project',
97
+ dataset: 'production',
98
+ })
105
99
  })
106
100
 
107
101
  it('handles workspace without auth.token (older Studio)', () => {
@@ -121,11 +115,12 @@ describe('SDKStudioContext', () => {
121
115
 
122
116
  expect(mockSDKProvider).toHaveBeenCalled()
123
117
  const receivedConfig = mockSDKProvider.mock.calls[0][0].config as SanityConfig
124
- expect(receivedConfig).toMatchObject({studio: expect.any(Object)})
125
- expect(receivedConfig.studio?.auth).toBeUndefined()
126
- const receivedResources = mockSDKProvider.mock.calls[0][0].resources
127
- expect(receivedResources).toEqual({
128
- default: {projectId: 'older-studio', dataset: 'production'},
118
+ expect(receivedConfig).toMatchObject({
119
+ projectId: 'older-studio',
120
+ dataset: 'production',
129
121
  })
122
+ // studio config should be present but auth.token should be undefined
123
+ expect(receivedConfig.studio).toBeDefined()
124
+ expect(receivedConfig.studio?.auth).toBeUndefined()
130
125
  })
131
126
  })
@@ -0,0 +1,100 @@
1
+ import {createSanityInstance, type SanityInstance} from '@sanity/sdk'
2
+ import {act, render, screen} from '@testing-library/react'
3
+ import {use, useEffect} from 'react'
4
+ import {describe, expect, it, vi} from 'vitest'
5
+
6
+ import {SanityInstanceContext} from './SanityInstanceContext'
7
+ import {SanityInstanceProvider} from './SanityInstanceProvider'
8
+
9
+ function promiseWithResolvers<T = void>(): {
10
+ promise: Promise<T>
11
+ resolve: (t: T) => void
12
+ reject: (error: unknown) => void
13
+ } {
14
+ let resolve!: (t: T) => void
15
+ let reject!: (error: unknown) => void
16
+ const promise = new Promise<T>((res, rej) => {
17
+ resolve = res
18
+ reject = rej
19
+ })
20
+ return {resolve, reject, promise}
21
+ }
22
+
23
+ describe('SanityInstanceProvider', () => {
24
+ it('renders children', () => {
25
+ const instance = createSanityInstance({projectId: 'test', dataset: 'test'})
26
+
27
+ render(
28
+ <SanityInstanceProvider instance={instance} fallback={<div>Loading...</div>}>
29
+ <div data-testid="test-child">Child Component</div>
30
+ </SanityInstanceProvider>,
31
+ )
32
+
33
+ expect(screen.getByTestId('test-child')).toBeInTheDocument()
34
+ instance.dispose()
35
+ })
36
+
37
+ it('provides the given instance via context', async () => {
38
+ const instance = createSanityInstance({projectId: 'test', dataset: 'test'})
39
+ const {promise, resolve} = promiseWithResolvers<SanityInstance | null>()
40
+
41
+ const CaptureInstance = () => {
42
+ const ctx = use(SanityInstanceContext)
43
+ useEffect(() => resolve(ctx), [ctx])
44
+ return null
45
+ }
46
+
47
+ render(
48
+ <SanityInstanceProvider instance={instance} fallback={null}>
49
+ <CaptureInstance />
50
+ </SanityInstanceProvider>,
51
+ )
52
+
53
+ const provided = await promise
54
+ expect(provided).toBe(instance)
55
+ instance.dispose()
56
+ })
57
+
58
+ it('shows fallback during suspense', async () => {
59
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
60
+ const instance = createSanityInstance({projectId: 'test', dataset: 'test'})
61
+ const {promise, resolve} = promiseWithResolvers()
62
+
63
+ function SuspendingChild(): React.ReactNode {
64
+ throw promise
65
+ }
66
+
67
+ render(
68
+ <SanityInstanceProvider
69
+ instance={instance}
70
+ fallback={<div data-testid="fallback">Loading...</div>}
71
+ >
72
+ <SuspendingChild />
73
+ </SanityInstanceProvider>,
74
+ )
75
+
76
+ expect(screen.getByTestId('fallback')).toBeInTheDocument()
77
+ act(() => {
78
+ resolve()
79
+ })
80
+ await new Promise((r) => setTimeout(r, 0))
81
+ instance.dispose()
82
+ consoleSpy.mockRestore()
83
+ })
84
+
85
+ it('does not dispose the instance on unmount', async () => {
86
+ const instance = createSanityInstance({projectId: 'test', dataset: 'test'})
87
+
88
+ const {unmount} = render(
89
+ <SanityInstanceProvider instance={instance} fallback={null}>
90
+ <div />
91
+ </SanityInstanceProvider>,
92
+ )
93
+
94
+ unmount()
95
+ await new Promise((r) => setTimeout(r, 0))
96
+
97
+ expect(instance.isDisposed()).toBe(false)
98
+ instance.dispose()
99
+ })
100
+ })