@sanity/sdk-react 3.0.0-rc.0 → 3.0.0-rc.2

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 (168) hide show
  1. package/README.md +103 -155
  2. package/dist/index.d.ts +2488 -2019
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +4435 -1529
  5. package/dist/index.js.map +1 -1
  6. package/package.json +55 -59
  7. package/src/_exports/index.ts +2 -4
  8. package/src/_exports/sdk-react.ts +30 -18
  9. package/src/components/SDKProvider.test.tsx +173 -32
  10. package/src/components/SDKProvider.tsx +87 -28
  11. package/src/components/SanityApp.test.tsx +68 -12
  12. package/src/components/SanityApp.tsx +93 -87
  13. package/src/components/auth/AuthBoundary.recovery.test.tsx +86 -0
  14. package/src/components/auth/AuthBoundary.test.tsx +7 -7
  15. package/src/components/auth/AuthBoundary.tsx +29 -6
  16. package/src/components/auth/DashboardAccessRequest.tsx +37 -0
  17. package/src/components/auth/LoginError.test.tsx +191 -5
  18. package/src/components/auth/LoginError.tsx +104 -61
  19. package/src/components/errors/ChunkLoadError.test.tsx +59 -0
  20. package/src/components/errors/ChunkLoadError.tsx +56 -0
  21. package/src/components/errors/CorsErrorComponent.tsx +2 -2
  22. package/src/components/errors/chunkReloadStorage.ts +57 -0
  23. package/src/config/handles.ts +29 -27
  24. package/src/constants.ts +5 -0
  25. package/src/context/ComlinkTokenRefresh.test.tsx +10 -27
  26. package/src/context/ComlinkTokenRefresh.tsx +3 -5
  27. package/src/context/DefaultResourceContext.ts +3 -3
  28. package/src/context/OrganizationResourcesProvider.test.tsx +189 -0
  29. package/src/context/OrganizationResourcesProvider.tsx +111 -0
  30. package/src/context/PerspectiveContext.ts +3 -3
  31. package/src/context/ProjectContext.ts +15 -0
  32. package/src/context/ResourceProvider.test.tsx +93 -90
  33. package/src/context/ResourceProvider.tsx +62 -93
  34. package/src/context/SDKStudioContext.test.tsx +28 -33
  35. package/src/context/SanityInstanceProvider.test.tsx +100 -0
  36. package/src/context/SanityInstanceProvider.tsx +71 -0
  37. package/src/context/WorkbenchTokenRefresh.test.tsx +106 -0
  38. package/src/context/WorkbenchTokenRefresh.tsx +61 -0
  39. package/src/context/renderSanityApp.test.tsx +151 -49
  40. package/src/context/renderSanityApp.tsx +12 -8
  41. package/src/context/workbenchToken.ts +63 -0
  42. package/src/hooks/access/useCheckPermissions.test-d.ts +15 -0
  43. package/src/hooks/access/useCheckPermissions.test.tsx +53 -0
  44. package/src/hooks/access/useCheckPermissions.ts +24 -0
  45. package/src/hooks/agent/agentActions.test.tsx +2 -2
  46. package/src/hooks/agent/agentActions.ts +52 -72
  47. package/src/hooks/agent/useAgentResourceContext.ts +1 -1
  48. package/src/hooks/applications/useApplication.test-d.ts +30 -0
  49. package/src/hooks/applications/useApplication.ts +22 -0
  50. package/src/hooks/applications/useApplications.test-d.ts +31 -0
  51. package/src/hooks/applications/useApplications.ts +25 -0
  52. package/src/hooks/applications/useDeleteApplication.test-d.ts +12 -0
  53. package/src/hooks/applications/useDeleteApplication.ts +11 -0
  54. package/src/hooks/applications/useUpdateApplication.test-d.ts +12 -0
  55. package/src/hooks/applications/useUpdateApplication.ts +11 -0
  56. package/src/hooks/auth/useDashboardOrganizationId.test.tsx +2 -8
  57. package/src/hooks/auth/useVerifyOrgProjects.test.tsx +8 -32
  58. package/src/hooks/auth/useVerifyOrgProjects.tsx +13 -6
  59. package/src/hooks/client/useClient.test.tsx +1 -4
  60. package/src/hooks/client/useClient.ts +1 -0
  61. package/src/hooks/comlink/useFrameConnection.test.tsx +3 -2
  62. package/src/hooks/comlink/useFrameConnection.ts +1 -1
  63. package/src/hooks/comlink/useWindowConnection.test.tsx +4 -3
  64. package/src/hooks/comlink/useWindowConnection.ts +2 -3
  65. package/src/hooks/comments/useCommentActions.test.tsx +150 -0
  66. package/src/hooks/comments/useCommentActions.ts +109 -0
  67. package/src/hooks/comments/useCommentList.ts +79 -0
  68. package/src/hooks/comments/useCommentThreads.test.tsx +107 -0
  69. package/src/hooks/comments/useCommentThreads.ts +73 -0
  70. package/src/hooks/comments/useComments.test.tsx +242 -0
  71. package/src/hooks/comments/useComments.ts +59 -0
  72. package/src/hooks/context/useResource.test.tsx +32 -0
  73. package/src/hooks/context/useResource.ts +24 -0
  74. package/src/hooks/context/useSanityInstance.test.tsx +18 -1
  75. package/src/hooks/context/useSanityInstance.ts +6 -6
  76. package/src/hooks/dashboard/useManageFavorite.test.tsx +110 -46
  77. package/src/hooks/dashboard/useManageFavorite.ts +11 -17
  78. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +5 -7
  79. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +2 -6
  80. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.test.ts +0 -2
  81. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.ts +2 -2
  82. package/src/hooks/dashboard/useWindowTitle.test.ts +244 -0
  83. package/src/hooks/dashboard/useWindowTitle.ts +114 -0
  84. package/src/hooks/datasets/useDatasets.test.tsx +116 -0
  85. package/src/hooks/datasets/useDatasets.ts +33 -13
  86. package/src/hooks/document/useApplyDocumentActions.test.tsx +48 -0
  87. package/src/hooks/document/useApplyDocumentActions.ts +66 -48
  88. package/src/hooks/document/useCreateDocument.test.tsx +83 -0
  89. package/src/hooks/document/useCreateDocument.ts +117 -0
  90. package/src/hooks/document/useDocument.test.tsx +37 -8
  91. package/src/hooks/document/useDocument.ts +131 -66
  92. package/src/hooks/document/useDocumentEvent.test.tsx +16 -4
  93. package/src/hooks/document/useDocumentEvent.ts +16 -10
  94. package/src/hooks/document/useDocumentPermissions.test.tsx +8 -7
  95. package/src/hooks/document/useDocumentPermissions.ts +71 -39
  96. package/src/hooks/document/useDocumentSyncStatus.test.ts +1 -0
  97. package/src/hooks/document/useDocumentSyncStatus.ts +13 -9
  98. package/src/hooks/document/useEditDocument.test.tsx +21 -14
  99. package/src/hooks/document/useEditDocument.ts +163 -17
  100. package/src/hooks/documents/useDocuments.test.tsx +64 -44
  101. package/src/hooks/documents/useDocuments.ts +31 -20
  102. package/src/hooks/helpers/createCallbackHook.test.tsx +13 -19
  103. package/src/hooks/helpers/createFetcherHook.test.tsx +180 -0
  104. package/src/hooks/helpers/createFetcherHook.ts +69 -0
  105. package/src/hooks/helpers/createMutationHook.test.tsx +125 -0
  106. package/src/hooks/helpers/createMutationHook.tsx +93 -0
  107. package/src/hooks/helpers/createStateSourceHook.test.tsx +10 -10
  108. package/src/hooks/helpers/createStateSourceHook.tsx +2 -1
  109. package/src/hooks/{document/useApplyDocumentActions.test.ts → helpers/useApplyActions.test.tsx} +92 -53
  110. package/src/hooks/helpers/useApplyActions.ts +68 -0
  111. package/src/hooks/helpers/useNormalizedResourceOptions.test.tsx +226 -0
  112. package/src/hooks/helpers/useNormalizedResourceOptions.ts +79 -48
  113. package/src/hooks/helpers/useResolvedProjectId.test.tsx +59 -0
  114. package/src/hooks/helpers/useResolvedProjectId.ts +35 -0
  115. package/src/hooks/helpers/useTrackHookUsage.ts +37 -0
  116. package/src/hooks/installations/useInstallation.test-d.ts +19 -0
  117. package/src/hooks/installations/useInstallation.ts +22 -0
  118. package/src/hooks/installations/useInstallations.test-d.ts +26 -0
  119. package/src/hooks/installations/useInstallations.ts +25 -0
  120. package/src/hooks/organizations/useOrganization.test-d.ts +60 -0
  121. package/src/hooks/organizations/useOrganization.test.ts +63 -0
  122. package/src/hooks/organizations/useOrganization.ts +34 -0
  123. package/src/hooks/organizations/useOrganizations.test-d.ts +68 -0
  124. package/src/hooks/organizations/useOrganizations.test.ts +63 -0
  125. package/src/hooks/organizations/useOrganizations.ts +39 -0
  126. package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +34 -27
  127. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +35 -22
  128. package/src/hooks/presence/usePresence.test.tsx +0 -15
  129. package/src/hooks/presence/usePresence.ts +22 -20
  130. package/src/hooks/presence/usePresenceForDocument.test.tsx +141 -0
  131. package/src/hooks/presence/usePresenceForDocument.ts +104 -0
  132. package/src/hooks/presence/useReportPresence.test.tsx +202 -0
  133. package/src/hooks/presence/useReportPresence.ts +170 -0
  134. package/src/hooks/preview/useDocumentPreview.test.tsx +0 -1
  135. package/src/hooks/preview/useDocumentPreview.tsx +12 -14
  136. package/src/hooks/projection/useDocumentProjection.test.tsx +78 -9
  137. package/src/hooks/projection/useDocumentProjection.ts +81 -2
  138. package/src/hooks/projects/useProject.test-d.ts +56 -0
  139. package/src/hooks/projects/useProject.test.tsx +120 -0
  140. package/src/hooks/projects/useProject.ts +42 -43
  141. package/src/hooks/projects/useProjects.test-d.ts +60 -0
  142. package/src/hooks/projects/useProjects.test.ts +45 -98
  143. package/src/hooks/projects/useProjects.ts +17 -32
  144. package/src/hooks/query/useQuery.test.tsx +6 -5
  145. package/src/hooks/query/useQuery.ts +94 -17
  146. package/src/hooks/releases/useActiveReleases.test.tsx +13 -7
  147. package/src/hooks/releases/useActiveReleases.ts +28 -24
  148. package/src/hooks/releases/useAllReleases.test.tsx +93 -0
  149. package/src/hooks/releases/useAllReleases.ts +62 -0
  150. package/src/hooks/releases/useApplyReleaseActions.test.tsx +66 -0
  151. package/src/hooks/releases/useApplyReleaseActions.ts +82 -0
  152. package/src/hooks/releases/usePerspective.test.tsx +18 -10
  153. package/src/hooks/releases/usePerspective.ts +13 -17
  154. package/src/hooks/users/useUser.test.tsx +3 -9
  155. package/src/hooks/users/useUser.ts +4 -8
  156. package/src/hooks/users/useUsers.test.tsx +103 -7
  157. package/src/hooks/users/useUsers.ts +38 -5
  158. package/src/utils/resolveOrgResources.test.ts +111 -0
  159. package/src/utils/resolveOrgResources.ts +69 -0
  160. package/src/hooks/context/useDefaultResource.test.tsx +0 -25
  161. package/src/hooks/context/useDefaultResource.ts +0 -30
  162. package/src/hooks/dashboard/useDispatchIntent.test.ts +0 -254
  163. package/src/hooks/dashboard/useDispatchIntent.ts +0 -157
  164. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.test.ts +0 -107
  165. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +0 -39
  166. package/src/hooks/datasets/useDatasets.test.ts +0 -66
  167. package/src/hooks/helpers/useNormalizedResourceOptions.test.ts +0 -65
  168. package/src/hooks/projects/useProject.test.ts +0 -79
@@ -1,18 +1,23 @@
1
1
  import {type DocumentResource, type PerspectiveHandle} from '@sanity/sdk'
2
- import {useContext} from 'react'
2
+ import {useContext, useMemo} from 'react'
3
3
 
4
4
  import {ResourceContext} from '../../context/DefaultResourceContext'
5
5
  import {PerspectiveContext} from '../../context/PerspectiveContext'
6
6
  import {ResourcesContext} from '../../context/ResourcesContext'
7
+ import {SanityInstanceContext} from '../../context/SanityInstanceContext'
8
+
9
+ type NormalizedResourceFields = 'resourceName' | 'projectId' | 'dataset'
7
10
 
8
11
  /**
9
- * You should generally prefer to use the React-layer handle types (ResourceHandle, DocumentHandle) from '\@sanity/sdk-react' instead.
10
- * This type is useful for non-handles (like document actions) that we still want to resolve resources for.
11
12
  * Adds React hook support (resourceName resolution) to core types.
12
- * @internal
13
+ * Prefer using the React-layer handle types (ResourceHandle, DocumentHandle)
14
+ * from `@sanity/sdk-react` — this wrapper is kept for cases where overloads
15
+ * don't fit (e.g. non-handle options objects).
16
+ *
17
+ * @typeParam T - The core type to extend (must have optional `resource` field)
18
+ * @beta
13
19
  */
14
- export type WithResourceNameSupport<T> = Omit<T, 'resource'> & {
15
- resource?: DocumentResource
20
+ export type WithResourceNameSupport<T extends {resource?: DocumentResource}> = T & {
16
21
  /**
17
22
  * Optional name of a resource to resolve from context.
18
23
  * If provided, will be resolved to a `DocumentResource` via `ResourcesContext`.
@@ -23,22 +28,23 @@ export type WithResourceNameSupport<T> = Omit<T, 'resource'> & {
23
28
 
24
29
  /**
25
30
  * Pure function that normalizes options by resolving `resourceName` to a `DocumentResource`
26
- * using the provided resources map, and injecting defaults from context when not provided.
27
- * Use this when options are only available at call time (e.g. inside a callback)
28
- * and you cannot call the {@link useNormalizedResourceOptions} hook.
31
+ * using the provided resources map. Use this when options are only available at call time
32
+ * (e.g. inside a callback) and you cannot call the {@link useNormalizedResourceOptions} hook.
29
33
  *
30
34
  * @typeParam T - The options type (must include optional resource field)
31
35
  * @param options - Options that may include `resourceName` and/or `resource`
32
36
  * @param resources - Map of resource names to DocumentResource (e.g. from ResourcesContext)
33
- * @param contextResource - Resource from context (injected by ResourceProvider)
34
- * @param contextPerspective - Perspective from context (injected by ResourceProvider)
35
- * @returns Normalized options with `resourceName` removed and defaults injected
37
+ * @param contextResource - Resource from context (from ResourceContext)
38
+ * @param contextPerspective - Perspective from context (from PerspectiveContext)
39
+ * @returns Normalized options with `resourceName` removed and `resource` resolved
36
40
  * @internal
37
41
  */
38
42
  export function normalizeResourceOptions<
39
43
  T extends {
40
44
  resource?: DocumentResource
41
45
  resourceName?: string
46
+ projectId?: string
47
+ dataset?: string
42
48
  perspective?: unknown
43
49
  },
44
50
  >(
@@ -46,70 +52,87 @@ export function normalizeResourceOptions<
46
52
  resources: Record<string, DocumentResource>,
47
53
  contextResource?: DocumentResource,
48
54
  contextPerspective?: PerspectiveHandle['perspective'],
49
- ): Omit<T, 'resourceName' | 'resource'> & {resource: DocumentResource} {
50
- const {resourceName, ...rest} = options
55
+ ): Omit<T, NormalizedResourceFields> {
56
+ const {resourceName, projectId, dataset, ...rest} = options
57
+ const resource = options.resource
51
58
 
52
- if (resourceName && Object.hasOwn(options, 'resource')) {
59
+ if (resourceName && resource) {
53
60
  throw new Error(
54
- `Resource name ${JSON.stringify(resourceName)} and resource ${JSON.stringify(options.resource)} cannot be used together.`,
61
+ `Resource name ${JSON.stringify(resourceName)} and resource ${JSON.stringify(resource)} cannot be used together.`,
55
62
  )
56
63
  }
57
64
 
58
- let resolvedResource: DocumentResource | undefined = options.resource
65
+ let resolvedResource: DocumentResource | undefined
59
66
 
60
- if (!resolvedResource && resourceName) {
67
+ // Tier (a): explicit resource object or resourceName lookup
68
+ if (resource) {
69
+ resolvedResource = resource
70
+ } else if (resourceName) {
61
71
  if (!Object.hasOwn(resources, resourceName)) {
62
72
  throw new Error(
63
- `There's no resource named ${JSON.stringify(resourceName)} in context. ` +
64
- 'Register it via the resources prop on <SanityApp>.',
73
+ `There's no resource named ${JSON.stringify(resourceName)} in context. Please use <ResourceProvider>.`,
65
74
  )
66
75
  }
67
76
  resolvedResource = resources[resourceName]
68
77
  }
69
78
 
70
- if (!resolvedResource) {
71
- resolvedResource = contextResource
79
+ // Tier (b): projectId or dataset in options → synthesize a resource
80
+ if (!resolvedResource && projectId && dataset) {
81
+ resolvedResource = {
82
+ projectId,
83
+ dataset,
84
+ }
72
85
  }
73
86
 
74
- if (resolvedResource === undefined) {
75
- throw new Error(
76
- 'A resource is required. Provide `resource`, `resourceName`, or ensure a default resource is available from context (e.g. via <ResourceProvider> or <SanityApp>).',
77
- )
87
+ // Tier (c): fall back to whatever ResourceContext provides
88
+ if (!resolvedResource) {
89
+ resolvedResource = contextResource
78
90
  }
79
91
 
92
+ // Inject perspective from context when not explicitly provided in options
80
93
  const resolvedPerspective = Object.hasOwn(options, 'perspective')
81
94
  ? options.perspective
82
95
  : contextPerspective
83
96
 
84
97
  return {
85
98
  ...rest,
86
- resource: resolvedResource,
99
+ ...(resolvedResource !== undefined && {resource: resolvedResource}),
87
100
  ...(resolvedPerspective !== undefined && {perspective: resolvedPerspective}),
88
- } as Omit<T, 'resourceName' | 'resource'> & {resource: DocumentResource}
101
+ }
89
102
  }
90
103
 
91
104
  /**
92
- * Normalizes hook options by resolving `resourceName` to a `DocumentResource`
93
- * and injecting resource/perspective from context.
94
- *
95
- * This hook ensures that options passed to core layer functions contain
96
- * the correct `resource` and `perspective` values, maintaining clean
97
- * separation between React and core layers.
105
+ * Returns the effective context resource: the `ResourceContext` value if set,
106
+ * otherwise a resource synthesized from the current `SanityInstance` config
107
+ * (tier-d fallback — returns `undefined` for studio-style configs with no project).
98
108
  *
99
- * @typeParam T - The options type (must include optional resource field)
100
- * @param options - Hook options that may include `resourceName` and/or `resource`
101
- * @returns Normalized options with `resourceName` removed and defaults injected
109
+ * @internal
110
+ */
111
+ export function useEffectiveContextResource(): DocumentResource | undefined {
112
+ const contextResource = useContext(ResourceContext)
113
+ const instance = useContext(SanityInstanceContext)
114
+ const {projectId, dataset, resource: configResource} = instance?.config ?? {}
115
+
116
+ return useMemo(() => {
117
+ if (contextResource) return contextResource
118
+ if (configResource) return configResource
119
+ if (projectId && dataset) return {projectId, dataset}
120
+ return undefined
121
+ }, [contextResource, configResource, projectId, dataset])
122
+ }
123
+
124
+ /**
125
+ * Normalizes hook options by resolving `resourceName` to a `DocumentResource`.
102
126
  *
103
- * @remarks
104
127
  * Resolution priority for resource:
105
- * 1. If both `resourceName` and `resource` are provided, throws an error
106
- * 2. If `resource` is provided, uses it directly
107
- * 3. If `resourceName` is provided, resolves it via `ResourcesContext`
108
- * 4. If neither is provided, injects the value from `ResourceContext`
128
+ * 1. Explicit `resource` or `resourceName` in options
129
+ * 2. Bare `projectId`/`dataset` pair in options synthesized into a resource
130
+ * 3. `ResourceContext` value (set by `ResourceProvider` / `SDKProvider`)
131
+ * 4. Current `SanityInstance` config falls back to `undefined` for studio configs
109
132
  *
110
133
  * Resolution priority for perspective:
111
- * 1. If `perspective` is explicitly provided in options, uses it
112
- * 2. Otherwise, injects the value from `PerspectiveContext`
134
+ * 1. Explicit `perspective` in options
135
+ * 2. `PerspectiveContext` value (set by `ResourceProvider`)
113
136
  *
114
137
  * @internal
115
138
  */
@@ -117,11 +140,19 @@ export function useNormalizedResourceOptions<
117
140
  T extends {
118
141
  resource?: DocumentResource
119
142
  resourceName?: string
120
- perspective?: unknown
143
+ projectId?: string
144
+ dataset?: string
145
+ perspective?: PerspectiveHandle['perspective']
121
146
  },
122
- >(options: T): Omit<T, 'resourceName' | 'resource'> & {resource: DocumentResource} {
147
+ >(
148
+ options: T,
149
+ ): Omit<T, NormalizedResourceFields> & {
150
+ resource?: DocumentResource
151
+ perspective?: PerspectiveHandle['perspective']
152
+ } {
123
153
  const resources = useContext(ResourcesContext)
124
- const contextResource = useContext(ResourceContext)
154
+ const effectiveContextResource = useEffectiveContextResource()
125
155
  const contextPerspective = useContext(PerspectiveContext)
126
- return normalizeResourceOptions(options, resources, contextResource, contextPerspective)
156
+
157
+ return normalizeResourceOptions(options, resources, effectiveContextResource, contextPerspective)
127
158
  }
@@ -0,0 +1,59 @@
1
+ import {type DocumentResource} from '@sanity/sdk'
2
+ import {renderHook} from '@testing-library/react'
3
+ import {type ReactNode} from 'react'
4
+ import {describe, expect, it} from 'vitest'
5
+
6
+ import {ResourceContext} from '../../context/DefaultResourceContext'
7
+ import {ProjectContext} from '../../context/ProjectContext'
8
+ import {useResolvedProjectId} from './useResolvedProjectId'
9
+
10
+ const datasetResource: DocumentResource = {projectId: 'resource-project', dataset: 'production'}
11
+
12
+ describe('useResolvedProjectId', () => {
13
+ it('prefers an explicit projectId on the options', () => {
14
+ const {result} = renderHook(() => useResolvedProjectId({projectId: 'option-project'}), {
15
+ wrapper: ({children}: {children: ReactNode}) => (
16
+ <ResourceContext.Provider value={datasetResource}>
17
+ <ProjectContext.Provider value="context-project">{children}</ProjectContext.Provider>
18
+ </ResourceContext.Provider>
19
+ ),
20
+ })
21
+ expect(result.current).toBe('option-project')
22
+ })
23
+
24
+ it('falls back to the ambient project scope (ProjectContext) over the resource', () => {
25
+ const {result} = renderHook(() => useResolvedProjectId(), {
26
+ wrapper: ({children}: {children: ReactNode}) => (
27
+ <ResourceContext.Provider value={datasetResource}>
28
+ <ProjectContext.Provider value="context-project">{children}</ProjectContext.Provider>
29
+ </ResourceContext.Provider>
30
+ ),
31
+ })
32
+ expect(result.current).toBe('context-project')
33
+ })
34
+
35
+ it('falls back to the resolved resource projectId', () => {
36
+ const {result} = renderHook(() => useResolvedProjectId(), {
37
+ wrapper: ({children}: {children: ReactNode}) => (
38
+ <ResourceContext.Provider value={datasetResource}>{children}</ResourceContext.Provider>
39
+ ),
40
+ })
41
+ expect(result.current).toBe('resource-project')
42
+ })
43
+
44
+ it('ignores a non-dataset resource (e.g. media library)', () => {
45
+ const {result} = renderHook(() => useResolvedProjectId(), {
46
+ wrapper: ({children}: {children: ReactNode}) => (
47
+ <ResourceContext.Provider value={{mediaLibraryId: 'ml-id'}}>
48
+ {children}
49
+ </ResourceContext.Provider>
50
+ ),
51
+ })
52
+ expect(result.current).toBeUndefined()
53
+ })
54
+
55
+ it('returns undefined when nothing resolves', () => {
56
+ const {result} = renderHook(() => useResolvedProjectId())
57
+ expect(result.current).toBeUndefined()
58
+ })
59
+ })
@@ -0,0 +1,35 @@
1
+ import {type DocumentResource, isDatasetResource} from '@sanity/sdk'
2
+ import {useContext} from 'react'
3
+
4
+ import {ProjectContext} from '../../context/ProjectContext'
5
+ import {useNormalizedResourceOptions} from './useNormalizedResourceOptions'
6
+
7
+ /**
8
+ * Resolves the effective `projectId` for project-scoped hooks (`useProject`,
9
+ * `useDatasets`, `useUsers`).
10
+ *
11
+ * Precedence:
12
+ * 1. an explicit `projectId` on the options
13
+ * 2. the ambient project scope (`ProjectContext`, e.g. a dataset-less
14
+ * `<ResourceProvider projectId="…">`)
15
+ * 3. the resolved resource's projectId (`ResourceProvider`/`SDKProvider`)
16
+ *
17
+ * Returns `undefined` when none apply, letting callers fall back to core's
18
+ * `instance.config.projectId`.
19
+ *
20
+ * @internal
21
+ */
22
+ export function useResolvedProjectId(options?: {
23
+ projectId?: string
24
+ dataset?: string
25
+ resource?: DocumentResource
26
+ resourceName?: string
27
+ }): string | undefined {
28
+ const {resource} = useNormalizedResourceOptions(options ?? {})
29
+ const contextProjectId = useContext(ProjectContext)
30
+ return (
31
+ options?.projectId ??
32
+ contextProjectId ??
33
+ (resource && isDatasetResource(resource) ? resource.projectId : undefined)
34
+ )
35
+ }
@@ -0,0 +1,37 @@
1
+ import {type SanityInstance} from '@sanity/sdk'
2
+ import {trackHookMounted} from '@sanity/sdk/_internal'
3
+ import {useRef} from 'react'
4
+
5
+ import {useSanityInstance} from '../context/useSanityInstance'
6
+
7
+ /**
8
+ * Tracks the first usage of a named hook per SDK session.
9
+ * If the telemetry manager hasn't initialized yet, the hook
10
+ * name is buffered and flushed when it becomes available.
11
+ *
12
+ * Uses a ref to ensure the tracking call only happens once per
13
+ * component mount, avoiding repeated WeakMap lookups on re-renders.
14
+ *
15
+ * Call at the top of any public hook whose adoption we want to measure.
16
+ *
17
+ * @internal
18
+ */
19
+ export function useTrackHookUsage(hookName: string): void {
20
+ const instance = useSanityInstance()
21
+ const tracked = useRef<true | null>(null)
22
+ if (tracked.current === null) {
23
+ tracked.current = true
24
+ trackHookMounted(instance, hookName)
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Non-hook variant for tracking hook usage when an instance is already
30
+ * available (avoids an extra `useSanityInstance` call in hooks that
31
+ * already have the instance).
32
+ *
33
+ * @internal
34
+ */
35
+ export function trackHookUsage(instance: SanityInstance, hookName: string): void {
36
+ trackHookMounted(instance, hookName)
37
+ }
@@ -0,0 +1,19 @@
1
+ import {type Installation, type InstallationActiveConfig} from '@sanity/sdk'
2
+ import {expectTypeOf, test} from 'vitest'
3
+
4
+ import {type FetcherHookResult} from '../helpers/createFetcherHook'
5
+ import {useInstallation} from './useInstallation'
6
+
7
+ test('useInstallation — no include: the base installation', () => {
8
+ const result = useInstallation('inst_1')
9
+ expectTypeOf(result).toEqualTypeOf<FetcherHookResult<Installation<never>>>()
10
+ expectTypeOf<
11
+ Extract<keyof typeof result.data, 'activeConfig' | 'access' | 'interfaces'>
12
+ >().toEqualTypeOf<never>()
13
+ })
14
+
15
+ test('useInstallation — activeConfig include adds the config field', () => {
16
+ const result = useInstallation('inst_1', {include: ['activeConfig']})
17
+ expectTypeOf(result.data).toEqualTypeOf<Installation<'activeConfig'>>()
18
+ expectTypeOf(result.data.activeConfig).toEqualTypeOf<InstallationActiveConfig | null>()
19
+ })
@@ -0,0 +1,22 @@
1
+ import {type Installation, installation, type InstallationInclude} from '@sanity/sdk'
2
+
3
+ import {createFetcherHook, type FetcherHookResult} from '../helpers/createFetcherHook'
4
+
5
+ /**
6
+ * Returns a single installation by id.
7
+ *
8
+ * The hook suspends until the first fetch succeeds, so `data` is always present.
9
+ * The `include` tokens you pass shape `data`: each requested token adds its
10
+ * field, and omitted ones are absent from the type.
11
+ *
12
+ * @public
13
+ * @param installationId - The installation id.
14
+ * @param options - Optional `include` list to expand related resources.
15
+ * @returns The result envelope `{data, isFetching, error, refetch}`.
16
+ */
17
+ export const useInstallation = createFetcherHook(installation) as <
18
+ Include extends InstallationInclude = never,
19
+ >(
20
+ installationId: string,
21
+ options?: {include?: Include[]},
22
+ ) => FetcherHookResult<Installation<Include>>
@@ -0,0 +1,26 @@
1
+ import {type Installation, type InstallationAccess, type InstallationsResponse} from '@sanity/sdk'
2
+ import {expectTypeOf, test} from 'vitest'
3
+
4
+ import {type FetcherHookResult} from '../helpers/createFetcherHook'
5
+ import {useInstallations} from './useInstallations'
6
+
7
+ test('useInstallations — no include: base items, no gated fields', () => {
8
+ const result = useInstallations({organizationId: 'org_1'})
9
+ expectTypeOf(result).toEqualTypeOf<FetcherHookResult<InstallationsResponse<never>>>()
10
+ type Item = (typeof result.data.data)[number]
11
+ expectTypeOf<
12
+ Extract<keyof Item, 'activeConfig' | 'access' | 'interfaces'>
13
+ >().toEqualTypeOf<never>()
14
+ })
15
+
16
+ test('useInstallations — include shapes each item', () => {
17
+ const result = useInstallations({organizationId: 'org_1', include: ['access']})
18
+ expectTypeOf(result.data.data).toEqualTypeOf<Installation<'access'>[]>()
19
+ type Item = (typeof result.data.data)[number]
20
+ expectTypeOf<Item['access']>().toEqualTypeOf<InstallationAccess[]>()
21
+ })
22
+
23
+ test('useInstallations — requires an organizationId', () => {
24
+ // @ts-expect-error — organizationId is required
25
+ void useInstallations({})
26
+ })
@@ -0,0 +1,25 @@
1
+ import {
2
+ type InstallationInclude,
3
+ installations,
4
+ type InstallationsOptions,
5
+ type InstallationsResponse,
6
+ } from '@sanity/sdk'
7
+
8
+ import {createFetcherHook, type FetcherHookResult} from '../helpers/createFetcherHook'
9
+
10
+ /**
11
+ * Returns the installations matching the given options.
12
+ *
13
+ * The hook suspends until the first fetch succeeds, so `data` is always present.
14
+ * The `include` tokens you pass shape `data.data`: each requested token adds its
15
+ * field, and omitted ones are absent from the type.
16
+ *
17
+ * @public
18
+ * @param options - Filter and include options for the installations list.
19
+ * @returns The result envelope `{data, isFetching, error, refetch}`.
20
+ */
21
+ export const useInstallations = createFetcherHook(installations) as <
22
+ Include extends InstallationInclude = never,
23
+ >(
24
+ options: InstallationsOptions<Include>,
25
+ ) => FetcherHookResult<InstallationsResponse<Include>>
@@ -0,0 +1,60 @@
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'}).data).toEqualTypeOf<
8
+ Organization<false, false>
9
+ >()
10
+ })
11
+
12
+ test('useOrganization — returns the FetcherHookResult envelope', () => {
13
+ const result = useOrganization({organizationId: 'org_1'})
14
+ expectTypeOf(result.isFetching).toEqualTypeOf<boolean>()
15
+ expectTypeOf(result.error).toEqualTypeOf<unknown>()
16
+ expectTypeOf(result.refetch).toEqualTypeOf<() => Promise<Organization<false, false>>>()
17
+ })
18
+
19
+ test('useOrganization — includeMembers: true adds members to the type', () => {
20
+ expectTypeOf(useOrganization({organizationId: 'org_1', includeMembers: true}).data).toEqualTypeOf<
21
+ Organization<true, false>
22
+ >()
23
+ type Result = ReturnType<typeof useOrganization<true, false>>
24
+ expectTypeOf<Result['data']['members']>().toEqualTypeOf<OrganizationMember[]>()
25
+ })
26
+
27
+ test('useOrganization — includeFeatures: true adds features to the type', () => {
28
+ expectTypeOf(
29
+ useOrganization({organizationId: 'org_1', includeFeatures: true}).data,
30
+ ).toEqualTypeOf<Organization<false, true>>()
31
+ })
32
+
33
+ test('useOrganization — both flags true → both arrays present', () => {
34
+ expectTypeOf(
35
+ useOrganization({organizationId: 'org_1', includeMembers: true, includeFeatures: true}).data,
36
+ ).toEqualTypeOf<Organization<true, true>>()
37
+ })
38
+
39
+ test('useOrganization — both flags false → bare base shape', () => {
40
+ expectTypeOf(
41
+ useOrganization({organizationId: 'org_1', includeMembers: false, includeFeatures: false}).data,
42
+ ).toEqualTypeOf<Organization<false, false>>()
43
+ type Result = ReturnType<typeof useOrganization<false, false>>
44
+ expectTypeOf<Result['data']['id']>().toEqualTypeOf<string>()
45
+ })
46
+
47
+ test('useOrganization — rejects non-boolean flag values', () => {
48
+ // @ts-expect-error — includeMembers must be a boolean
49
+ void useOrganization({organizationId: 'org_1', includeMembers: 'yes'})
50
+ })
51
+
52
+ test('useOrganization — non-literal boolean flag makes members optional', () => {
53
+ const includeMembers = false as boolean
54
+ expectTypeOf(useOrganization({organizationId: 'org_1', includeMembers}).data).toEqualTypeOf<
55
+ Organization<boolean, false>
56
+ >()
57
+ type Result = ReturnType<typeof useOrganization<boolean, false>>
58
+ expectTypeOf<Result['data']['members']>().toEqualTypeOf<OrganizationMember[] | undefined>()
59
+ expectTypeOf<Pick<Result['data'], 'members'>>().toEqualTypeOf<{members?: OrganizationMember[]}>()
60
+ })
@@ -0,0 +1,63 @@
1
+ import {type Organization, organization, type StateSource} from '@sanity/sdk'
2
+ import {type FetcherSnapshot} from '@sanity/sdk/_internal'
3
+ import {type Observable} from 'rxjs'
4
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
5
+
6
+ import {renderHook} from '../../../test/test-utils'
7
+ import {useOrganization} from './useOrganization'
8
+
9
+ vi.mock('@sanity/sdk', async (importOriginal) => {
10
+ const original = await importOriginal<typeof import('@sanity/sdk')>()
11
+ return {...original, organization: {getState: vi.fn(), resolveState: vi.fn(), refetch: vi.fn()}}
12
+ })
13
+
14
+ const stateSource = (
15
+ current: Organization | undefined,
16
+ ): StateSource<FetcherSnapshot<Organization>> => {
17
+ const snapshot = current
18
+ ? {status: 'success', data: current, error: undefined, isFetching: false, dataUpdatedAt: 1}
19
+ : {
20
+ status: 'pending',
21
+ data: undefined,
22
+ error: undefined,
23
+ isFetching: true,
24
+ dataUpdatedAt: undefined,
25
+ }
26
+ return {
27
+ getCurrent: vi.fn(() => snapshot),
28
+ subscribe: vi.fn(() => () => {}),
29
+ get observable(): Observable<unknown> {
30
+ throw new Error('Not implemented')
31
+ },
32
+ } as unknown as StateSource<FetcherSnapshot<Organization>>
33
+ }
34
+
35
+ const sanityInstance = expect.objectContaining({config: expect.any(Object)})
36
+
37
+ describe('useOrganization', () => {
38
+ beforeEach(() => {
39
+ vi.clearAllMocks()
40
+ vi.mocked(organization.getState).mockReturnValue(
41
+ stateSource({id: 'org_1'} as unknown as Organization),
42
+ )
43
+ })
44
+
45
+ it('reads the organization fetcher with the passed options', () => {
46
+ renderHook(() => useOrganization({organizationId: 'org_1'}))
47
+ expect(organization.getState).toHaveBeenCalledWith(sanityInstance, {organizationId: 'org_1'})
48
+ })
49
+
50
+ it('returns the fetcher data in the result envelope', () => {
51
+ const {result} = renderHook(() => useOrganization({organizationId: 'org_1'}))
52
+ expect(result.current.data).toEqual({id: 'org_1'})
53
+ expect(result.current.isFetching).toBe(false)
54
+ expect(typeof result.current.refetch).toBe('function')
55
+ })
56
+
57
+ it('suspends via the organization fetcher until data is available', () => {
58
+ vi.mocked(organization.getState).mockReturnValue(stateSource(undefined))
59
+ vi.mocked(organization.resolveState).mockReturnValue(new Promise(() => {}))
60
+ renderHook(() => useOrganization({organizationId: 'org_1'}))
61
+ expect(organization.resolveState).toHaveBeenCalled()
62
+ })
63
+ })
@@ -0,0 +1,34 @@
1
+ import {type Organization, organization, type OrganizationOptions} from '@sanity/sdk'
2
+
3
+ import {createFetcherHook, type FetcherHookResult} from '../helpers/createFetcherHook'
4
+
5
+ /**
6
+ * Returns metadata for a given organisation.
7
+ *
8
+ * @category Organizations
9
+ * @param options - Configuration options
10
+ * @returns A {@link FetcherHookResult} whose `data` is the metadata for the
11
+ * organisation. `members` is included only when `includeMembers: true`;
12
+ * `features` is included only when `includeFeatures: true`.
13
+ * @example
14
+ * ```tsx
15
+ * function OrganizationName({organizationId}: {organizationId: string}) {
16
+ * const {data: organization} = useOrganization({organizationId})
17
+ *
18
+ * return <h1>{organization.name}</h1>
19
+ * }
20
+ * ```
21
+ * @example
22
+ * ```tsx
23
+ * const {data: organizationWithMembers} = useOrganization({organizationId, includeMembers: true})
24
+ * const {data: organizationWithFeatures} = useOrganization({organizationId, includeFeatures: true})
25
+ * ```
26
+ * @public
27
+ * @function
28
+ */
29
+ export const useOrganization = createFetcherHook(organization) as <
30
+ IncludeMembers extends boolean = false,
31
+ IncludeFeatures extends boolean = false,
32
+ >(
33
+ options: OrganizationOptions<IncludeMembers, IncludeFeatures>,
34
+ ) => FetcherHookResult<Organization<IncludeMembers, IncludeFeatures>>
@@ -0,0 +1,68 @@
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().data).toEqualTypeOf<Organizations<false, false>>()
8
+ })
9
+
10
+ test('useOrganizations — returns the FetcherHookResult envelope', () => {
11
+ const result = useOrganizations()
12
+ expectTypeOf(result.isFetching).toEqualTypeOf<boolean>()
13
+ expectTypeOf(result.error).toEqualTypeOf<unknown>()
14
+ expectTypeOf(result.refetch).toEqualTypeOf<() => Promise<Organizations<false, false>>>()
15
+ })
16
+
17
+ test('useOrganizations — includeMembers: true adds members to the type', () => {
18
+ expectTypeOf(useOrganizations({includeMembers: true}).data).toEqualTypeOf<
19
+ Organizations<true, false>
20
+ >()
21
+ type Result = ReturnType<typeof useOrganizations<true, false>>
22
+ expectTypeOf<Result['data'][number]['members']>().toEqualTypeOf<OrganizationMember[]>()
23
+ })
24
+
25
+ test('useOrganizations — includeFeatures: true adds features to the type', () => {
26
+ expectTypeOf(useOrganizations({includeFeatures: true}).data).toEqualTypeOf<
27
+ Organizations<false, true>
28
+ >()
29
+ })
30
+
31
+ test('useOrganizations — both flags true → both arrays present', () => {
32
+ expectTypeOf(useOrganizations({includeMembers: true, includeFeatures: true}).data).toEqualTypeOf<
33
+ Organizations<true, true>
34
+ >()
35
+ })
36
+
37
+ test('useOrganizations — both flags false → bare base shape', () => {
38
+ expectTypeOf(
39
+ useOrganizations({includeMembers: false, includeFeatures: false}).data,
40
+ ).toEqualTypeOf<Organizations<false, false>>()
41
+ type Result = ReturnType<typeof useOrganizations<false, false>>
42
+ expectTypeOf<Result['data'][number]['id']>().toEqualTypeOf<string>()
43
+ })
44
+
45
+ test('useOrganizations — rejects non-boolean flag values', () => {
46
+ // @ts-expect-error — includeMembers must be a boolean
47
+ void useOrganizations({includeMembers: 'yes'})
48
+ })
49
+
50
+ test('useOrganizations — includeImplicitMemberships does not change the data shape', () => {
51
+ expectTypeOf(useOrganizations({includeImplicitMemberships: true}).data).toEqualTypeOf<
52
+ Organizations<false, false>
53
+ >()
54
+ })
55
+
56
+ test('useOrganizations — non-literal boolean flag makes members optional', () => {
57
+ const includeMembers = false as boolean
58
+ expectTypeOf(useOrganizations({includeMembers}).data).toEqualTypeOf<
59
+ Organizations<boolean, false>
60
+ >()
61
+ type Result = ReturnType<typeof useOrganizations<boolean, false>>
62
+ expectTypeOf<Result['data'][number]['members']>().toEqualTypeOf<
63
+ OrganizationMember[] | undefined
64
+ >()
65
+ expectTypeOf<Pick<Result['data'][number], 'members'>>().toEqualTypeOf<{
66
+ members?: OrganizationMember[]
67
+ }>()
68
+ })