@sanity/sdk-react 2.10.0 → 2.11.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 (55) hide show
  1. package/dist/index.d.ts +257 -200
  2. package/dist/index.js +368 -257
  3. package/dist/index.js.map +1 -1
  4. package/package.json +21 -24
  5. package/src/_exports/index.ts +2 -0
  6. package/src/_exports/sdk-react.ts +4 -0
  7. package/src/components/SDKProvider.test.tsx +5 -12
  8. package/src/components/SDKProvider.tsx +26 -24
  9. package/src/components/errors/CorsErrorComponent.tsx +2 -2
  10. package/src/config/handles.ts +55 -0
  11. package/src/constants.ts +5 -0
  12. package/src/context/DefaultResourceContext.ts +10 -0
  13. package/src/context/PerspectiveContext.ts +12 -0
  14. package/src/context/ResourceProvider.test.tsx +2 -2
  15. package/src/context/ResourceProvider.tsx +53 -49
  16. package/src/hooks/agent/agentActions.ts +55 -38
  17. package/src/hooks/context/useResource.test.tsx +32 -0
  18. package/src/hooks/context/useResource.ts +24 -0
  19. package/src/hooks/context/useSanityInstance.test.tsx +42 -111
  20. package/src/hooks/context/useSanityInstance.ts +28 -50
  21. package/src/hooks/dashboard/useDispatchIntent.test.ts +5 -1
  22. package/src/hooks/dashboard/useDispatchIntent.ts +3 -3
  23. package/src/hooks/dashboard/useManageFavorite.test.tsx +16 -12
  24. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +1 -5
  25. package/src/hooks/document/{useApplyDocumentActions.test.ts → useApplyDocumentActions.test.tsx} +42 -77
  26. package/src/hooks/document/useApplyDocumentActions.ts +29 -63
  27. package/src/hooks/document/useDocument.ts +5 -7
  28. package/src/hooks/document/useDocumentEvent.ts +4 -3
  29. package/src/hooks/document/useDocumentPermissions.test.tsx +58 -150
  30. package/src/hooks/document/useDocumentPermissions.ts +78 -55
  31. package/src/hooks/document/useEditDocument.test.tsx +25 -60
  32. package/src/hooks/document/useEditDocument.ts +1 -1
  33. package/src/hooks/documents/useDocuments.ts +13 -8
  34. package/src/hooks/helpers/createStateSourceHook.tsx +1 -2
  35. package/src/hooks/helpers/useNormalizedResourceOptions.test.tsx +253 -0
  36. package/src/hooks/helpers/useNormalizedResourceOptions.ts +85 -47
  37. package/src/hooks/organizations/useOrganization.test-d.ts +53 -0
  38. package/src/hooks/organizations/useOrganization.test.ts +65 -0
  39. package/src/hooks/organizations/useOrganization.ts +40 -0
  40. package/src/hooks/organizations/useOrganizations.test-d.ts +55 -0
  41. package/src/hooks/organizations/useOrganizations.test.ts +85 -0
  42. package/src/hooks/organizations/useOrganizations.ts +45 -0
  43. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +23 -9
  44. package/src/hooks/presence/usePresence.ts +4 -11
  45. package/src/hooks/preview/useDocumentPreview.tsx +4 -7
  46. package/src/hooks/projection/useDocumentProjection.ts +5 -7
  47. package/src/hooks/projects/useProject.test-d.ts +49 -0
  48. package/src/hooks/projects/useProject.ts +33 -41
  49. package/src/hooks/projects/useProjects.test-d.ts +49 -0
  50. package/src/hooks/projects/useProjects.ts +17 -23
  51. package/src/hooks/query/useQuery.ts +1 -1
  52. package/src/hooks/releases/useActiveReleases.ts +6 -6
  53. package/src/hooks/releases/usePerspective.ts +7 -12
  54. package/src/hooks/users/useUser.ts +1 -1
  55. package/src/hooks/users/useUsers.ts +1 -1
@@ -1,13 +1,15 @@
1
1
  import {
2
2
  createGroqSearchFilter,
3
- type DatasetHandle,
4
3
  type DocumentHandle,
4
+ isDatasetResource,
5
5
  type QueryOptions,
6
6
  } from '@sanity/sdk'
7
+ import {pickProperties} from '@sanity/sdk/_internal'
7
8
  import {type SortOrderingItem} from '@sanity/types'
8
9
  import {useCallback, useMemo, useState} from 'react'
9
10
 
10
- import {useSanityInstance} from '../context/useSanityInstance'
11
+ import {type ResourceHandle} from '../../config/handles'
12
+ import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
11
13
  import {useTrackHookUsage} from '../helpers/useTrackHookUsage'
12
14
  import {useQuery} from '../query/useQuery'
13
15
 
@@ -24,7 +26,7 @@ export interface DocumentsOptions<
24
26
  TDataset extends string = string,
25
27
  TProjectId extends string = string,
26
28
  >
27
- extends DatasetHandle<TDataset, TProjectId>, Pick<QueryOptions, 'perspective' | 'params'> {
29
+ extends ResourceHandle<TDataset, TProjectId>, Pick<QueryOptions, 'perspective' | 'params'> {
28
30
  /**
29
31
  * Filter documents by their `_type`. Can be a single type or an array of types.
30
32
  */
@@ -201,14 +203,14 @@ export function useDocuments<
201
203
  filter,
202
204
  orderings,
203
205
  documentType,
204
- ...options
206
+ ...rawOptions
205
207
  }: DocumentsOptions<TDocumentType, TDataset, TProjectId>): DocumentsResponse<
206
208
  TDocumentType,
207
209
  TDataset,
208
210
  TProjectId
209
211
  > {
210
212
  useTrackHookUsage('useDocuments')
211
- const instance = useSanityInstance(options)
213
+ const options = useNormalizedResourceOptions(rawOptions)
212
214
  const [limit, setLimit] = useState(batchSize)
213
215
  const documentTypes = useMemo(
214
216
  () =>
@@ -284,9 +286,12 @@ export function useDocuments<
284
286
  ...params,
285
287
  // these are passed back to the user as part of each document handle
286
288
  __handle: {
287
- projectId: options.projectId ?? instance.config.projectId,
288
- dataset: options.dataset ?? instance.config.dataset,
289
- perspective: options.perspective ?? instance.config.perspective,
289
+ // keep projectId/dataset for backward compat until v4; resource is added
290
+ // intentionally so that hook consumers can resolve the correct resource
291
+ ...(options.resource && isDatasetResource(options.resource)
292
+ ? pickProperties(options.resource, ['projectId', 'dataset'])
293
+ : {}),
294
+ ...pickProperties(options, ['perspective', 'resource']),
290
295
  },
291
296
  __types: documentTypes,
292
297
  },
@@ -19,11 +19,10 @@ export function createStateSourceHook<TParams extends unknown[], TState>(
19
19
  options: StateSourceFactory<TParams, TState> | CreateStateSourceHookOptions<TParams, TState>,
20
20
  ): (...params: TParams) => TState {
21
21
  const getState = typeof options === 'function' ? options : options.getState
22
- const getConfig = 'getConfig' in options ? options.getConfig : undefined
23
22
  const suspense = 'shouldSuspend' in options && 'suspender' in options ? options : undefined
24
23
 
25
24
  function useHook(...params: TParams) {
26
- const instance = useSanityInstance(getConfig?.(...params))
25
+ const instance = useSanityInstance()
27
26
 
28
27
  if (suspense?.suspender && suspense?.shouldSuspend?.(instance, ...params)) {
29
28
  throw suspense.suspender(instance, ...params)
@@ -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,14 +1,18 @@
1
- import {type DocumentResource} from '@sanity/sdk'
2
- import {useContext} from 'react'
1
+ import {type DocumentResource, type PerspectiveHandle} from '@sanity/sdk'
2
+ import {useContext, useMemo} from 'react'
3
3
 
4
+ import {ResourceContext} from '../../context/DefaultResourceContext'
5
+ import {PerspectiveContext} from '../../context/PerspectiveContext'
4
6
  import {ResourcesContext} from '../../context/ResourcesContext'
7
+ import {SanityInstanceContext} from '../../context/SanityInstanceContext'
8
+
9
+ type NormalizedResourceFields = 'resourceName' | 'source' | 'sourceName' | 'projectId' | 'dataset'
5
10
 
6
11
  /**
7
12
  * Adds React hook support (resourceName resolution) to core types.
8
- * This wrapper allows hooks to accept `resourceName` as a convenience,
9
- * which is then resolved to a `DocumentResource` at the React layer.
10
- * For now, we are trying to avoid resource name resolution in core --
11
- * functions having resources explicitly passed will reduce complexity.
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).
12
16
  *
13
17
  * @typeParam T - The core type to extend (must have optional `resource` field)
14
18
  * @beta
@@ -35,6 +39,8 @@ export type WithResourceNameSupport<T extends {resource?: DocumentResource}> = T
35
39
  * @typeParam T - The options type (must include optional resource field)
36
40
  * @param options - Options that may include `resourceName` and/or `resource`
37
41
  * @param resources - Map of resource names to DocumentResource (e.g. from ResourcesContext)
42
+ * @param contextResource - Resource from context (from ResourceContext)
43
+ * @param contextPerspective - Perspective from context (from PerspectiveContext)
38
44
  * @returns Normalized options with `resourceName` removed and `resource` resolved
39
45
  * @internal
40
46
  */
@@ -44,25 +50,23 @@ export function normalizeResourceOptions<
44
50
  resourceName?: string
45
51
  source?: DocumentResource
46
52
  sourceName?: string
53
+ projectId?: string
54
+ dataset?: string
55
+ perspective?: unknown
47
56
  },
48
57
  >(
49
58
  options: T,
50
59
  resources: Record<string, DocumentResource>,
51
- ): Omit<T, 'resourceName' | 'source' | 'sourceName'> {
52
- const {resourceName, sourceName, source, ...rest} = options
60
+ contextResource?: DocumentResource,
61
+ contextPerspective?: PerspectiveHandle['perspective'],
62
+ ): Omit<T, NormalizedResourceFields> {
63
+ const {resourceName, sourceName, source, projectId, dataset, ...rest} = options
53
64
 
54
65
  // Coalesce deprecated aliases to their canonical equivalents
55
66
  const effectiveResourceName = resourceName ?? sourceName
56
67
  const effectiveResource = options.resource ?? source
57
68
 
58
- if (!effectiveResourceName && !effectiveResource) {
59
- return rest as Omit<T, 'resourceName' | 'source' | 'sourceName'>
60
- }
61
-
62
- const hasNameKey = Object.hasOwn(options, 'resourceName') || Object.hasOwn(options, 'sourceName')
63
- const hasResourceKey = Object.hasOwn(options, 'resource') || Object.hasOwn(options, 'source')
64
-
65
- if (hasNameKey && hasResourceKey) {
69
+ if (effectiveResourceName && effectiveResource) {
66
70
  throw new Error(
67
71
  `Resource name ${JSON.stringify(effectiveResourceName)} and resource ${JSON.stringify(effectiveResource)} cannot be used together.`,
68
72
  )
@@ -70,53 +74,76 @@ export function normalizeResourceOptions<
70
74
 
71
75
  let resolvedResource: DocumentResource | undefined
72
76
 
77
+ // Tier (a): explicit resource object or resourceName lookup
73
78
  if (effectiveResource) {
74
79
  resolvedResource = effectiveResource
80
+ } else if (effectiveResourceName) {
81
+ if (!Object.hasOwn(resources, effectiveResourceName)) {
82
+ throw new Error(
83
+ `There's no resource named ${JSON.stringify(effectiveResourceName)} in context. Please use <ResourceProvider>.`,
84
+ )
85
+ }
86
+ resolvedResource = resources[effectiveResourceName]
75
87
  }
76
88
 
77
- if (effectiveResourceName && !Object.hasOwn(resources, effectiveResourceName)) {
78
- throw new Error(
79
- `There's no resource named ${JSON.stringify(effectiveResourceName)} in context. Please use <ResourceProvider>.`,
80
- )
89
+ // Tier (b): projectId or dataset in options → synthesize a resource
90
+ if (!resolvedResource && projectId && dataset) {
91
+ resolvedResource = {
92
+ projectId,
93
+ dataset,
94
+ }
81
95
  }
82
96
 
83
- if (effectiveResourceName && resources[effectiveResourceName]) {
84
- resolvedResource = resources[effectiveResourceName]
97
+ // Tier (c): fall back to whatever ResourceContext provides
98
+ if (!resolvedResource) {
99
+ resolvedResource = contextResource
85
100
  }
86
101
 
102
+ // Inject perspective from context when not explicitly provided in options
103
+ const resolvedPerspective = Object.hasOwn(options, 'perspective')
104
+ ? options.perspective
105
+ : contextPerspective
106
+
87
107
  return {
88
108
  ...rest,
89
- resource: resolvedResource,
109
+ ...(resolvedResource !== undefined && {resource: resolvedResource}),
110
+ ...(resolvedPerspective !== undefined && {perspective: resolvedPerspective}),
90
111
  }
91
112
  }
92
113
 
93
114
  /**
94
- * Normalizes hook options by resolving `resourceName` to a `DocumentResource`.
95
- * This hook ensures that options passed to core layer functions only contain
96
- * `resource` (never `resourceName`), preventing duplicate cache keys and maintaining
97
- * clean separation between React and core layers.
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).
98
118
  *
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 `resource` resolved
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
- * Resolution priority:
105
- * 1. If `resourceName` is provided, resolves it via `ResourcesContext` and uses that
106
- * 2. Otherwise, uses the inline `resource` if provided
107
- * 3. If neither is provided, returns options without a resource field
136
+ * Resolution priority for resource:
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
108
141
  *
109
- * @example
110
- * ```tsx
111
- * function useQuery(options: WithResourceNameSupport<QueryOptions>) {
112
- * const instance = useSanityInstance(options)
113
- * const normalized = useNormalizedOptions(options)
114
- * // normalized now has resource but never resourceName
115
- * const queryKey = getQueryKey(normalized)
116
- * }
117
- * ```
142
+ * Resolution priority for perspective:
143
+ * 1. Explicit `perspective` in options
144
+ * 2. `PerspectiveContext` value (set by `ResourceProvider`)
118
145
  *
119
- * @beta
146
+ * @internal
120
147
  */
121
148
  export function useNormalizedResourceOptions<
122
149
  T extends {
@@ -124,8 +151,19 @@ export function useNormalizedResourceOptions<
124
151
  resourceName?: string
125
152
  source?: DocumentResource
126
153
  sourceName?: string
154
+ projectId?: string
155
+ dataset?: string
156
+ perspective?: PerspectiveHandle['perspective']
127
157
  },
128
- >(options: T): Omit<T, 'resourceName' | 'source' | 'sourceName'> {
158
+ >(
159
+ options: T,
160
+ ): Omit<T, NormalizedResourceFields> & {
161
+ resource?: DocumentResource
162
+ perspective?: PerspectiveHandle['perspective']
163
+ } {
129
164
  const resources = useContext(ResourcesContext)
130
- return normalizeResourceOptions(options, resources)
165
+ const effectiveContextResource = useEffectiveContextResource()
166
+ const contextPerspective = useContext(PerspectiveContext)
167
+
168
+ return normalizeResourceOptions(options, resources, effectiveContextResource, contextPerspective)
131
169
  }
@@ -0,0 +1,53 @@
1
+ import {type Organization, type OrganizationMember} from '@sanity/sdk'
2
+ import {expectTypeOf, test} from 'vitest'
3
+
4
+ import {useOrganization} from './useOrganization'
5
+
6
+ test('useOrganization — no flags: members and features both omitted', () => {
7
+ expectTypeOf(useOrganization({organizationId: 'org_1'})).toEqualTypeOf<
8
+ Organization<false, false>
9
+ >()
10
+ })
11
+
12
+ test('useOrganization — includeMembers: true adds members to the type', () => {
13
+ expectTypeOf(useOrganization({organizationId: 'org_1', includeMembers: true})).toEqualTypeOf<
14
+ Organization<true, false>
15
+ >()
16
+ type Result = ReturnType<typeof useOrganization<true, false>>
17
+ expectTypeOf<Result['members']>().toEqualTypeOf<OrganizationMember[]>()
18
+ })
19
+
20
+ test('useOrganization — includeFeatures: true adds features to the type', () => {
21
+ expectTypeOf(useOrganization({organizationId: 'org_1', includeFeatures: true})).toEqualTypeOf<
22
+ Organization<false, true>
23
+ >()
24
+ })
25
+
26
+ test('useOrganization — both flags true → both arrays present', () => {
27
+ expectTypeOf(
28
+ useOrganization({organizationId: 'org_1', includeMembers: true, includeFeatures: true}),
29
+ ).toEqualTypeOf<Organization<true, true>>()
30
+ })
31
+
32
+ test('useOrganization — both flags false → bare base shape', () => {
33
+ expectTypeOf(
34
+ useOrganization({organizationId: 'org_1', includeMembers: false, includeFeatures: false}),
35
+ ).toEqualTypeOf<Organization<false, false>>()
36
+ type Result = ReturnType<typeof useOrganization<false, false>>
37
+ expectTypeOf<Result['id']>().toEqualTypeOf<string>()
38
+ })
39
+
40
+ test('useOrganization — rejects non-boolean flag values', () => {
41
+ // @ts-expect-error — includeMembers must be a boolean
42
+ void useOrganization({organizationId: 'org_1', includeMembers: 'yes'})
43
+ })
44
+
45
+ test('useOrganization — non-literal boolean flag makes members optional', () => {
46
+ const includeMembers = false as boolean
47
+ expectTypeOf(useOrganization({organizationId: 'org_1', includeMembers})).toEqualTypeOf<
48
+ Organization<boolean, false>
49
+ >()
50
+ type Result = ReturnType<typeof useOrganization<boolean, false>>
51
+ expectTypeOf<Result['members']>().toEqualTypeOf<OrganizationMember[] | undefined>()
52
+ expectTypeOf<Pick<Result, 'members'>>().toEqualTypeOf<{members?: OrganizationMember[]}>()
53
+ })
@@ -0,0 +1,65 @@
1
+ import {getOrganizationState, type OrganizationOptions, type SanityInstance} from '@sanity/sdk'
2
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
3
+
4
+ import {createStateSourceHook} from '../helpers/createStateSourceHook'
5
+
6
+ vi.mock('@sanity/sdk', () => ({
7
+ getOrganizationState: vi.fn(() => ({
8
+ getCurrent: vi.fn(() => undefined),
9
+ })),
10
+ resolveOrganization: vi.fn(),
11
+ }))
12
+ vi.mock('../helpers/createStateSourceHook', () => ({
13
+ createStateSourceHook: vi.fn(),
14
+ }))
15
+
16
+ describe('useOrganization', () => {
17
+ beforeEach(() => {
18
+ vi.resetModules()
19
+ vi.mock('@sanity/sdk', () => ({
20
+ getOrganizationState: vi.fn(() => ({
21
+ getCurrent: vi.fn(() => undefined),
22
+ })),
23
+ resolveOrganization: vi.fn(),
24
+ }))
25
+ vi.mock('../helpers/createStateSourceHook', () => ({
26
+ createStateSourceHook: vi.fn(),
27
+ }))
28
+ })
29
+
30
+ it('should call createStateSourceHook with correct arguments on import', async () => {
31
+ await import('./useOrganization')
32
+
33
+ expect(createStateSourceHook).toHaveBeenCalled()
34
+ expect(createStateSourceHook).toHaveBeenCalledWith(
35
+ expect.objectContaining({
36
+ getState: expect.any(Function),
37
+ shouldSuspend: expect.any(Function),
38
+ suspender: expect.any(Function),
39
+ }),
40
+ )
41
+ })
42
+
43
+ it('shouldSuspend should call getOrganizationState and getCurrent', async () => {
44
+ await import('./useOrganization')
45
+
46
+ const mockCreateStateSourceHook = createStateSourceHook as ReturnType<typeof vi.fn>
47
+ expect(mockCreateStateSourceHook.mock.calls.length).toBeGreaterThan(0)
48
+ const createStateSourceHookArgs = mockCreateStateSourceHook.mock.calls[0][0]
49
+ const shouldSuspend = createStateSourceHookArgs.shouldSuspend
50
+
51
+ const mockInstance = {} as SanityInstance
52
+ const mockOptions: OrganizationOptions = {organizationId: 'org_1'}
53
+
54
+ const result = shouldSuspend(mockInstance, mockOptions)
55
+
56
+ const mockGetOrganizationState = getOrganizationState as ReturnType<typeof vi.fn>
57
+ expect(mockGetOrganizationState).toHaveBeenCalledWith(mockInstance, mockOptions)
58
+
59
+ expect(mockGetOrganizationState.mock.results.length).toBeGreaterThan(0)
60
+ const getOrganizationStateMockResult = mockGetOrganizationState.mock.results[0].value
61
+ expect(getOrganizationStateMockResult.getCurrent).toHaveBeenCalled()
62
+
63
+ expect(result).toBe(true)
64
+ })
65
+ })