@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
@@ -0,0 +1,253 @@
1
+ import {createSanityInstance, type DocumentHandle} from '@sanity/sdk'
2
+ import {type ReactNode} from 'react'
3
+ import {describe, expect, it} from 'vitest'
4
+
5
+ import {renderHook, resources} from '../../../test/test-utils'
6
+ import {ResourceProvider} from '../../context/ResourceProvider'
7
+ import {ResourcesContext} from '../../context/ResourcesContext'
8
+ import {SanityInstanceContext} from '../../context/SanityInstanceContext'
9
+ import {useNormalizedResourceOptions} from './useNormalizedResourceOptions'
10
+
11
+ // Wrapper that sets ResourceContext via the `resource` prop (tier 3).
12
+ // Includes ResourcesContext so resourceName resolution also works in these tests.
13
+ function ResourceContextWrapper({
14
+ children,
15
+ resource,
16
+ }: {
17
+ children: ReactNode
18
+ resource: {projectId: string; dataset: string}
19
+ }) {
20
+ return (
21
+ <ResourceProvider resource={resource} fallback={null}>
22
+ <ResourcesContext.Provider value={resources}>{children}</ResourcesContext.Provider>
23
+ </ResourceProvider>
24
+ )
25
+ }
26
+
27
+ // Wrapper that provides an instance with no projectId/dataset and no ResourceContext (tier 5).
28
+ const bareInstance = createSanityInstance({})
29
+ function NoResourceWrapper({children}: {children: ReactNode}) {
30
+ return (
31
+ <SanityInstanceContext.Provider value={bareInstance}>{children}</SanityInstanceContext.Provider>
32
+ )
33
+ }
34
+
35
+ describe('useNormalizedResourceOptions', () => {
36
+ describe('tier 1 — explicit options', () => {
37
+ it('uses an explicit dataset resource object', () => {
38
+ const {result} = renderHook(() =>
39
+ useNormalizedResourceOptions({resource: {projectId: 'explicit', dataset: 'explicit-ds'}}),
40
+ )
41
+ expect(result.current.resource).toEqual({projectId: 'explicit', dataset: 'explicit-ds'})
42
+ })
43
+
44
+ it('uses an explicit media-library resource object', () => {
45
+ const {result} = renderHook(() =>
46
+ useNormalizedResourceOptions({resource: {mediaLibraryId: 'ml-123'}}),
47
+ )
48
+ expect(result.current.resource).toEqual({mediaLibraryId: 'ml-123'})
49
+ })
50
+
51
+ it('uses an explicit canvas resource object', () => {
52
+ const {result} = renderHook(() =>
53
+ useNormalizedResourceOptions({resource: {canvasId: 'canvas-123'}}),
54
+ )
55
+ expect(result.current.resource).toEqual({canvasId: 'canvas-123'})
56
+ })
57
+
58
+ it('resolves resourceName to a named dataset resource', () => {
59
+ const {result} = renderHook(() => useNormalizedResourceOptions({resourceName: 'dataset'}))
60
+ expect(result.current.resource).toEqual({
61
+ projectId: 'resource-project-id',
62
+ dataset: 'resource-dataset',
63
+ })
64
+ })
65
+
66
+ it('resolves resourceName to a named media-library resource', () => {
67
+ const {result} = renderHook(() =>
68
+ useNormalizedResourceOptions({resourceName: 'media-library'}),
69
+ )
70
+ expect(result.current.resource).toEqual({mediaLibraryId: 'media-library-id'})
71
+ })
72
+
73
+ it('resolves resourceName to a named canvas resource', () => {
74
+ const {result} = renderHook(() => useNormalizedResourceOptions({resourceName: 'canvas'}))
75
+ expect(result.current.resource).toEqual({canvasId: 'canvas-id'})
76
+ })
77
+
78
+ it('throws when resourceName is not registered', () => {
79
+ expect(() =>
80
+ renderHook(() => useNormalizedResourceOptions({resourceName: 'unknown'})),
81
+ ).toThrow(/no resource named/i)
82
+ })
83
+
84
+ it('throws when both resource and resourceName are provided', () => {
85
+ expect(() =>
86
+ renderHook(() =>
87
+ useNormalizedResourceOptions({
88
+ resource: {projectId: 'p', dataset: 'd'},
89
+ resourceName: 'dataset',
90
+ }),
91
+ ),
92
+ ).toThrow()
93
+ })
94
+
95
+ it('resolves deprecated `source` as `resource`', () => {
96
+ const {result} = renderHook(() =>
97
+ useNormalizedResourceOptions({source: {projectId: 'src', dataset: 'src-ds'}}),
98
+ )
99
+ expect(result.current.resource).toEqual({projectId: 'src', dataset: 'src-ds'})
100
+ })
101
+
102
+ it('resolves deprecated `sourceName` as `resourceName`', () => {
103
+ const {result} = renderHook(() => useNormalizedResourceOptions({sourceName: 'dataset'}))
104
+ expect(result.current.resource).toEqual({
105
+ projectId: 'resource-project-id',
106
+ dataset: 'resource-dataset',
107
+ })
108
+ })
109
+ })
110
+
111
+ describe('tier 2 — bare projectId/dataset in options', () => {
112
+ it('synthesizes a resource from projectId + dataset', () => {
113
+ const {result} = renderHook(() =>
114
+ useNormalizedResourceOptions({projectId: 'opt', dataset: 'opt-ds'}),
115
+ )
116
+ expect(result.current.resource).toEqual({projectId: 'opt', dataset: 'opt-ds'})
117
+ })
118
+
119
+ it('falls through to context when only projectId is provided (no dataset)', () => {
120
+ // Only projectId is not enough to synthesize — should fall back to context resource
121
+ const {result} = renderHook(() => useNormalizedResourceOptions({projectId: 'opt'}))
122
+ // Default test-utils: ResourceProvider projectId="test" dataset="test" → tier-3 via config synthesis
123
+ expect(result.current.resource).toEqual({projectId: 'test', dataset: 'test'})
124
+ })
125
+ })
126
+
127
+ describe('tier 3 — ResourceContext', () => {
128
+ it('uses ResourceContext set via ResourceProvider `resource` prop', () => {
129
+ const contextResource = {projectId: 'ctx-project', dataset: 'ctx-dataset'}
130
+ const {result} = renderHook(() => useNormalizedResourceOptions({}), {
131
+ wrapper: ({children}) => (
132
+ <ResourceContextWrapper resource={contextResource}>{children}</ResourceContextWrapper>
133
+ ),
134
+ })
135
+ expect(result.current.resource).toEqual(contextResource)
136
+ })
137
+
138
+ it('uses ResourceContext synthesized from ResourceProvider projectId/dataset', () => {
139
+ // ResourceProvider with projectId/dataset (no explicit resource prop) synthesizes ResourceContext
140
+ const {result} = renderHook(() => useNormalizedResourceOptions({}))
141
+ // Default test-utils: ResourceProvider projectId="test" dataset="test"
142
+ expect(result.current.resource).toEqual({projectId: 'test', dataset: 'test'})
143
+ })
144
+
145
+ it('explicit resource in options takes precedence over ResourceContext', () => {
146
+ const {result} = renderHook(
147
+ () =>
148
+ useNormalizedResourceOptions({resource: {projectId: 'explicit', dataset: 'explicit-ds'}}),
149
+ {
150
+ wrapper: ({children}) => (
151
+ <ResourceContextWrapper resource={{projectId: 'ctx-project', dataset: 'ctx-dataset'}}>
152
+ {children}
153
+ </ResourceContextWrapper>
154
+ ),
155
+ },
156
+ )
157
+ expect(result.current.resource).toEqual({projectId: 'explicit', dataset: 'explicit-ds'})
158
+ })
159
+ })
160
+
161
+ describe('tier 4 — SanityInstance config fallback', () => {
162
+ it('falls back to instance projectId/dataset when ResourceContext is not set', () => {
163
+ // Bare SanityInstanceContext with config — no ResourceProvider, so no ResourceContext
164
+ const instanceWithConfig = createSanityInstance({projectId: 'inst', dataset: 'inst-ds'})
165
+ const {result} = renderHook(() => useNormalizedResourceOptions({}), {
166
+ wrapper: ({children}) => (
167
+ <SanityInstanceContext.Provider value={instanceWithConfig}>
168
+ {children}
169
+ </SanityInstanceContext.Provider>
170
+ ),
171
+ })
172
+ expect(result.current.resource).toEqual({projectId: 'inst', dataset: 'inst-ds'})
173
+ })
174
+ })
175
+
176
+ describe('tier 5 — no resource available', () => {
177
+ it('returns no resource when neither options, context, nor instance config provide one', () => {
178
+ const {result} = renderHook(() => useNormalizedResourceOptions({}), {
179
+ wrapper: NoResourceWrapper,
180
+ })
181
+ expect(result.current).not.toHaveProperty('resource')
182
+ })
183
+ })
184
+
185
+ describe('perspective resolution', () => {
186
+ it('uses explicit perspective from options', () => {
187
+ const {result} = renderHook(() => useNormalizedResourceOptions({perspective: 'published'}))
188
+ expect(result.current.perspective).toBe('published')
189
+ })
190
+
191
+ it('falls back to PerspectiveContext when no perspective in options', () => {
192
+ const {result} = renderHook(() => useNormalizedResourceOptions({}), {
193
+ wrapper: ({children}) => (
194
+ <ResourceProvider perspective="previewDrafts" fallback={null}>
195
+ {children}
196
+ </ResourceProvider>
197
+ ),
198
+ })
199
+ expect(result.current.perspective).toBe('previewDrafts')
200
+ })
201
+
202
+ it('explicit perspective overrides PerspectiveContext', () => {
203
+ const {result} = renderHook(() => useNormalizedResourceOptions({perspective: 'published'}), {
204
+ wrapper: ({children}) => (
205
+ <ResourceProvider perspective="previewDrafts" fallback={null}>
206
+ {children}
207
+ </ResourceProvider>
208
+ ),
209
+ })
210
+ expect(result.current.perspective).toBe('published')
211
+ })
212
+
213
+ it('omits perspective from result when not set', () => {
214
+ const {result} = renderHook(() => useNormalizedResourceOptions({}), {
215
+ wrapper: NoResourceWrapper,
216
+ })
217
+ expect(result.current).not.toHaveProperty('perspective')
218
+ })
219
+ })
220
+
221
+ describe('field stripping', () => {
222
+ it('strips resourceName from the result', () => {
223
+ const {result} = renderHook(() => useNormalizedResourceOptions({resourceName: 'dataset'}))
224
+ expect(result.current).not.toHaveProperty('resourceName')
225
+ })
226
+
227
+ it('strips projectId and dataset from the result when synthesized into resource', () => {
228
+ const {result} = renderHook(() =>
229
+ useNormalizedResourceOptions({projectId: 'p', dataset: 'd'}),
230
+ )
231
+ expect(result.current).not.toHaveProperty('projectId')
232
+ expect(result.current).not.toHaveProperty('dataset')
233
+ })
234
+
235
+ it('strips deprecated source from the result', () => {
236
+ const {result} = renderHook(() =>
237
+ useNormalizedResourceOptions({source: {projectId: 'src', dataset: 'src-ds'}}),
238
+ )
239
+ expect(result.current).not.toHaveProperty('source')
240
+ })
241
+
242
+ it('strips deprecated sourceName from the result', () => {
243
+ const {result} = renderHook(() => useNormalizedResourceOptions({sourceName: 'dataset'}))
244
+ expect(result.current).not.toHaveProperty('sourceName')
245
+ })
246
+
247
+ it('preserves unrelated fields', () => {
248
+ const opts: DocumentHandle = {documentId: 'doc-1', documentType: 'article'}
249
+ const {result} = renderHook(() => useNormalizedResourceOptions(opts))
250
+ expect(result.current).toMatchObject({documentId: 'doc-1', documentType: 'article'})
251
+ })
252
+ })
253
+ })
@@ -1,44 +1,57 @@
1
1
  import {type DocumentResource, type PerspectiveHandle} from '@sanity/sdk'
2
- import {useContext} from 'react'
2
+ import {useContext, useMemo} from 'react'
3
3
 
4
4
  import {ResourceContext} from '../../context/DefaultResourceContext'
5
5
  import {PerspectiveContext} from '../../context/PerspectiveContext'
6
6
  import {ResourcesContext} from '../../context/ResourcesContext'
7
+ import {SanityInstanceContext} from '../../context/SanityInstanceContext'
8
+
9
+ type NormalizedResourceFields = 'resourceName' | 'source' | 'sourceName' | 'projectId' | 'dataset'
7
10
 
8
11
  /**
9
- * You should generally prefer to use the React-layer handle types (ResourceHandle, DocumentHandle) from '\@sanity/sdk-react' instead.
10
- * This type is useful for non-handles (like document actions) that we still want to resolve resources for.
11
12
  * Adds React hook support (resourceName resolution) to core types.
12
- * @internal
13
+ * Prefer using the React-layer handle types (ResourceHandle, DocumentHandle)
14
+ * from `@sanity/sdk-react` — this wrapper is kept for cases where overloads
15
+ * don't fit (e.g. non-handle options objects).
16
+ *
17
+ * @typeParam T - The core type to extend (must have optional `resource` field)
18
+ * @beta
13
19
  */
14
- export type WithResourceNameSupport<T> = Omit<T, 'resource'> & {
15
- resource?: DocumentResource
20
+ export type WithResourceNameSupport<T extends {resource?: DocumentResource}> = T & {
16
21
  /**
17
22
  * Optional name of a resource to resolve from context.
18
23
  * If provided, will be resolved to a `DocumentResource` via `ResourcesContext`.
19
24
  * @beta
20
25
  */
21
26
  resourceName?: string
27
+ /**
28
+ * @deprecated Use `resourceName` instead.
29
+ * @beta
30
+ */
31
+ sourceName?: string
22
32
  }
23
33
 
24
34
  /**
25
35
  * Pure function that normalizes options by resolving `resourceName` to a `DocumentResource`
26
- * using the provided resources map, and injecting defaults from context when not provided.
27
- * Use this when options are only available at call time (e.g. inside a callback)
28
- * and you cannot call the {@link useNormalizedResourceOptions} hook.
36
+ * using the provided resources map. Use this when options are only available at call time
37
+ * (e.g. inside a callback) and you cannot call the {@link useNormalizedResourceOptions} hook.
29
38
  *
30
39
  * @typeParam T - The options type (must include optional resource field)
31
40
  * @param options - Options that may include `resourceName` and/or `resource`
32
41
  * @param resources - Map of resource names to DocumentResource (e.g. from ResourcesContext)
33
- * @param contextResource - Resource from context (injected by ResourceProvider)
34
- * @param contextPerspective - Perspective from context (injected by ResourceProvider)
35
- * @returns Normalized options with `resourceName` removed and defaults injected
42
+ * @param contextResource - Resource from context (from ResourceContext)
43
+ * @param contextPerspective - Perspective from context (from PerspectiveContext)
44
+ * @returns Normalized options with `resourceName` removed and `resource` resolved
36
45
  * @internal
37
46
  */
38
47
  export function normalizeResourceOptions<
39
48
  T extends {
40
49
  resource?: DocumentResource
41
50
  resourceName?: string
51
+ source?: DocumentResource
52
+ sourceName?: string
53
+ projectId?: string
54
+ dataset?: string
42
55
  perspective?: unknown
43
56
  },
44
57
  >(
@@ -46,70 +59,89 @@ export function normalizeResourceOptions<
46
59
  resources: Record<string, DocumentResource>,
47
60
  contextResource?: DocumentResource,
48
61
  contextPerspective?: PerspectiveHandle['perspective'],
49
- ): Omit<T, 'resourceName' | 'resource'> & {resource: DocumentResource} {
50
- const {resourceName, ...rest} = options
62
+ ): Omit<T, NormalizedResourceFields> {
63
+ const {resourceName, sourceName, source, projectId, dataset, ...rest} = options
64
+
65
+ // Coalesce deprecated aliases to their canonical equivalents
66
+ const effectiveResourceName = resourceName ?? sourceName
67
+ const effectiveResource = options.resource ?? source
51
68
 
52
- if (resourceName && Object.hasOwn(options, 'resource')) {
69
+ if (effectiveResourceName && effectiveResource) {
53
70
  throw new Error(
54
- `Resource name ${JSON.stringify(resourceName)} and resource ${JSON.stringify(options.resource)} cannot be used together.`,
71
+ `Resource name ${JSON.stringify(effectiveResourceName)} and resource ${JSON.stringify(effectiveResource)} cannot be used together.`,
55
72
  )
56
73
  }
57
74
 
58
- let resolvedResource: DocumentResource | undefined = options.resource
75
+ let resolvedResource: DocumentResource | undefined
59
76
 
60
- if (!resolvedResource && resourceName) {
61
- if (!Object.hasOwn(resources, resourceName)) {
77
+ // Tier (a): explicit resource object or resourceName lookup
78
+ if (effectiveResource) {
79
+ resolvedResource = effectiveResource
80
+ } else if (effectiveResourceName) {
81
+ if (!Object.hasOwn(resources, effectiveResourceName)) {
62
82
  throw new Error(
63
- `There's no resource named ${JSON.stringify(resourceName)} in context. ` +
64
- 'Register it via the resources prop on <SanityApp>.',
83
+ `There's no resource named ${JSON.stringify(effectiveResourceName)} in context. Please use <ResourceProvider>.`,
65
84
  )
66
85
  }
67
- resolvedResource = resources[resourceName]
86
+ resolvedResource = resources[effectiveResourceName]
68
87
  }
69
88
 
70
- if (!resolvedResource) {
71
- resolvedResource = contextResource
89
+ // Tier (b): projectId or dataset in options → synthesize a resource
90
+ if (!resolvedResource && projectId && dataset) {
91
+ resolvedResource = {
92
+ projectId,
93
+ dataset,
94
+ }
72
95
  }
73
96
 
74
- if (resolvedResource === undefined) {
75
- throw new Error(
76
- 'A resource is required. Provide `resource`, `resourceName`, or ensure a default resource is available from context (e.g. via <ResourceProvider> or <SanityApp>).',
77
- )
97
+ // Tier (c): fall back to whatever ResourceContext provides
98
+ if (!resolvedResource) {
99
+ resolvedResource = contextResource
78
100
  }
79
101
 
102
+ // Inject perspective from context when not explicitly provided in options
80
103
  const resolvedPerspective = Object.hasOwn(options, 'perspective')
81
104
  ? options.perspective
82
105
  : contextPerspective
83
106
 
84
107
  return {
85
108
  ...rest,
86
- resource: resolvedResource,
109
+ ...(resolvedResource !== undefined && {resource: resolvedResource}),
87
110
  ...(resolvedPerspective !== undefined && {perspective: resolvedPerspective}),
88
- } as Omit<T, 'resourceName' | 'resource'> & {resource: DocumentResource}
111
+ }
89
112
  }
90
113
 
91
114
  /**
92
- * Normalizes hook options by resolving `resourceName` to a `DocumentResource`
93
- * and injecting resource/perspective from context.
115
+ * Returns the effective context resource: the `ResourceContext` value if set,
116
+ * otherwise a resource synthesized from the current `SanityInstance` config
117
+ * (tier-d fallback — returns `undefined` for studio-style configs with no project).
94
118
  *
95
- * This hook ensures that options passed to core layer functions contain
96
- * the correct `resource` and `perspective` values, maintaining clean
97
- * separation between React and core layers.
98
- *
99
- * @typeParam T - The options type (must include optional resource field)
100
- * @param options - Hook options that may include `resourceName` and/or `resource`
101
- * @returns Normalized options with `resourceName` removed and defaults injected
119
+ * @internal
120
+ */
121
+ export function useEffectiveContextResource(): DocumentResource | undefined {
122
+ const contextResource = useContext(ResourceContext)
123
+ const instance = useContext(SanityInstanceContext)
124
+ const {projectId, dataset} = instance?.config ?? {}
125
+
126
+ return useMemo(() => {
127
+ if (contextResource) return contextResource
128
+ if (projectId && dataset) return {projectId, dataset}
129
+ return undefined
130
+ }, [contextResource, projectId, dataset])
131
+ }
132
+
133
+ /**
134
+ * Normalizes hook options by resolving `resourceName` to a `DocumentResource`.
102
135
  *
103
- * @remarks
104
136
  * Resolution priority for resource:
105
- * 1. If both `resourceName` and `resource` are provided, throws an error
106
- * 2. If `resource` is provided, uses it directly
107
- * 3. If `resourceName` is provided, resolves it via `ResourcesContext`
108
- * 4. If neither is provided, injects the value from `ResourceContext`
137
+ * 1. Explicit `resource` or `resourceName` in options
138
+ * 2. Bare `projectId`/`dataset` pair in options → synthesized into a resource
139
+ * 3. `ResourceContext` value (set by `ResourceProvider` / `SDKProvider`)
140
+ * 4. Current `SanityInstance` config — falls back to `undefined` for studio configs
109
141
  *
110
142
  * Resolution priority for perspective:
111
- * 1. If `perspective` is explicitly provided in options, uses it
112
- * 2. Otherwise, injects the value from `PerspectiveContext`
143
+ * 1. Explicit `perspective` in options
144
+ * 2. `PerspectiveContext` value (set by `ResourceProvider`)
113
145
  *
114
146
  * @internal
115
147
  */
@@ -117,11 +149,21 @@ export function useNormalizedResourceOptions<
117
149
  T extends {
118
150
  resource?: DocumentResource
119
151
  resourceName?: string
120
- perspective?: unknown
152
+ source?: DocumentResource
153
+ sourceName?: string
154
+ projectId?: string
155
+ dataset?: string
156
+ perspective?: PerspectiveHandle['perspective']
121
157
  },
122
- >(options: T): Omit<T, 'resourceName' | 'resource'> & {resource: DocumentResource} {
158
+ >(
159
+ options: T,
160
+ ): Omit<T, NormalizedResourceFields> & {
161
+ resource?: DocumentResource
162
+ perspective?: PerspectiveHandle['perspective']
163
+ } {
123
164
  const resources = useContext(ResourcesContext)
124
- const contextResource = useContext(ResourceContext)
165
+ const effectiveContextResource = useEffectiveContextResource()
125
166
  const contextPerspective = useContext(PerspectiveContext)
126
- return normalizeResourceOptions(options, resources, contextResource, contextPerspective)
167
+
168
+ return normalizeResourceOptions(options, resources, effectiveContextResource, contextPerspective)
127
169
  }
@@ -0,0 +1,59 @@
1
+ import {type DocumentResource} from '@sanity/sdk'
2
+ import {renderHook} from '@testing-library/react'
3
+ import {type ReactNode} from 'react'
4
+ import {describe, expect, it} from 'vitest'
5
+
6
+ import {ResourceContext} from '../../context/DefaultResourceContext'
7
+ import {ProjectContext} from '../../context/ProjectContext'
8
+ import {useResolvedProjectId} from './useResolvedProjectId'
9
+
10
+ const datasetResource: DocumentResource = {projectId: 'resource-project', dataset: 'production'}
11
+
12
+ describe('useResolvedProjectId', () => {
13
+ it('prefers an explicit projectId on the options', () => {
14
+ const {result} = renderHook(() => useResolvedProjectId({projectId: 'option-project'}), {
15
+ wrapper: ({children}: {children: ReactNode}) => (
16
+ <ResourceContext.Provider value={datasetResource}>
17
+ <ProjectContext.Provider value="context-project">{children}</ProjectContext.Provider>
18
+ </ResourceContext.Provider>
19
+ ),
20
+ })
21
+ expect(result.current).toBe('option-project')
22
+ })
23
+
24
+ it('falls back to the ambient project scope (ProjectContext) over the resource', () => {
25
+ const {result} = renderHook(() => useResolvedProjectId(), {
26
+ wrapper: ({children}: {children: ReactNode}) => (
27
+ <ResourceContext.Provider value={datasetResource}>
28
+ <ProjectContext.Provider value="context-project">{children}</ProjectContext.Provider>
29
+ </ResourceContext.Provider>
30
+ ),
31
+ })
32
+ expect(result.current).toBe('context-project')
33
+ })
34
+
35
+ it('falls back to the resolved resource projectId', () => {
36
+ const {result} = renderHook(() => useResolvedProjectId(), {
37
+ wrapper: ({children}: {children: ReactNode}) => (
38
+ <ResourceContext.Provider value={datasetResource}>{children}</ResourceContext.Provider>
39
+ ),
40
+ })
41
+ expect(result.current).toBe('resource-project')
42
+ })
43
+
44
+ it('ignores a non-dataset resource (e.g. media library)', () => {
45
+ const {result} = renderHook(() => useResolvedProjectId(), {
46
+ wrapper: ({children}: {children: ReactNode}) => (
47
+ <ResourceContext.Provider value={{mediaLibraryId: 'ml-id'}}>
48
+ {children}
49
+ </ResourceContext.Provider>
50
+ ),
51
+ })
52
+ expect(result.current).toBeUndefined()
53
+ })
54
+
55
+ it('returns undefined when nothing resolves', () => {
56
+ const {result} = renderHook(() => useResolvedProjectId())
57
+ expect(result.current).toBeUndefined()
58
+ })
59
+ })
@@ -0,0 +1,35 @@
1
+ import {type DocumentResource, isDatasetResource} from '@sanity/sdk'
2
+ import {useContext} from 'react'
3
+
4
+ import {ProjectContext} from '../../context/ProjectContext'
5
+ import {useNormalizedResourceOptions} from './useNormalizedResourceOptions'
6
+
7
+ /**
8
+ * Resolves the effective `projectId` for project-scoped hooks (`useProject`,
9
+ * `useDatasets`, `useUsers`).
10
+ *
11
+ * Precedence:
12
+ * 1. an explicit `projectId` on the options
13
+ * 2. the ambient project scope (`ProjectContext`, e.g. a dataset-less
14
+ * `<ResourceProvider projectId="…">`)
15
+ * 3. the resolved resource's projectId (`ResourceProvider`/`SDKProvider`)
16
+ *
17
+ * Returns `undefined` when none apply, letting callers fall back to core's
18
+ * `instance.config.projectId`.
19
+ *
20
+ * @internal
21
+ */
22
+ export function useResolvedProjectId(options?: {
23
+ projectId?: string
24
+ dataset?: string
25
+ resource?: DocumentResource
26
+ resourceName?: string
27
+ }): string | undefined {
28
+ const {resource} = useNormalizedResourceOptions(options ?? {})
29
+ const contextProjectId = useContext(ProjectContext)
30
+ return (
31
+ options?.projectId ??
32
+ contextProjectId ??
33
+ (resource && isDatasetResource(resource) ? resource.projectId : undefined)
34
+ )
35
+ }
@@ -0,0 +1,37 @@
1
+ import {type SanityInstance} from '@sanity/sdk'
2
+ import {trackHookMounted} from '@sanity/sdk/_internal'
3
+ import {useRef} from 'react'
4
+
5
+ import {useSanityInstance} from '../context/useSanityInstance'
6
+
7
+ /**
8
+ * Tracks the first usage of a named hook per SDK session.
9
+ * If the telemetry manager hasn't initialized yet, the hook
10
+ * name is buffered and flushed when it becomes available.
11
+ *
12
+ * Uses a ref to ensure the tracking call only happens once per
13
+ * component mount, avoiding repeated WeakMap lookups on re-renders.
14
+ *
15
+ * Call at the top of any public hook whose adoption we want to measure.
16
+ *
17
+ * @internal
18
+ */
19
+ export function useTrackHookUsage(hookName: string): void {
20
+ const instance = useSanityInstance()
21
+ const tracked = useRef<true | null>(null)
22
+ if (tracked.current === null) {
23
+ tracked.current = true
24
+ trackHookMounted(instance, hookName)
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Non-hook variant for tracking hook usage when an instance is already
30
+ * available (avoids an extra `useSanityInstance` call in hooks that
31
+ * already have the instance).
32
+ *
33
+ * @internal
34
+ */
35
+ export function trackHookUsage(instance: SanityInstance, hookName: string): void {
36
+ trackHookMounted(instance, hookName)
37
+ }
@@ -0,0 +1,19 @@
1
+ import {type Installation, type InstallationActiveConfig} from '@sanity/sdk'
2
+ import {expectTypeOf, test} from 'vitest'
3
+
4
+ import {type FetcherHookResult} from '../helpers/createFetcherHook'
5
+ import {useInstallation} from './useInstallation'
6
+
7
+ test('useInstallation — no include: the base installation', () => {
8
+ const result = useInstallation('inst_1')
9
+ expectTypeOf(result).toEqualTypeOf<FetcherHookResult<Installation<never>>>()
10
+ expectTypeOf<
11
+ Extract<keyof typeof result.data, 'activeConfig' | 'access' | 'interfaces'>
12
+ >().toEqualTypeOf<never>()
13
+ })
14
+
15
+ test('useInstallation — activeConfig include adds the config field', () => {
16
+ const result = useInstallation('inst_1', {include: ['activeConfig']})
17
+ expectTypeOf(result.data).toEqualTypeOf<Installation<'activeConfig'>>()
18
+ expectTypeOf(result.data.activeConfig).toEqualTypeOf<InstallationActiveConfig | null>()
19
+ })
@@ -0,0 +1,22 @@
1
+ import {type Installation, installation, type InstallationInclude} from '@sanity/sdk'
2
+
3
+ import {createFetcherHook, type FetcherHookResult} from '../helpers/createFetcherHook'
4
+
5
+ /**
6
+ * Returns a single installation by id.
7
+ *
8
+ * The hook suspends until the first fetch succeeds, so `data` is always present.
9
+ * The `include` tokens you pass shape `data`: each requested token adds its
10
+ * field, and omitted ones are absent from the type.
11
+ *
12
+ * @public
13
+ * @param installationId - The installation id.
14
+ * @param options - Optional `include` list to expand related resources.
15
+ * @returns The result envelope `{data, isFetching, error, refetch}`.
16
+ */
17
+ export const useInstallation = createFetcherHook(installation) as <
18
+ Include extends InstallationInclude = never,
19
+ >(
20
+ installationId: string,
21
+ options?: {include?: Include[]},
22
+ ) => FetcherHookResult<Installation<Include>>