@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
@@ -0,0 +1,55 @@
1
+ import {type OrganizationMember, type Organizations} from '@sanity/sdk'
2
+ import {expectTypeOf, test} from 'vitest'
3
+
4
+ import {useOrganizations} from './useOrganizations'
5
+
6
+ test('useOrganizations — no args: members and features both omitted', () => {
7
+ expectTypeOf(useOrganizations()).toEqualTypeOf<Organizations<false, false>>()
8
+ })
9
+
10
+ test('useOrganizations — includeMembers: true adds members to the type', () => {
11
+ expectTypeOf(useOrganizations({includeMembers: true})).toEqualTypeOf<Organizations<true, false>>()
12
+ type Result = ReturnType<typeof useOrganizations<true, false>>
13
+ expectTypeOf<Result[number]['members']>().toEqualTypeOf<OrganizationMember[]>()
14
+ })
15
+
16
+ test('useOrganizations — includeFeatures: true adds features to the type', () => {
17
+ expectTypeOf(useOrganizations({includeFeatures: true})).toEqualTypeOf<
18
+ Organizations<false, true>
19
+ >()
20
+ })
21
+
22
+ test('useOrganizations — both flags true → both arrays present', () => {
23
+ expectTypeOf(useOrganizations({includeMembers: true, includeFeatures: true})).toEqualTypeOf<
24
+ Organizations<true, true>
25
+ >()
26
+ })
27
+
28
+ test('useOrganizations — both flags false → bare base shape', () => {
29
+ expectTypeOf(useOrganizations({includeMembers: false, includeFeatures: false})).toEqualTypeOf<
30
+ Organizations<false, false>
31
+ >()
32
+ type Result = ReturnType<typeof useOrganizations<false, false>>
33
+ expectTypeOf<Result[number]['id']>().toEqualTypeOf<string>()
34
+ })
35
+
36
+ test('useOrganizations — rejects non-boolean flag values', () => {
37
+ // @ts-expect-error — includeMembers must be a boolean
38
+ void useOrganizations({includeMembers: 'yes'})
39
+ })
40
+
41
+ test('useOrganizations — includeImplicitMemberships does not change the data shape', () => {
42
+ expectTypeOf(useOrganizations({includeImplicitMemberships: true})).toEqualTypeOf<
43
+ Organizations<false, false>
44
+ >()
45
+ })
46
+
47
+ test('useOrganizations — non-literal boolean flag makes members optional', () => {
48
+ const includeMembers = false as boolean
49
+ expectTypeOf(useOrganizations({includeMembers})).toEqualTypeOf<Organizations<boolean, false>>()
50
+ type Result = ReturnType<typeof useOrganizations<boolean, false>>
51
+ expectTypeOf<Result[number]['members']>().toEqualTypeOf<OrganizationMember[] | undefined>()
52
+ expectTypeOf<Pick<Result[number], 'members'>>().toEqualTypeOf<{
53
+ members?: OrganizationMember[]
54
+ }>()
55
+ })
@@ -0,0 +1,85 @@
1
+ import {getOrganizationsState, 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
+ getOrganizationsState: vi.fn(() => ({
8
+ getCurrent: vi.fn(() => undefined),
9
+ })),
10
+ resolveOrganizations: vi.fn(),
11
+ }))
12
+ vi.mock('../helpers/createStateSourceHook', () => ({
13
+ createStateSourceHook: vi.fn(),
14
+ }))
15
+
16
+ describe('useOrganizations', () => {
17
+ beforeEach(() => {
18
+ vi.resetModules()
19
+ vi.mock('@sanity/sdk', () => ({
20
+ getOrganizationsState: vi.fn(() => ({
21
+ getCurrent: vi.fn(() => undefined),
22
+ })),
23
+ resolveOrganizations: 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('./useOrganizations')
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 getOrganizationsState and getCurrent', async () => {
44
+ await import('./useOrganizations')
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
+
53
+ const result = shouldSuspend(mockInstance, undefined)
54
+
55
+ const mockGetOrganizationsState = getOrganizationsState as ReturnType<typeof vi.fn>
56
+ expect(mockGetOrganizationsState).toHaveBeenCalledWith(mockInstance, undefined)
57
+
58
+ expect(mockGetOrganizationsState.mock.results.length).toBeGreaterThan(0)
59
+ const getOrganizationsStateMockResult = mockGetOrganizationsState.mock.results[0].value
60
+ expect(getOrganizationsStateMockResult.getCurrent).toHaveBeenCalled()
61
+
62
+ expect(result).toBe(true)
63
+ })
64
+
65
+ it('should handle different parameter combinations in shouldSuspend', async () => {
66
+ await import('./useOrganizations')
67
+
68
+ const mockCreateStateSourceHook = createStateSourceHook as ReturnType<typeof vi.fn>
69
+ const createStateSourceHookArgs = mockCreateStateSourceHook.mock.calls[0][0]
70
+ const shouldSuspend = createStateSourceHookArgs.shouldSuspend
71
+
72
+ const mockInstance = {} as SanityInstance
73
+
74
+ expect(() => shouldSuspend(mockInstance, undefined)).not.toThrow()
75
+ expect(() => shouldSuspend(mockInstance, {includeMembers: true})).not.toThrow()
76
+ expect(() => shouldSuspend(mockInstance, {includeFeatures: true})).not.toThrow()
77
+ expect(() =>
78
+ shouldSuspend(mockInstance, {
79
+ includeMembers: true,
80
+ includeFeatures: true,
81
+ includeImplicitMemberships: true,
82
+ }),
83
+ ).not.toThrow()
84
+ })
85
+ })
@@ -0,0 +1,45 @@
1
+ import {
2
+ getOrganizationsState,
3
+ type Organizations,
4
+ type OrganizationsOptions,
5
+ resolveOrganizations,
6
+ } from '@sanity/sdk'
7
+
8
+ import {createStateSourceHook} from '../helpers/createStateSourceHook'
9
+
10
+ /**
11
+ * Returns metadata for each organisation the current user has access to.
12
+ *
13
+ * @category Organizations
14
+ * @param options - Configuration options
15
+ * @returns An array of organisation metadata. `members` is included only when
16
+ * `includeMembers: true`; `features` is included only when `includeFeatures: true`.
17
+ * @example
18
+ * ```tsx
19
+ * const organizations = useOrganizations()
20
+ *
21
+ * return (
22
+ * <select>
23
+ * {organizations.map((organization) => (
24
+ * <option key={organization.id}>{organization.name}</option>
25
+ * ))}
26
+ * </select>
27
+ * )
28
+ * ```
29
+ * @example
30
+ * ```tsx
31
+ * const organizationsWithMembers = useOrganizations({includeMembers: true})
32
+ * const organizationsWithFeatures = useOrganizations({includeFeatures: true})
33
+ * const organizationsIncludingImplicit = useOrganizations({includeImplicitMemberships: true})
34
+ * ```
35
+ * @public
36
+ * @function
37
+ */
38
+ export const useOrganizations = createStateSourceHook({
39
+ getState: getOrganizationsState,
40
+ shouldSuspend: (instance, ...params) =>
41
+ getOrganizationsState(instance, ...params).getCurrent() === undefined,
42
+ suspender: resolveOrganizations,
43
+ }) as <IncludeMembers extends boolean = false, IncludeFeatures extends boolean = false>(
44
+ options?: OrganizationsOptions<IncludeMembers, IncludeFeatures>,
45
+ ) => Organizations<IncludeMembers, IncludeFeatures>
@@ -1,9 +1,17 @@
1
- import {createGroqSearchFilter, type DocumentHandle, type QueryOptions} from '@sanity/sdk'
1
+ import {
2
+ createGroqSearchFilter,
3
+ type DocumentHandle,
4
+ isDatasetResource,
5
+ type QueryOptions,
6
+ } from '@sanity/sdk'
7
+ import {pickProperties} from '@sanity/sdk/_internal'
2
8
  import {type SortOrderingItem} from '@sanity/types'
3
- import {pick} from 'lodash-es'
4
- import {useCallback, useEffect, useMemo, useState} from 'react'
9
+ import {useCallback, useMemo, useState} from 'react'
5
10
 
6
- import {useSanityInstance} from '../context/useSanityInstance'
11
+ import {
12
+ useNormalizedResourceOptions,
13
+ type WithResourceNameSupport,
14
+ } from '../helpers/useNormalizedResourceOptions'
7
15
  import {useTrackHookUsage} from '../helpers/useTrackHookUsage'
8
16
  import {useQuery} from '../query/useQuery'
9
17
 
@@ -17,7 +25,9 @@ export interface PaginatedDocumentsOptions<
17
25
  TDocumentType extends string = string,
18
26
  TDataset extends string = string,
19
27
  TProjectId extends string = string,
20
- > extends Omit<QueryOptions<TDocumentType, TDataset, TProjectId>, 'query'> {
28
+ > extends WithResourceNameSupport<
29
+ Omit<QueryOptions<TDocumentType, TDataset, TProjectId>, 'query'>
30
+ > {
21
31
  documentType?: TDocumentType | TDocumentType[]
22
32
  /**
23
33
  * GROQ filter expression to apply to the query
@@ -233,21 +243,22 @@ export function usePaginatedDocuments<
233
243
  params = {},
234
244
  orderings,
235
245
  search,
236
- ...options
246
+ ...rawOptions
237
247
  }: PaginatedDocumentsOptions<TDocumentType, TDataset, TProjectId>): PaginatedDocumentsResponse<
238
248
  TDocumentType,
239
249
  TDataset,
240
250
  TProjectId
241
251
  > {
242
252
  useTrackHookUsage('usePaginatedDocuments')
243
- const instance = useSanityInstance(options)
253
+ const options = useNormalizedResourceOptions(rawOptions)
244
254
  const [pageIndex, setPageIndex] = useState(0)
245
- const key = JSON.stringify({filter, search, params, orderings, pageSize})
246
- // Reset the pageIndex to 0 whenever any query parameters (filter, search,
247
- // params, orderings) or pageSize changes
248
- useEffect(() => {
255
+ const key = JSON.stringify({filter, search, params, orderings, pageSize, ...options})
256
+ // Reset pageIndex to 0 whenever any query parameter changes.
257
+ const [prevKey, setPrevKey] = useState(key)
258
+ if (prevKey !== key) {
259
+ setPrevKey(key)
249
260
  setPageIndex(0)
250
- }, [key])
261
+ }
251
262
 
252
263
  const startIndex = pageIndex * pageSize
253
264
  const endIndex = (pageIndex + 1) * pageSize
@@ -303,8 +314,12 @@ export function usePaginatedDocuments<
303
314
  ...params,
304
315
  __types: documentTypes,
305
316
  __handle: {
306
- ...pick(instance.config, 'projectId', 'dataset', 'perspective'),
307
- ...pick(options, 'projectId', 'dataset', 'perspective'),
317
+ // keep projectId/dataset for backward compat until v4; resource is added
318
+ // intentionally so that hook consumers can resolve the correct resource
319
+ ...(options.resource && isDatasetResource(options.resource)
320
+ ? pickProperties(options.resource, ['projectId', 'dataset'])
321
+ : {}),
322
+ ...pickProperties(options, ['perspective', 'resource']),
308
323
  },
309
324
  },
310
325
  })
@@ -6,14 +6,17 @@ import {describe, expect, it, vi} from 'vitest'
6
6
  import {ResourceProvider} from '../../context/ResourceProvider'
7
7
  import {usePresence} from './usePresence'
8
8
 
9
- vi.mock('@sanity/sdk', () => ({
10
- getPresence: vi.fn(),
11
- createSanityInstance: vi.fn(() => ({
12
- createChild: vi.fn(),
13
- isDisposed: vi.fn(() => false),
14
- dispose: vi.fn(),
15
- })),
16
- }))
9
+ vi.mock('@sanity/sdk', async (importOriginal) => {
10
+ const actual = await importOriginal<typeof import('@sanity/sdk')>()
11
+ return {
12
+ ...actual,
13
+ getPresence: vi.fn(),
14
+ createSanityInstance: vi.fn(() => ({
15
+ isDisposed: vi.fn(() => false),
16
+ dispose: vi.fn(),
17
+ })),
18
+ }
19
+ })
17
20
 
18
21
  describe('usePresence', () => {
19
22
  it('should return presence locations and update when the store changes', () => {
@@ -59,7 +62,10 @@ describe('usePresence', () => {
59
62
 
60
63
  const {result, unmount} = renderHook(() => usePresence(), {
61
64
  wrapper: ({children}) => (
62
- <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
65
+ <ResourceProvider
66
+ resource={{projectId: 'test-project', dataset: 'test-dataset'}}
67
+ fallback={null}
68
+ >
63
69
  {children}
64
70
  </ResourceProvider>
65
71
  ),
@@ -80,4 +86,45 @@ describe('usePresence', () => {
80
86
  expect(result.current.locations).toEqual(updatedLocations)
81
87
  unmount()
82
88
  })
89
+
90
+ it('should throw an error when used with a media library resource', () => {
91
+ expect(() => {
92
+ renderHook(() => usePresence({resource: {mediaLibraryId: 'ml123'}}), {
93
+ wrapper: ({children}) => (
94
+ <ResourceProvider
95
+ resource={{projectId: 'test-project', dataset: 'test-dataset'}}
96
+ fallback={null}
97
+ >
98
+ {children}
99
+ </ResourceProvider>
100
+ ),
101
+ })
102
+ }).toThrow('usePresence() does not support media library resources')
103
+ })
104
+
105
+ it('should work with a dataset resource', () => {
106
+ const mockPresenceSource = {
107
+ getCurrent: vi.fn().mockReturnValue([]),
108
+ subscribe: vi.fn(() => () => {}),
109
+ observable: NEVER,
110
+ }
111
+ vi.mocked(getPresence).mockReturnValue(mockPresenceSource)
112
+
113
+ const {result, unmount} = renderHook(
114
+ () => usePresence({resource: {projectId: 'test-project', dataset: 'test-dataset'}}),
115
+ {
116
+ wrapper: ({children}) => (
117
+ <ResourceProvider
118
+ resource={{projectId: 'test-project', dataset: 'test-dataset'}}
119
+ fallback={null}
120
+ >
121
+ {children}
122
+ </ResourceProvider>
123
+ ),
124
+ },
125
+ )
126
+
127
+ expect(result.current.locations).toEqual([])
128
+ unmount()
129
+ })
83
130
  })
@@ -1,19 +1,31 @@
1
- import {getPresence, type UserPresence} from '@sanity/sdk'
1
+ import {getPresence, isMediaLibraryResource, type UserPresence} from '@sanity/sdk'
2
2
  import {useCallback, useMemo, useSyncExternalStore} from 'react'
3
3
 
4
+ import {type ResourceHandle} from '../../config/handles'
4
5
  import {useSanityInstance} from '../context/useSanityInstance'
6
+ import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
5
7
  import {trackHookUsage} from '../helpers/useTrackHookUsage'
6
8
 
7
9
  /**
8
- * A hook for subscribing to presence information for the current project.
10
+ * A hook for subscribing to presence information for the current project or Canvas.
9
11
  * @public
10
12
  */
11
- export function usePresence(): {
13
+ export function usePresence(options: ResourceHandle = {}): {
12
14
  locations: UserPresence[]
13
15
  } {
16
+ const normalizedOptions = useNormalizedResourceOptions(options)
17
+ if (normalizedOptions.resource && isMediaLibraryResource(normalizedOptions.resource)) {
18
+ throw new Error(
19
+ 'usePresence() does not support media library resources. Presence tracking requires a canvas or dataset resource.',
20
+ )
21
+ }
22
+
14
23
  const sanityInstance = useSanityInstance()
15
24
  trackHookUsage(sanityInstance, 'usePresence')
16
- const source = useMemo(() => getPresence(sanityInstance), [sanityInstance])
25
+ const source = useMemo(
26
+ () => getPresence(sanityInstance, normalizedOptions),
27
+ [sanityInstance, normalizedOptions],
28
+ )
17
29
  const subscribe = useCallback((callback: () => void) => source.subscribe(callback), [source])
18
30
  const locations = useSyncExternalStore(
19
31
  subscribe,
@@ -1,5 +1,4 @@
1
1
  import {
2
- type DocumentHandle,
3
2
  PREVIEW_PROJECTION,
4
3
  type PreviewQueryResult,
5
4
  type PreviewValue,
@@ -7,11 +6,9 @@ import {
7
6
  } from '@sanity/sdk'
8
7
  import {useMemo} from 'react'
9
8
 
9
+ import {type DocumentHandle} from '../../config/handles'
10
10
  import {useSanityInstance} from '../context/useSanityInstance'
11
- import {
12
- useNormalizedSourceOptions,
13
- type WithSourceNameSupport,
14
- } from '../helpers/useNormalizedSourceOptions'
11
+ import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
15
12
  import {trackHookUsage} from '../helpers/useTrackHookUsage'
16
13
  import {useDocumentProjection} from '../projection/useDocumentProjection'
17
14
 
@@ -19,7 +16,7 @@ import {useDocumentProjection} from '../projection/useDocumentProjection'
19
16
  * @public
20
17
  * @category Types
21
18
  */
22
- export interface useDocumentPreviewOptions extends WithSourceNameSupport<DocumentHandle> {
19
+ export interface useDocumentPreviewOptions extends DocumentHandle {
23
20
  /**
24
21
  * Optional ref object to track visibility. When provided, preview resolution
25
22
  * only occurs when the referenced element is visible in the viewport.
@@ -98,9 +95,9 @@ export function useDocumentPreview({
98
95
  ref,
99
96
  ...docHandle
100
97
  }: useDocumentPreviewOptions): useDocumentPreviewResults {
101
- const instance = useSanityInstance(docHandle)
98
+ const instance = useSanityInstance()
102
99
  trackHookUsage(instance, 'useDocumentPreview')
103
- const normalizedDocHandle = useNormalizedSourceOptions(docHandle)
100
+ const normalizedDocHandle = useNormalizedResourceOptions(docHandle)
104
101
 
105
102
  // Use the projection hook with the fixed preview projection
106
103
  const projectionResult = useDocumentProjection<PreviewQueryResult>({
@@ -112,8 +109,9 @@ export function useDocumentPreview({
112
109
  // Contract: useDocumentProjection suspends while data is null, so data is always available here.
113
110
  // Keep this non-null assumption aligned with useDocumentPreviewResults.data.
114
111
  const previewValue = useMemo(
115
- () => transformProjectionToPreview(instance, projectionResult.data, normalizedDocHandle.source),
116
- [projectionResult.data, instance, normalizedDocHandle.source],
112
+ () =>
113
+ transformProjectionToPreview(instance, projectionResult.data, normalizedDocHandle.resource),
114
+ [projectionResult.data, instance, normalizedDocHandle.resource],
117
115
  )
118
116
 
119
117
  return {
@@ -1,13 +1,11 @@
1
- import {type DocumentHandle, getProjectionState, resolveProjection} from '@sanity/sdk'
1
+ import {getProjectionState, resolveProjection} from '@sanity/sdk'
2
2
  import {type SanityProjectionResult} from 'groq'
3
3
  import {useCallback, useMemo, useSyncExternalStore} from 'react'
4
4
  import {distinctUntilChanged, EMPTY, Observable, startWith, switchMap} from 'rxjs'
5
5
 
6
+ import {type DocumentHandle} from '../../config/handles'
6
7
  import {useSanityInstance} from '../context/useSanityInstance'
7
- import {
8
- useNormalizedSourceOptions,
9
- type WithSourceNameSupport,
10
- } from '../helpers/useNormalizedSourceOptions'
8
+ import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
11
9
  import {trackHookUsage} from '../helpers/useTrackHookUsage'
12
10
 
13
11
  /**
@@ -19,7 +17,7 @@ export interface useDocumentProjectionOptions<
19
17
  TDocumentType extends string = string,
20
18
  TDataset extends string = string,
21
19
  TProjectId extends string = string,
22
- > extends WithSourceNameSupport<DocumentHandle<TDocumentType, TDataset, TProjectId>> {
20
+ > extends DocumentHandle<TDocumentType, TDataset, TProjectId> {
23
21
  /** The GROQ projection string */
24
22
  projection: TProjection
25
23
  /** Optional parameters for the projection query */
@@ -181,7 +179,7 @@ export function useDocumentProjection<TData extends object>({
181
179
  projection,
182
180
  ...docHandle
183
181
  }: useDocumentProjectionOptions): useDocumentProjectionResults<TData> {
184
- const instance = useSanityInstance(docHandle)
182
+ const instance = useSanityInstance()
185
183
  trackHookUsage(instance, 'useDocumentProjection')
186
184
 
187
185
  // Normalize projection string to handle template literals with whitespace
@@ -189,8 +187,8 @@ export function useDocumentProjection<TData extends object>({
189
187
  // even if the string reference changes (e.g., from inline template literals)
190
188
  const normalizedProjection = useMemo(() => projection.trim(), [projection])
191
189
 
192
- // Normalize options: resolve sourceName to source and strip sourceName
193
- const normalizedDocHandle = useNormalizedSourceOptions(docHandle)
190
+ // Normalize options: resolve resourceName to resource and strip resourceName
191
+ const normalizedDocHandle = useNormalizedResourceOptions(docHandle)
194
192
 
195
193
  // Memoize stateSource based on normalized projection and docHandle properties
196
194
  // This prevents creating a new StateSource on every render when projection content is the same
@@ -0,0 +1,49 @@
1
+ import {type Project, type ProjectMember} from '@sanity/sdk'
2
+ import {expectTypeOf, test} from 'vitest'
3
+
4
+ import {useProject} from './useProject'
5
+
6
+ test('useProject — no args: members and features both included by default', () => {
7
+ expectTypeOf(useProject()).toEqualTypeOf<Project<true, true>>()
8
+ type Result = ReturnType<typeof useProject<true, true>>
9
+ expectTypeOf<Result['members']>().toEqualTypeOf<ProjectMember[]>()
10
+ })
11
+
12
+ test('useProject — includeMembers: false drops members from the type', () => {
13
+ expectTypeOf(useProject({includeMembers: false})).toEqualTypeOf<Project<false, true>>()
14
+ })
15
+
16
+ test('useProject — includeFeatures: false drops features from the type', () => {
17
+ expectTypeOf(useProject({includeFeatures: false})).toEqualTypeOf<Project<true, false>>()
18
+ })
19
+
20
+ test('useProject — both flags true → both arrays present', () => {
21
+ expectTypeOf(useProject({includeMembers: true, includeFeatures: true})).toEqualTypeOf<
22
+ Project<true, true>
23
+ >()
24
+ })
25
+
26
+ test('useProject — both flags false → bare base shape', () => {
27
+ expectTypeOf(useProject({includeMembers: false, includeFeatures: false})).toEqualTypeOf<
28
+ Project<false, false>
29
+ >()
30
+ type Result = ReturnType<typeof useProject<false, false>>
31
+ expectTypeOf<Result['id']>().toEqualTypeOf<string>()
32
+ })
33
+
34
+ test('useProject — rejects non-boolean flag values', () => {
35
+ // @ts-expect-error — includeMembers must be a boolean
36
+ void useProject({includeMembers: 'yes'})
37
+ })
38
+
39
+ test('useProject — projectId alone does not change the data shape', () => {
40
+ expectTypeOf(useProject({projectId: 'p'})).toEqualTypeOf<Project<true, true>>()
41
+ })
42
+
43
+ test('useProject — non-literal boolean flag makes members optional', () => {
44
+ const includeMembers = false as boolean
45
+ expectTypeOf(useProject({includeMembers})).toEqualTypeOf<Project<boolean, true>>()
46
+ type Result = ReturnType<typeof useProject<boolean, true>>
47
+ expectTypeOf<Result['members']>().toEqualTypeOf<ProjectMember[] | undefined>()
48
+ expectTypeOf<Pick<Result, 'members'>>().toEqualTypeOf<{members?: ProjectMember[]}>()
49
+ })
@@ -1,51 +1,43 @@
1
- import {
2
- getProjectState,
3
- type ProjectHandle,
4
- resolveProject,
5
- type SanityInstance,
6
- type SanityProject,
7
- type StateSource,
8
- } from '@sanity/sdk'
1
+ import {getProjectState, type Project, type ProjectOptions, resolveProject} from '@sanity/sdk'
9
2
  import {identity} from 'rxjs'
10
3
 
11
4
  import {createStateSourceHook} from '../helpers/createStateSourceHook'
12
5
 
13
- type UseProject = {
14
- /**
15
- *
16
- * Returns metadata for a given project
17
- *
18
- * @category Projects
19
- * @param projectId - The ID of the project to retrieve metadata for
20
- * @returns The metadata for the project
21
- * @example
22
- * ```tsx
23
- * function ProjectMetadata({ projectId }: { projectId: string }) {
24
- * const project = useProject(projectId)
25
- *
26
- * return (
27
- * <figure style={{ backgroundColor: project.metadata.color || 'lavender'}}>
28
- * <h1>{project.displayName}</h1>
29
- * </figure>
30
- * )
31
- * }
32
- * ```
33
- */
34
- (projectHandle?: ProjectHandle): SanityProject
35
- }
36
-
37
6
  /**
7
+ * Returns metadata for a given project.
8
+ *
9
+ * @category Projects
10
+ * @param options - Configuration options
11
+ * @returns The metadata for the project. `members` is included only when
12
+ * `includeMembers: true`; `features` is included unless `includeFeatures: false`.
13
+ * @example
14
+ * ```tsx
15
+ * function ProjectMetadata({projectId}: {projectId: string}) {
16
+ * const project = useProject({projectId})
17
+ *
18
+ * return (
19
+ * <figure style={{backgroundColor: project.metadata.color || 'lavender'}}>
20
+ * <h1>{project.displayName}</h1>
21
+ * </figure>
22
+ * )
23
+ * }
24
+ * ```
25
+ * @example
26
+ * ```tsx
27
+ * const projectWithMembersAndFeatures = useProject({projectId})
28
+ * const projectWithMembers = useProject({projectId, includeMembers: true})
29
+ * const projectWithoutMembers = useProject({projectId, includeMembers: false})
30
+ * const projectWithoutFeatures = useProject({projectId, includeFeatures: false})
31
+ * ```
38
32
  * @public
39
33
  * @function
40
34
  */
41
- export const useProject: UseProject = createStateSourceHook({
42
- // remove `undefined` since we're suspending when that is the case
43
- getState: getProjectState as (
44
- instance: SanityInstance,
45
- projectHandle?: ProjectHandle,
46
- ) => StateSource<SanityProject>,
47
- shouldSuspend: (instance, projectHandle) =>
48
- getProjectState(instance, projectHandle).getCurrent() === undefined,
35
+ export const useProject = createStateSourceHook({
36
+ getState: getProjectState,
37
+ shouldSuspend: (instance, ...params) =>
38
+ getProjectState(instance, ...params).getCurrent() === undefined,
49
39
  suspender: resolveProject,
50
40
  getConfig: identity,
51
- })
41
+ }) as <IncludeMembers extends boolean = true, IncludeFeatures extends boolean = true>(
42
+ options?: ProjectOptions<IncludeMembers, IncludeFeatures>,
43
+ ) => Project<IncludeMembers, IncludeFeatures>
@@ -0,0 +1,49 @@
1
+ import {type Project, type ProjectMember} from '@sanity/sdk'
2
+ import {expectTypeOf, test} from 'vitest'
3
+
4
+ import {useProjects} from './useProjects'
5
+
6
+ test('useProjects — no args: features included, members omitted', () => {
7
+ expectTypeOf(useProjects()).toEqualTypeOf<Project<false, true>[]>()
8
+ })
9
+
10
+ test('useProjects — includeMembers: true adds members to the type', () => {
11
+ expectTypeOf(useProjects({includeMembers: true})).toEqualTypeOf<Project<true, true>[]>()
12
+ type Result = ReturnType<typeof useProjects<true, true>>
13
+ expectTypeOf<Result[number]['members']>().toEqualTypeOf<ProjectMember[]>()
14
+ })
15
+
16
+ test('useProjects — includeFeatures: false drops features from the type', () => {
17
+ expectTypeOf(useProjects({includeFeatures: false})).toEqualTypeOf<Project<false, false>[]>()
18
+ })
19
+
20
+ test('useProjects — both flags true → both arrays present', () => {
21
+ expectTypeOf(useProjects({includeMembers: true, includeFeatures: true})).toEqualTypeOf<
22
+ Project<true, true>[]
23
+ >()
24
+ })
25
+
26
+ test('useProjects — both flags false → bare base shape', () => {
27
+ expectTypeOf(useProjects({includeMembers: false, includeFeatures: false})).toEqualTypeOf<
28
+ Project<false, false>[]
29
+ >()
30
+ type Result = ReturnType<typeof useProjects<false, false>>
31
+ expectTypeOf<Result[number]['id']>().toEqualTypeOf<string>()
32
+ })
33
+
34
+ test('useProjects — rejects non-boolean flag values', () => {
35
+ // @ts-expect-error — includeMembers must be a boolean
36
+ void useProjects({includeMembers: 'yes'})
37
+ })
38
+
39
+ test('useProjects — organizationId alone does not change the data shape', () => {
40
+ expectTypeOf(useProjects({organizationId: 'org_123'})).toEqualTypeOf<Project<false, true>[]>()
41
+ })
42
+
43
+ test('useProjects — non-literal boolean flag makes members optional', () => {
44
+ const includeMembers = false as boolean
45
+ expectTypeOf(useProjects({includeMembers})).toEqualTypeOf<Project<boolean, true>[]>()
46
+ type Result = ReturnType<typeof useProjects<boolean, true>>
47
+ expectTypeOf<Result[number]['members']>().toEqualTypeOf<ProjectMember[] | undefined>()
48
+ expectTypeOf<Pick<Result[number], 'members'>>().toEqualTypeOf<{members?: ProjectMember[]}>()
49
+ })