@sanity/sdk-react 2.15.0 → 2.17.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.
@@ -0,0 +1,111 @@
1
+ import {type DocumentResource, type SanityInstance} from '@sanity/sdk'
2
+ import {type ReactElement, type ReactNode, use, useMemo} from 'react'
3
+
4
+ import {useDashboardOrganizationId} from '../hooks/auth/useDashboardOrganizationId'
5
+ import {useSanityInstance} from '../hooks/context/useSanityInstance'
6
+ import {resolveOrgResources} from '../utils/resolveOrgResources'
7
+ import {ResourcesContext} from './ResourcesContext'
8
+
9
+ const DEFAULT_MEDIA_LIBRARY_RESOURCE_NAME = 'media-library'
10
+ const DEFAULT_CANVAS_RESOURCE_NAME = 'canvas'
11
+
12
+ type OrgResource = typeof DEFAULT_MEDIA_LIBRARY_RESOURCE_NAME | typeof DEFAULT_CANVAS_RESOURCE_NAME
13
+ type OrgResourcePromises = Map<OrgResource, PromiseLike<DocumentResource | undefined>>
14
+
15
+ // Module-level cache keyed by SanityInstance so Promise references are stable
16
+ // across Suspense unmount/remount cycles. React's use() tracks promises by
17
+ // identity, so stable references prevent unnecessary re-suspensions.
18
+ // WeakMap entries are GC'd when the instance is disposed.
19
+ // There is only ever one org at a time, so the instance alone is a sufficient
20
+ // key — organizationId only scopes the fetch, it doesn't need to key the cache.
21
+ const inferredResourceCache = new WeakMap<SanityInstance, OrgResourcePromises>()
22
+
23
+ function getOrgResourcePromises(
24
+ instance: SanityInstance,
25
+ organizationId: string,
26
+ ): OrgResourcePromises {
27
+ let promises = inferredResourceCache.get(instance)
28
+ if (!promises) {
29
+ const basePromise = resolveOrgResources(instance, organizationId).then(
30
+ (result) => result,
31
+ (error) => {
32
+ // eslint-disable-next-line no-console
33
+ console.warn('[sanity/sdk] Failed to infer org resources:', error)
34
+ return {mediaLibrary: undefined, canvas: undefined}
35
+ },
36
+ )
37
+ promises = new Map([
38
+ [DEFAULT_MEDIA_LIBRARY_RESOURCE_NAME, basePromise.then((r) => r.mediaLibrary)],
39
+ [DEFAULT_CANVAS_RESOURCE_NAME, basePromise.then((r) => r.canvas)],
40
+ ])
41
+ inferredResourceCache.set(instance, promises)
42
+ }
43
+
44
+ return promises
45
+ }
46
+
47
+ /**
48
+ * Inner component that suspends via use() until both inference promises settle.
49
+ * Requires a Suspense boundary from the caller (usually a top ResourceProvider).
50
+ */
51
+ function InferredResourcesProvider({
52
+ instance,
53
+ orgId,
54
+ explicitResources,
55
+ children,
56
+ }: {
57
+ instance: SanityInstance
58
+ orgId: string
59
+ explicitResources: Record<string, DocumentResource>
60
+ children: ReactNode
61
+ }): ReactElement {
62
+ const promises = getOrgResourcePromises(instance, orgId)
63
+ const ml = use(promises.get(DEFAULT_MEDIA_LIBRARY_RESOURCE_NAME)!)
64
+ const canvas = use(promises.get(DEFAULT_CANVAS_RESOURCE_NAME)!)
65
+
66
+ const resources = useMemo(() => {
67
+ const inferred: Record<string, DocumentResource> = {}
68
+ if (ml !== undefined) inferred[DEFAULT_MEDIA_LIBRARY_RESOURCE_NAME] = ml
69
+ if (canvas !== undefined) inferred[DEFAULT_CANVAS_RESOURCE_NAME] = canvas
70
+ return {...inferred, ...explicitResources}
71
+ }, [ml, canvas, explicitResources])
72
+
73
+ return <ResourcesContext.Provider value={resources}>{children}</ResourcesContext.Provider>
74
+ }
75
+
76
+ /**
77
+ * When `inferMediaLibraryAndCanvas` is set, this component suspends until
78
+ * inference resolves. The nearest Suspense boundary (e.g. from ResourceProvider)
79
+ * shows its fallback during that window.
80
+
81
+ * If a user explicitly names a 'media-library' or 'canvas' resource,
82
+ * this component will use that resource instead of inferring it.
83
+ */
84
+ export function OrganizationResourcesProvider({
85
+ resources: explicitResources = {},
86
+ inferMediaLibraryAndCanvas,
87
+ children,
88
+ }: {
89
+ resources?: Record<string, DocumentResource>
90
+ inferMediaLibraryAndCanvas?: boolean
91
+ children: ReactNode
92
+ }): ReactElement {
93
+ const instance = useSanityInstance()
94
+ const orgId = useDashboardOrganizationId()
95
+
96
+ if (!inferMediaLibraryAndCanvas || !orgId) {
97
+ return (
98
+ <ResourcesContext.Provider value={explicitResources}>{children}</ResourcesContext.Provider>
99
+ )
100
+ }
101
+
102
+ return (
103
+ <InferredResourcesProvider
104
+ instance={instance}
105
+ orgId={orgId}
106
+ explicitResources={explicitResources}
107
+ >
108
+ {children}
109
+ </InferredResourcesProvider>
110
+ )
111
+ }
@@ -0,0 +1,15 @@
1
+ import {createContext} from 'react'
2
+
3
+ /**
4
+ * Carries a `projectId` for a subtree even when a complete
5
+ * `DatasetResource` can't be formed — e.g. a `<ResourceProvider projectId="…">`
6
+ * with no `dataset` and no parent resource to inherit one from.
7
+ *
8
+ * Project-scoped hooks (`useProject`, `useDatasets`, `useUsers`) read this as a
9
+ * fallback so they can still resolve a project in that case (the only hooks that would work with a config like this.)
10
+ *
11
+ * @remarks Temporary bridge for the dataset-less project scope.
12
+ * Remove in the next major version; those hooks should just always use a projectId or default to a resource available.
13
+ * @internal
14
+ */
15
+ export const ProjectContext = createContext<string | undefined>(undefined)
@@ -1,8 +1,10 @@
1
- import {type SanityConfig, type SanityInstance} from '@sanity/sdk'
1
+ import {type DocumentResource, type SanityConfig, type SanityInstance} from '@sanity/sdk'
2
2
  import {act, render, screen} from '@testing-library/react'
3
3
  import {StrictMode, use, useEffect} from 'react'
4
4
  import {describe, expect, it, vi} from 'vitest'
5
5
 
6
+ import {ResourceContext} from './DefaultResourceContext'
7
+ import {ProjectContext} from './ProjectContext'
6
8
  import {ResourceProvider} from './ResourceProvider'
7
9
  import {SanityInstanceContext} from './SanityInstanceContext'
8
10
 
@@ -164,4 +166,58 @@ describe('ResourceProvider', () => {
164
166
  await new Promise((r) => setTimeout(r, 0))
165
167
  consoleSpy.mockRestore()
166
168
  })
169
+
170
+ it('inherits the missing half of a partial nested config from the parent resource', async () => {
171
+ const {promise, resolve} = promiseWithResolvers<DocumentResource | undefined>()
172
+ const CaptureResource = () => {
173
+ const resource = use(ResourceContext)
174
+ useEffect(() => resolve(resource), [resource])
175
+ return null
176
+ }
177
+
178
+ render(
179
+ <ResourceProvider projectId="parent-project" dataset="parent-dataset" fallback={null}>
180
+ {/* Partial config: only `dataset`. It should inherit `projectId` from the
181
+ parent resource and resolve to a complete DatasetResource rather than
182
+ being dropped in favor of the parent's dataset. */}
183
+ <ResourceProvider dataset="child-dataset" fallback={null}>
184
+ <CaptureResource />
185
+ </ResourceProvider>
186
+ </ResourceProvider>,
187
+ )
188
+
189
+ await expect(promise).resolves.toEqual({
190
+ projectId: 'parent-project',
191
+ dataset: 'child-dataset',
192
+ })
193
+ })
194
+
195
+ it('does not merge a nested bare projectId with the enveloping dataset', async () => {
196
+ const captured = promiseWithResolvers<{
197
+ resource: DocumentResource | undefined
198
+ projectId: string | undefined
199
+ }>()
200
+ const Capture = () => {
201
+ const resource = use(ResourceContext)
202
+ const projectId = use(ProjectContext)
203
+ useEffect(() => captured.resolve({resource, projectId}), [resource, projectId])
204
+ return null
205
+ }
206
+
207
+ render(
208
+ <ResourceProvider projectId="parent-project" dataset="parent-dataset" fallback={null}>
209
+ {/* Bare `projectId`: switches project scope. It must surface as the
210
+ project but must NOT adopt the parent's dataset — that dataset belongs
211
+ to a different project. */}
212
+ <ResourceProvider projectId="child-project" fallback={null}>
213
+ <Capture />
214
+ </ResourceProvider>
215
+ </ResourceProvider>,
216
+ )
217
+
218
+ await expect(captured.promise).resolves.toEqual({
219
+ resource: undefined,
220
+ projectId: 'child-project',
221
+ })
222
+ })
167
223
  })
@@ -11,6 +11,7 @@ import {useContext, useEffect, useMemo, useRef, useState} from 'react'
11
11
 
12
12
  import {ResourceContext} from './DefaultResourceContext'
13
13
  import {PerspectiveContext} from './PerspectiveContext'
14
+ import {ProjectContext} from './ProjectContext'
14
15
  import {SanityInstanceContext} from './SanityInstanceContext'
15
16
  import {SanityInstanceProvider} from './SanityInstanceProvider'
16
17
 
@@ -63,21 +64,39 @@ export function ResourceProvider({
63
64
  const parentPerspective = useContext(PerspectiveContext)
64
65
  const parentResource = useContext(ResourceContext)
65
66
  const parentInstance = useContext(SanityInstanceContext)
67
+ const parentProjectId = useContext(ProjectContext)
66
68
 
67
69
  const {projectId, dataset, perspective} = config
68
70
 
69
71
  const [instance] = useState<SanityInstance>(() => parentInstance ?? createSanityInstance(config))
70
72
 
71
73
  const configResource: DatasetResource | undefined = useMemo(() => {
72
- if (projectId && dataset) {
73
- return {projectId, dataset}
74
- }
74
+ // Historically, we allowed asymmetric merging: If you provided JUST a dataset, we'd merge it with the parent projectId.
75
+ //
76
+ // This backwards-compatible merging should be removed in the next major version.
77
+ if (projectId && dataset) return {projectId, dataset}
78
+ if (dataset && parentProjectId) return {projectId: parentProjectId, dataset}
75
79
  return undefined
76
- }, [projectId, dataset])
80
+ }, [projectId, dataset, parentProjectId])
77
81
 
78
82
  const effectiveResource = useMemo(() => {
79
- return resource ?? configResource ?? parentResource
80
- }, [resource, configResource, parentResource])
83
+ if (resource) return resource
84
+ if (configResource) return configResource
85
+ // A projectId with no dataset historically created its own scope, so no resource is needed.
86
+ if (projectId) return undefined
87
+ return parentResource
88
+ }, [resource, configResource, projectId, parentResource])
89
+
90
+ // Historically, ResourceProviders allowed a bare `projectId` (no dataset, no resource to complete it) to be provided.
91
+ // This keeps that behavior intact for backwards compatibility, even though we prefer resources everywhere.
92
+ //
93
+ // This should be removed in the next major version.
94
+ const effectiveProjectId = useMemo(() => {
95
+ if (effectiveResource && isDatasetResource(effectiveResource)) {
96
+ return effectiveResource.projectId
97
+ }
98
+ return projectId ?? parentProjectId
99
+ }, [effectiveResource, projectId, parentProjectId])
81
100
 
82
101
  useEffect(() => {
83
102
  if (effectiveResource && isDatasetResource(effectiveResource))
@@ -113,9 +132,11 @@ export function ResourceProvider({
113
132
  return (
114
133
  <SanityInstanceProvider instance={instance} fallback={fallback ?? DEFAULT_FALLBACK}>
115
134
  <ResourceContext.Provider value={effectiveResource}>
116
- <PerspectiveContext.Provider value={perspective ?? parentPerspective}>
117
- {children}
118
- </PerspectiveContext.Provider>
135
+ <ProjectContext.Provider value={effectiveProjectId}>
136
+ <PerspectiveContext.Provider value={perspective ?? parentPerspective}>
137
+ {children}
138
+ </PerspectiveContext.Provider>
139
+ </ProjectContext.Provider>
119
140
  </ResourceContext.Provider>
120
141
  </SanityInstanceProvider>
121
142
  )
@@ -0,0 +1,116 @@
1
+ import {type DatasetsResponse} from '@sanity/client'
2
+ import {
3
+ createSanityInstance,
4
+ getDatasetsState,
5
+ resolveDatasets,
6
+ type StateSource,
7
+ } from '@sanity/sdk'
8
+ import {type ReactNode} from 'react'
9
+ import {type Observable} from 'rxjs'
10
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
11
+
12
+ import {renderHook} from '../../../test/test-utils'
13
+ import {ResourceProvider} from '../../context/ResourceProvider'
14
+ import {SanityInstanceContext} from '../../context/SanityInstanceContext'
15
+ import {useDatasets} from './useDatasets'
16
+
17
+ vi.mock('@sanity/sdk', async (importOriginal) => {
18
+ const original = await importOriginal<typeof import('@sanity/sdk')>()
19
+ return {...original, getDatasetsState: vi.fn(), resolveDatasets: vi.fn()}
20
+ })
21
+
22
+ const stateSource = (
23
+ current: DatasetsResponse | undefined,
24
+ ): StateSource<DatasetsResponse | undefined> =>
25
+ ({
26
+ getCurrent: vi.fn(() => current),
27
+ subscribe: vi.fn(),
28
+ get observable(): Observable<unknown> {
29
+ throw new Error('Not implemented')
30
+ },
31
+ }) as unknown as StateSource<DatasetsResponse | undefined>
32
+
33
+ const sanityInstance = expect.objectContaining({config: expect.any(Object)})
34
+
35
+ describe('useDatasets', () => {
36
+ beforeEach(() => {
37
+ vi.clearAllMocks()
38
+ vi.mocked(getDatasetsState).mockReturnValue(stateSource([] as unknown as DatasetsResponse))
39
+ })
40
+
41
+ it('resolves the projectId from the instance config resource', () => {
42
+ // test-utils wraps with ResourceProvider projectId="test" dataset="test".
43
+ renderHook(() => useDatasets())
44
+ expect(getDatasetsState).toHaveBeenCalledWith(
45
+ sanityInstance,
46
+ expect.objectContaining({projectId: 'test'}),
47
+ )
48
+ })
49
+
50
+ it('lets an explicit projectId override the ambient resource', () => {
51
+ renderHook(() => useDatasets({projectId: 'explicit-project'}))
52
+ expect(getDatasetsState).toHaveBeenCalledWith(
53
+ sanityInstance,
54
+ expect.objectContaining({projectId: 'explicit-project'}),
55
+ )
56
+ })
57
+
58
+ it('resolves the projectId from an explicit resource when the config has none', () => {
59
+ renderHook(() => useDatasets(), {
60
+ wrapper: ({children}: {children: ReactNode}) => (
61
+ <ResourceProvider
62
+ resource={{projectId: 'resource-project', dataset: 'production'}}
63
+ fallback={null}
64
+ >
65
+ {children}
66
+ </ResourceProvider>
67
+ ),
68
+ })
69
+ expect(getDatasetsState).toHaveBeenCalledWith(
70
+ sanityInstance,
71
+ expect.objectContaining({projectId: 'resource-project'}),
72
+ )
73
+ })
74
+
75
+ it('resolves a dataset-less projectId config for project-scoped use', () => {
76
+ renderHook(() => useDatasets(), {
77
+ wrapper: ({children}: {children: ReactNode}) => (
78
+ <ResourceProvider projectId="config-project" fallback={null}>
79
+ {children}
80
+ </ResourceProvider>
81
+ ),
82
+ })
83
+ // A dataset-less config can't form a DatasetResource; the projectId is carried
84
+ // via ProjectContext and injected so project-scoped reads still resolve it.
85
+ expect(getDatasetsState).toHaveBeenCalledWith(
86
+ sanityInstance,
87
+ expect.objectContaining({projectId: 'config-project'}),
88
+ )
89
+ })
90
+
91
+ it('resolves a bare projectId from a ResourceProvider when the parent instance has no config', () => {
92
+ // An instance with no project/dataset config and no ambient
93
+ // resource, then a projectId-only ResourceProvider.
94
+ const emptyInstance = createSanityInstance({})
95
+ renderHook(() => useDatasets(), {
96
+ wrapper: ({children}: {children: ReactNode}) => (
97
+ <SanityInstanceContext.Provider value={emptyInstance}>
98
+ <ResourceProvider projectId="bare-project" fallback={null}>
99
+ {children}
100
+ </ResourceProvider>
101
+ </SanityInstanceContext.Provider>
102
+ ),
103
+ })
104
+ expect(getDatasetsState).toHaveBeenCalledWith(
105
+ emptyInstance,
106
+ expect.objectContaining({projectId: 'bare-project'}),
107
+ )
108
+ })
109
+
110
+ it('suspends via resolveDatasets until dataset data is available', () => {
111
+ vi.mocked(getDatasetsState).mockReturnValue(stateSource(undefined))
112
+ vi.mocked(resolveDatasets).mockReturnValue(new Promise(() => {}))
113
+ renderHook(() => useDatasets())
114
+ expect(resolveDatasets).toHaveBeenCalled()
115
+ })
116
+ })
@@ -6,9 +6,9 @@ import {
6
6
  type SanityInstance,
7
7
  type StateSource,
8
8
  } from '@sanity/sdk'
9
- import {identity} from 'rxjs'
10
9
 
11
10
  import {createStateSourceHook} from '../helpers/createStateSourceHook'
11
+ import {useResolvedProjectId} from '../helpers/useResolvedProjectId'
12
12
 
13
13
  type UseDatasets = {
14
14
  /**
@@ -16,6 +16,8 @@ type UseDatasets = {
16
16
  * Returns metadata for each dataset the current user has access to.
17
17
  *
18
18
  * @category Datasets
19
+ * @param options - Optional project/resource to read datasets for. Defaults to
20
+ * the resource named in `ResourceProvider`/`SDKProvider`.
19
21
  * @returns The metadata for your the datasets
20
22
  *
21
23
  * @example
@@ -31,15 +33,17 @@ type UseDatasets = {
31
33
  * )
32
34
  * ```
33
35
  *
36
+ * @remarks
37
+ * The `projectId` is resolved in order from:
38
+ * 1. an explicit `projectId` option
39
+ * 2. A legacy ProjectContext (e.g. a `<ResourceProvider projectId="…">` with no dataset), then
40
+ * 3. The active resource (`ResourceProvider`/`SDKProvider`)
41
+ * 4. `instance.config`.
34
42
  */
35
- (): DatasetsResponse
43
+ (options?: ProjectHandle): DatasetsResponse
36
44
  }
37
45
 
38
- /**
39
- * @public
40
- * @function
41
- */
42
- export const useDatasets: UseDatasets = createStateSourceHook({
46
+ const useDatasetsBase = createStateSourceHook({
43
47
  getState: getDatasetsState as (
44
48
  instance: SanityInstance,
45
49
  projectHandle?: ProjectHandle,
@@ -48,5 +52,13 @@ export const useDatasets: UseDatasets = createStateSourceHook({
48
52
  // remove `undefined` since we're suspending when that is the case
49
53
  getDatasetsState(instance, projectHandle).getCurrent() === undefined,
50
54
  suspender: resolveDatasets,
51
- getConfig: identity as (projectHandle?: ProjectHandle) => ProjectHandle,
52
55
  })
56
+
57
+ /**
58
+ * @public
59
+ * @function
60
+ */
61
+ export const useDatasets: UseDatasets = (options) => {
62
+ const projectId = useResolvedProjectId(options)
63
+ return useDatasetsBase(projectId ? {...options, projectId} : options)
64
+ }
@@ -4,7 +4,7 @@ import {type SanityDocument} from 'groq'
4
4
  import {type ResourceHandle} from '../../config/handles'
5
5
  import {useApplyActions} from '../helpers/useApplyActions'
6
6
  // this import is used in an `{@link useEditDocument}`
7
- // eslint-disable-next-line import/consistent-type-specifier-style
7
+ // eslint-disable-next-line import-x/consistent-type-specifier-style
8
8
  import type {useEditDocument} from './useEditDocument'
9
9
 
10
10
  /**
@@ -7,9 +7,9 @@ import {createStateSourceHook} from '../helpers/createStateSourceHook'
7
7
  import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
8
8
  import {useTrackHookUsage} from '../helpers/useTrackHookUsage'
9
9
  // used in an `{@link useDocumentProjection}` and `{@link useQuery}`
10
- // eslint-disable-next-line import/consistent-type-specifier-style
10
+ // eslint-disable-next-line import-x/consistent-type-specifier-style
11
11
  import type {useDocumentProjection} from '../projection/useDocumentProjection'
12
- // eslint-disable-next-line import/consistent-type-specifier-style
12
+ // eslint-disable-next-line import-x/consistent-type-specifier-style
13
13
  import type {useQuery} from '../query/useQuery'
14
14
 
15
15
  const useDocumentValue = createStateSourceHook({
@@ -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,114 @@
1
+ import {
2
+ createSanityInstance,
3
+ getProjectState,
4
+ type Project,
5
+ resolveProject,
6
+ type StateSource,
7
+ } from '@sanity/sdk'
8
+ import {type ReactNode} from 'react'
9
+ import {type Observable} from 'rxjs'
10
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
11
+
12
+ import {renderHook} from '../../../test/test-utils'
13
+ import {ResourceProvider} from '../../context/ResourceProvider'
14
+ import {SanityInstanceContext} from '../../context/SanityInstanceContext'
15
+ import {useProject} from './useProject'
16
+
17
+ vi.mock('@sanity/sdk', async (importOriginal) => {
18
+ const original = await importOriginal<typeof import('@sanity/sdk')>()
19
+ return {...original, getProjectState: vi.fn(), resolveProject: vi.fn()}
20
+ })
21
+
22
+ const stateSource = (current: Project | undefined): StateSource<Project | undefined> =>
23
+ ({
24
+ getCurrent: vi.fn(() => current),
25
+ subscribe: vi.fn(),
26
+ get observable(): Observable<unknown> {
27
+ throw new Error('Not implemented')
28
+ },
29
+ }) as unknown as StateSource<Project | undefined>
30
+
31
+ const sanityInstance = expect.objectContaining({config: expect.any(Object)})
32
+
33
+ describe('useProject', () => {
34
+ beforeEach(() => {
35
+ vi.clearAllMocks()
36
+ vi.mocked(getProjectState).mockReturnValue(stateSource({id: 'p'} as unknown as Project))
37
+ })
38
+
39
+ it('resolves the projectId from the instance config resource', () => {
40
+ // test-utils wraps with ResourceProvider projectId="test" dataset="test".
41
+ renderHook(() => useProject())
42
+ expect(getProjectState).toHaveBeenCalledWith(
43
+ sanityInstance,
44
+ expect.objectContaining({projectId: 'test'}),
45
+ )
46
+ })
47
+
48
+ it('lets an explicit projectId override the ambient resource', () => {
49
+ renderHook(() => useProject({projectId: 'explicit-project'}))
50
+ expect(getProjectState).toHaveBeenCalledWith(
51
+ sanityInstance,
52
+ expect.objectContaining({projectId: 'explicit-project'}),
53
+ )
54
+ })
55
+
56
+ it('resolves the projectId from an explicit resource when the config has none', () => {
57
+ renderHook(() => useProject(), {
58
+ wrapper: ({children}: {children: ReactNode}) => (
59
+ <ResourceProvider
60
+ resource={{projectId: 'resource-project', dataset: 'production'}}
61
+ fallback={null}
62
+ >
63
+ {children}
64
+ </ResourceProvider>
65
+ ),
66
+ })
67
+ expect(getProjectState).toHaveBeenCalledWith(
68
+ sanityInstance,
69
+ expect.objectContaining({projectId: 'resource-project'}),
70
+ )
71
+ })
72
+
73
+ it('resolves a dataset-less projectId config for project-scoped use', () => {
74
+ renderHook(() => useProject(), {
75
+ wrapper: ({children}: {children: ReactNode}) => (
76
+ <ResourceProvider projectId="config-project" fallback={null}>
77
+ {children}
78
+ </ResourceProvider>
79
+ ),
80
+ })
81
+ // A dataset-less config can't form a DatasetResource; the projectId is carried
82
+ // via ProjectContext and injected so project-scoped reads still resolve it.
83
+ expect(getProjectState).toHaveBeenCalledWith(
84
+ sanityInstance,
85
+ expect.objectContaining({projectId: 'config-project'}),
86
+ )
87
+ })
88
+
89
+ it('resolves a bare projectId from a ResourceProvider when the parent instance has no config', () => {
90
+ // An instance with no project/dataset config and no ambient
91
+ // resource, then a projectId-only ResourceProvider.
92
+ const emptyInstance = createSanityInstance({})
93
+ renderHook(() => useProject(), {
94
+ wrapper: ({children}: {children: ReactNode}) => (
95
+ <SanityInstanceContext.Provider value={emptyInstance}>
96
+ <ResourceProvider projectId="bare-project" fallback={null}>
97
+ {children}
98
+ </ResourceProvider>
99
+ </SanityInstanceContext.Provider>
100
+ ),
101
+ })
102
+ expect(getProjectState).toHaveBeenCalledWith(
103
+ emptyInstance,
104
+ expect.objectContaining({projectId: 'bare-project'}),
105
+ )
106
+ })
107
+
108
+ it('suspends via resolveProject until project data is available', () => {
109
+ vi.mocked(getProjectState).mockReturnValue(stateSource(undefined))
110
+ vi.mocked(resolveProject).mockReturnValue(new Promise(() => {}))
111
+ renderHook(() => useProject())
112
+ expect(resolveProject).toHaveBeenCalled()
113
+ })
114
+ })