@sanity/sdk-react 2.9.0 → 2.11.0

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 (72) hide show
  1. package/dist/index.d.ts +338 -215
  2. package/dist/index.js +564 -342
  3. package/dist/index.js.map +1 -1
  4. package/package.json +9 -14
  5. package/src/_exports/index.ts +2 -0
  6. package/src/_exports/sdk-react.ts +8 -0
  7. package/src/components/SDKProvider.test.tsx +5 -12
  8. package/src/components/SDKProvider.tsx +58 -28
  9. package/src/components/SanityApp.tsx +2 -2
  10. package/src/components/auth/AuthBoundary.tsx +8 -1
  11. package/src/components/auth/DashboardAccessRequest.tsx +37 -0
  12. package/src/components/auth/LoginError.test.tsx +191 -5
  13. package/src/components/auth/LoginError.tsx +100 -56
  14. package/src/components/errors/ChunkLoadError.test.tsx +59 -0
  15. package/src/components/errors/ChunkLoadError.tsx +56 -0
  16. package/src/components/errors/chunkReloadStorage.ts +57 -0
  17. package/src/config/handles.ts +55 -0
  18. package/src/constants.ts +5 -0
  19. package/src/context/DefaultResourceContext.ts +10 -0
  20. package/src/context/PerspectiveContext.ts +12 -0
  21. package/src/context/ResourceProvider.test.tsx +2 -2
  22. package/src/context/ResourceProvider.tsx +56 -51
  23. package/src/context/ResourcesContext.tsx +7 -0
  24. package/src/context/SanityInstanceProvider.test.tsx +100 -0
  25. package/src/context/SanityInstanceProvider.tsx +71 -0
  26. package/src/hooks/agent/agentActions.ts +55 -38
  27. package/src/hooks/auth/useVerifyOrgProjects.tsx +13 -6
  28. package/src/hooks/context/useResource.test.tsx +32 -0
  29. package/src/hooks/context/useResource.ts +24 -0
  30. package/src/hooks/context/useSanityInstance.test.tsx +42 -111
  31. package/src/hooks/context/useSanityInstance.ts +28 -50
  32. package/src/hooks/dashboard/useDispatchIntent.test.ts +11 -7
  33. package/src/hooks/dashboard/useDispatchIntent.ts +7 -7
  34. package/src/hooks/dashboard/useManageFavorite.test.tsx +16 -12
  35. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.test.ts +15 -15
  36. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +13 -17
  37. package/src/hooks/document/{useApplyDocumentActions.test.ts → useApplyDocumentActions.test.tsx} +46 -81
  38. package/src/hooks/document/useApplyDocumentActions.ts +33 -67
  39. package/src/hooks/document/useDocument.ts +4 -6
  40. package/src/hooks/document/useDocumentEvent.ts +8 -7
  41. package/src/hooks/document/useDocumentPermissions.test.tsx +60 -152
  42. package/src/hooks/document/useDocumentPermissions.ts +78 -55
  43. package/src/hooks/document/useDocumentSyncStatus.ts +2 -2
  44. package/src/hooks/document/useEditDocument.test.tsx +25 -60
  45. package/src/hooks/document/useEditDocument.ts +3 -3
  46. package/src/hooks/documents/useDocuments.ts +19 -11
  47. package/src/hooks/helpers/createStateSourceHook.tsx +1 -2
  48. package/src/hooks/helpers/useNormalizedResourceOptions.test.tsx +253 -0
  49. package/src/hooks/helpers/useNormalizedResourceOptions.ts +169 -0
  50. package/src/hooks/helpers/useTrackHookUsage.ts +2 -2
  51. package/src/hooks/organizations/useOrganization.test-d.ts +53 -0
  52. package/src/hooks/organizations/useOrganization.test.ts +65 -0
  53. package/src/hooks/organizations/useOrganization.ts +40 -0
  54. package/src/hooks/organizations/useOrganizations.test-d.ts +55 -0
  55. package/src/hooks/organizations/useOrganizations.test.ts +85 -0
  56. package/src/hooks/organizations/useOrganizations.ts +45 -0
  57. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +29 -14
  58. package/src/hooks/presence/usePresence.test.tsx +56 -9
  59. package/src/hooks/presence/usePresence.ts +16 -4
  60. package/src/hooks/preview/useDocumentPreview.tsx +8 -10
  61. package/src/hooks/projection/useDocumentProjection.ts +7 -9
  62. package/src/hooks/projects/useProject.test-d.ts +49 -0
  63. package/src/hooks/projects/useProject.ts +33 -41
  64. package/src/hooks/projects/useProjects.test-d.ts +49 -0
  65. package/src/hooks/projects/useProjects.ts +17 -23
  66. package/src/hooks/query/useQuery.ts +11 -10
  67. package/src/hooks/releases/useActiveReleases.ts +14 -14
  68. package/src/hooks/releases/usePerspective.ts +11 -16
  69. package/src/hooks/users/useUser.ts +1 -1
  70. package/src/hooks/users/useUsers.ts +1 -1
  71. package/src/context/SourcesContext.tsx +0 -7
  72. package/src/hooks/helpers/useNormalizedSourceOptions.ts +0 -107
@@ -1,14 +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
- import {pick} from 'lodash-es'
9
- import {useCallback, useEffect, useMemo, useState} from 'react'
9
+ import {useCallback, useMemo, useState} from 'react'
10
10
 
11
- import {useSanityInstance} from '../context/useSanityInstance'
11
+ import {type ResourceHandle} from '../../config/handles'
12
+ import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
12
13
  import {useTrackHookUsage} from '../helpers/useTrackHookUsage'
13
14
  import {useQuery} from '../query/useQuery'
14
15
 
@@ -25,7 +26,7 @@ export interface DocumentsOptions<
25
26
  TDataset extends string = string,
26
27
  TProjectId extends string = string,
27
28
  >
28
- extends DatasetHandle<TDataset, TProjectId>, Pick<QueryOptions, 'perspective' | 'params'> {
29
+ extends ResourceHandle<TDataset, TProjectId>, Pick<QueryOptions, 'perspective' | 'params'> {
29
30
  /**
30
31
  * Filter documents by their `_type`. Can be a single type or an array of types.
31
32
  */
@@ -202,14 +203,14 @@ export function useDocuments<
202
203
  filter,
203
204
  orderings,
204
205
  documentType,
205
- ...options
206
+ ...rawOptions
206
207
  }: DocumentsOptions<TDocumentType, TDataset, TProjectId>): DocumentsResponse<
207
208
  TDocumentType,
208
209
  TDataset,
209
210
  TProjectId
210
211
  > {
211
212
  useTrackHookUsage('useDocuments')
212
- const instance = useSanityInstance(options)
213
+ const options = useNormalizedResourceOptions(rawOptions)
213
214
  const [limit, setLimit] = useState(batchSize)
214
215
  const documentTypes = useMemo(
215
216
  () =>
@@ -230,9 +231,11 @@ export function useDocuments<
230
231
  types: documentTypes,
231
232
  ...options,
232
233
  })
233
- useEffect(() => {
234
+ const [prevKey, setPrevKey] = useState(key)
235
+ if (prevKey !== key) {
236
+ setPrevKey(key)
234
237
  setLimit(batchSize)
235
- }, [key, batchSize])
238
+ }
236
239
 
237
240
  const filterClause = useMemo(() => {
238
241
  const conditions: string[] = []
@@ -281,9 +284,14 @@ export function useDocuments<
281
284
  query: `{"count":${countQuery},"data":${dataQuery}}`,
282
285
  params: {
283
286
  ...params,
287
+ // these are passed back to the user as part of each document handle
284
288
  __handle: {
285
- ...pick(instance.config, 'projectId', 'dataset', 'perspective'),
286
- ...pick(options, 'projectId', 'dataset', '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']),
287
295
  },
288
296
  __types: documentTypes,
289
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
+ })
@@ -0,0 +1,169 @@
1
+ import {type DocumentResource, type PerspectiveHandle} from '@sanity/sdk'
2
+ import {useContext, useMemo} from 'react'
3
+
4
+ import {ResourceContext} from '../../context/DefaultResourceContext'
5
+ import {PerspectiveContext} from '../../context/PerspectiveContext'
6
+ import {ResourcesContext} from '../../context/ResourcesContext'
7
+ import {SanityInstanceContext} from '../../context/SanityInstanceContext'
8
+
9
+ type NormalizedResourceFields = 'resourceName' | 'source' | 'sourceName' | 'projectId' | 'dataset'
10
+
11
+ /**
12
+ * Adds React hook support (resourceName resolution) to core types.
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
19
+ */
20
+ export type WithResourceNameSupport<T extends {resource?: DocumentResource}> = T & {
21
+ /**
22
+ * Optional name of a resource to resolve from context.
23
+ * If provided, will be resolved to a `DocumentResource` via `ResourcesContext`.
24
+ * @beta
25
+ */
26
+ resourceName?: string
27
+ /**
28
+ * @deprecated Use `resourceName` instead.
29
+ * @beta
30
+ */
31
+ sourceName?: string
32
+ }
33
+
34
+ /**
35
+ * Pure function that normalizes options by resolving `resourceName` to a `DocumentResource`
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.
38
+ *
39
+ * @typeParam T - The options type (must include optional resource field)
40
+ * @param options - Options that may include `resourceName` and/or `resource`
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)
44
+ * @returns Normalized options with `resourceName` removed and `resource` resolved
45
+ * @internal
46
+ */
47
+ export function normalizeResourceOptions<
48
+ T extends {
49
+ resource?: DocumentResource
50
+ resourceName?: string
51
+ source?: DocumentResource
52
+ sourceName?: string
53
+ projectId?: string
54
+ dataset?: string
55
+ perspective?: unknown
56
+ },
57
+ >(
58
+ options: T,
59
+ resources: Record<string, DocumentResource>,
60
+ contextResource?: DocumentResource,
61
+ contextPerspective?: PerspectiveHandle['perspective'],
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
68
+
69
+ if (effectiveResourceName && effectiveResource) {
70
+ throw new Error(
71
+ `Resource name ${JSON.stringify(effectiveResourceName)} and resource ${JSON.stringify(effectiveResource)} cannot be used together.`,
72
+ )
73
+ }
74
+
75
+ let resolvedResource: DocumentResource | undefined
76
+
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)) {
82
+ throw new Error(
83
+ `There's no resource named ${JSON.stringify(effectiveResourceName)} in context. Please use <ResourceProvider>.`,
84
+ )
85
+ }
86
+ resolvedResource = resources[effectiveResourceName]
87
+ }
88
+
89
+ // Tier (b): projectId or dataset in options → synthesize a resource
90
+ if (!resolvedResource && projectId && dataset) {
91
+ resolvedResource = {
92
+ projectId,
93
+ dataset,
94
+ }
95
+ }
96
+
97
+ // Tier (c): fall back to whatever ResourceContext provides
98
+ if (!resolvedResource) {
99
+ resolvedResource = contextResource
100
+ }
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
+
107
+ return {
108
+ ...rest,
109
+ ...(resolvedResource !== undefined && {resource: resolvedResource}),
110
+ ...(resolvedPerspective !== undefined && {perspective: resolvedPerspective}),
111
+ }
112
+ }
113
+
114
+ /**
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).
118
+ *
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`.
135
+ *
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
141
+ *
142
+ * Resolution priority for perspective:
143
+ * 1. Explicit `perspective` in options
144
+ * 2. `PerspectiveContext` value (set by `ResourceProvider`)
145
+ *
146
+ * @internal
147
+ */
148
+ export function useNormalizedResourceOptions<
149
+ T extends {
150
+ resource?: DocumentResource
151
+ resourceName?: string
152
+ source?: DocumentResource
153
+ sourceName?: string
154
+ projectId?: string
155
+ dataset?: string
156
+ perspective?: PerspectiveHandle['perspective']
157
+ },
158
+ >(
159
+ options: T,
160
+ ): Omit<T, NormalizedResourceFields> & {
161
+ resource?: DocumentResource
162
+ perspective?: PerspectiveHandle['perspective']
163
+ } {
164
+ const resources = useContext(ResourcesContext)
165
+ const effectiveContextResource = useEffectiveContextResource()
166
+ const contextPerspective = useContext(PerspectiveContext)
167
+
168
+ return normalizeResourceOptions(options, resources, effectiveContextResource, contextPerspective)
169
+ }
@@ -18,8 +18,8 @@ import {useSanityInstance} from '../context/useSanityInstance'
18
18
  */
19
19
  export function useTrackHookUsage(hookName: string): void {
20
20
  const instance = useSanityInstance()
21
- const tracked = useRef(false)
22
- if (!tracked.current) {
21
+ const tracked = useRef<true | null>(null)
22
+ if (tracked.current === null) {
23
23
  tracked.current = true
24
24
  trackHookMounted(instance, hookName)
25
25
  }
@@ -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
+ })
@@ -0,0 +1,40 @@
1
+ import {
2
+ getOrganizationState,
3
+ type Organization,
4
+ type OrganizationOptions,
5
+ resolveOrganization,
6
+ } from '@sanity/sdk'
7
+
8
+ import {createStateSourceHook} from '../helpers/createStateSourceHook'
9
+
10
+ /**
11
+ * Returns metadata for a given organisation.
12
+ *
13
+ * @category Organizations
14
+ * @param options - Configuration options
15
+ * @returns The metadata for the organisation. `members` is included only when
16
+ * `includeMembers: true`; `features` is included only when `includeFeatures: true`.
17
+ * @example
18
+ * ```tsx
19
+ * function OrganizationName({organizationId}: {organizationId: string}) {
20
+ * const organization = useOrganization({organizationId})
21
+ *
22
+ * return <h1>{organization.name}</h1>
23
+ * }
24
+ * ```
25
+ * @example
26
+ * ```tsx
27
+ * const organizationWithMembers = useOrganization({organizationId, includeMembers: true})
28
+ * const organizationWithFeatures = useOrganization({organizationId, includeFeatures: true})
29
+ * ```
30
+ * @public
31
+ * @function
32
+ */
33
+ export const useOrganization = createStateSourceHook({
34
+ getState: getOrganizationState,
35
+ shouldSuspend: (instance, ...params) =>
36
+ getOrganizationState(instance, ...params).getCurrent() === undefined,
37
+ suspender: resolveOrganization,
38
+ }) as <IncludeMembers extends boolean = false, IncludeFeatures extends boolean = false>(
39
+ options: OrganizationOptions<IncludeMembers, IncludeFeatures>,
40
+ ) => Organization<IncludeMembers, IncludeFeatures>