@sanity/sdk-react 2.10.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 (54) hide show
  1. package/dist/index.d.ts +257 -200
  2. package/dist/index.js +364 -253
  3. package/dist/index.js.map +1 -1
  4. package/package.json +6 -9
  5. package/src/_exports/index.ts +2 -0
  6. package/src/_exports/sdk-react.ts +4 -0
  7. package/src/components/SDKProvider.test.tsx +5 -12
  8. package/src/components/SDKProvider.tsx +26 -24
  9. package/src/config/handles.ts +55 -0
  10. package/src/constants.ts +5 -0
  11. package/src/context/DefaultResourceContext.ts +10 -0
  12. package/src/context/PerspectiveContext.ts +12 -0
  13. package/src/context/ResourceProvider.test.tsx +2 -2
  14. package/src/context/ResourceProvider.tsx +53 -49
  15. package/src/hooks/agent/agentActions.ts +55 -38
  16. package/src/hooks/context/useResource.test.tsx +32 -0
  17. package/src/hooks/context/useResource.ts +24 -0
  18. package/src/hooks/context/useSanityInstance.test.tsx +42 -111
  19. package/src/hooks/context/useSanityInstance.ts +28 -50
  20. package/src/hooks/dashboard/useDispatchIntent.test.ts +5 -1
  21. package/src/hooks/dashboard/useDispatchIntent.ts +3 -3
  22. package/src/hooks/dashboard/useManageFavorite.test.tsx +16 -12
  23. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +1 -5
  24. package/src/hooks/document/{useApplyDocumentActions.test.ts → useApplyDocumentActions.test.tsx} +42 -77
  25. package/src/hooks/document/useApplyDocumentActions.ts +28 -62
  26. package/src/hooks/document/useDocument.ts +3 -5
  27. package/src/hooks/document/useDocumentEvent.ts +4 -3
  28. package/src/hooks/document/useDocumentPermissions.test.tsx +58 -150
  29. package/src/hooks/document/useDocumentPermissions.ts +78 -55
  30. package/src/hooks/document/useEditDocument.test.tsx +25 -60
  31. package/src/hooks/document/useEditDocument.ts +1 -1
  32. package/src/hooks/documents/useDocuments.ts +13 -8
  33. package/src/hooks/helpers/createStateSourceHook.tsx +1 -2
  34. package/src/hooks/helpers/useNormalizedResourceOptions.test.tsx +253 -0
  35. package/src/hooks/helpers/useNormalizedResourceOptions.ts +85 -47
  36. package/src/hooks/organizations/useOrganization.test-d.ts +53 -0
  37. package/src/hooks/organizations/useOrganization.test.ts +65 -0
  38. package/src/hooks/organizations/useOrganization.ts +40 -0
  39. package/src/hooks/organizations/useOrganizations.test-d.ts +55 -0
  40. package/src/hooks/organizations/useOrganizations.test.ts +85 -0
  41. package/src/hooks/organizations/useOrganizations.ts +45 -0
  42. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +23 -9
  43. package/src/hooks/presence/usePresence.ts +4 -11
  44. package/src/hooks/preview/useDocumentPreview.tsx +4 -7
  45. package/src/hooks/projection/useDocumentProjection.ts +5 -7
  46. package/src/hooks/projects/useProject.test-d.ts +49 -0
  47. package/src/hooks/projects/useProject.ts +33 -41
  48. package/src/hooks/projects/useProjects.test-d.ts +49 -0
  49. package/src/hooks/projects/useProjects.ts +17 -23
  50. package/src/hooks/query/useQuery.ts +1 -1
  51. package/src/hooks/releases/useActiveReleases.ts +6 -6
  52. package/src/hooks/releases/usePerspective.ts +7 -12
  53. package/src/hooks/users/useUser.ts +1 -1
  54. package/src/hooks/users/useUsers.ts +1 -1
@@ -1,17 +1,14 @@
1
- import {
2
- type ActionsResult,
3
- applyDocumentActions,
4
- type ApplyDocumentActionsOptions,
5
- type DocumentAction,
6
- } from '@sanity/sdk'
1
+ import {type ActionsResult, applyDocumentActions, type DocumentAction} from '@sanity/sdk'
2
+ import {isDeepEqual} from '@sanity/sdk/_internal'
7
3
  import {type SanityDocument} from 'groq'
8
4
  import {useContext} from 'react'
9
5
 
6
+ import {type ResourceHandle} from '../../config/handles'
10
7
  import {ResourcesContext} from '../../context/ResourcesContext'
11
8
  import {useSanityInstance} from '../context/useSanityInstance'
12
9
  import {
13
10
  normalizeResourceOptions,
14
- type WithResourceNameSupport,
11
+ useEffectiveContextResource,
15
12
  } from '../helpers/useNormalizedResourceOptions'
16
13
  // this import is used in an `{@link useEditDocument}`
17
14
  // eslint-disable-next-line unused-imports/no-unused-imports, import/consistent-type-specifier-style
@@ -29,7 +26,7 @@ interface UseApplyDocumentActions {
29
26
  action:
30
27
  | DocumentAction<TDocumentType, TDataset, TProjectId>
31
28
  | DocumentAction<TDocumentType, TDataset, TProjectId>[],
32
- options?: WithResourceNameSupport<ApplyDocumentActionsOptions>,
29
+ options?: ResourceHandle,
33
30
  ) => Promise<ActionsResult<SanityDocument<TDocumentType, `${TProjectId}.${TDataset}`>>>
34
31
  }
35
32
 
@@ -219,73 +216,42 @@ interface UseApplyDocumentActions {
219
216
  export const useApplyDocumentActions: UseApplyDocumentActions = () => {
220
217
  const instance = useSanityInstance()
221
218
  const resources = useContext(ResourcesContext)
219
+ const effectiveContextResource = useEffectiveContextResource()
222
220
 
223
221
  return (actionOrActions, options) => {
224
222
  const actions = Array.isArray(actionOrActions) ? actionOrActions : [actionOrActions]
225
- const normalizedOptions = options ? normalizeResourceOptions(options, resources) : undefined
223
+ const optionsResource = options
224
+ ? normalizeResourceOptions(options, resources, effectiveContextResource).resource
225
+ : undefined
226
226
 
227
- let projectId
228
- let dataset
227
+ const normalizedActions = actions.map((action) =>
228
+ normalizeResourceOptions(action, resources, effectiveContextResource),
229
+ )
229
230
  let resource
230
- for (const action of actions) {
231
- if (action.projectId) {
232
- if (resource) {
233
- throw new Error(
234
- `Mismatches between projectId/dataset options and resource in actions. Found projectId "${action.projectId}" and dataset "${action.dataset}" but expected resource "${resource}".`,
235
- )
236
- }
237
- if (!projectId) projectId = action.projectId
238
- if (action.projectId !== projectId) {
239
- throw new Error(
240
- `Mismatched project IDs found in actions. All actions must belong to the same project. Found "${action.projectId}" but expected "${projectId}".`,
241
- )
242
- }
243
-
244
- if (action.dataset) {
245
- if (!dataset) dataset = action.dataset
246
- if (action.dataset !== dataset) {
247
- throw new Error(
248
- `Mismatched datasets found in actions. All actions must belong to the same dataset. Found "${action.dataset}" but expected "${dataset}".`,
249
- )
250
- }
251
- }
252
- }
253
231
 
254
- if (action.resource) {
255
- if (!resource) resource = action.resource
256
- if (action.resource !== resource) {
257
- throw new Error(
258
- `Mismatched resources found in actions. All actions must belong to the same resource. Found "${action.resource}" but expected "${resource}".`,
259
- )
260
- }
261
- if (projectId || dataset) {
262
- throw new Error(
263
- `Mismatches between projectId/dataset options and resource in actions. Found "${action.resource}" but expected project "${projectId}" and dataset "${dataset}".`,
264
- )
265
- }
266
- }
267
- }
268
-
269
- if (projectId || dataset) {
270
- const actualInstance = instance.match({projectId, dataset})
271
- if (!actualInstance) {
232
+ for (const action of normalizedActions) {
233
+ if (!resource && action.resource) resource = action.resource
234
+ if (!isDeepEqual(action.resource, resource)) {
272
235
  throw new Error(
273
- `Could not find a matching Sanity instance for the requested action: ${JSON.stringify({projectId, dataset}, null, 2)}.
274
- Please ensure there is a ResourceProvider component with a matching configuration in the component hierarchy.`,
236
+ `Mismatched resources found in actions. All actions must belong to the same resource. Found "${JSON.stringify(action.resource)}" but expected "${JSON.stringify(resource)}".`,
275
237
  )
276
238
  }
239
+ }
240
+
241
+ if (optionsResource && resource && !isDeepEqual(optionsResource, resource)) {
242
+ throw new Error(
243
+ `Mismatched resources found in actions. Found top-level resource "${JSON.stringify(optionsResource)}" but expected resource from action handles "${JSON.stringify(resource)}".`,
244
+ )
245
+ }
277
246
 
278
- return applyDocumentActions(actualInstance, {
279
- actions,
280
- resource,
281
- ...normalizedOptions,
282
- })
247
+ const effectiveResource = resource ?? optionsResource ?? effectiveContextResource
248
+ if (!effectiveResource) {
249
+ throw new Error('No resource found. Provide a resource via the action handle or context.')
283
250
  }
284
251
 
285
252
  return applyDocumentActions(instance, {
286
- actions,
287
- resource,
288
- ...normalizedOptions,
253
+ actions: normalizedActions as DocumentAction[],
254
+ resource: effectiveResource,
289
255
  })
290
256
  }
291
257
  }
@@ -2,11 +2,9 @@ import {type DocumentOptions, getDocumentState, type JsonMatch, resolveDocument}
2
2
  import {type SanityDocument} from 'groq'
3
3
  import {identity} from 'rxjs'
4
4
 
5
+ import {type DocumentHandle} from '../../config/handles'
5
6
  import {createStateSourceHook} from '../helpers/createStateSourceHook'
6
- import {
7
- useNormalizedResourceOptions,
8
- type WithResourceNameSupport,
9
- } from '../helpers/useNormalizedResourceOptions'
7
+ import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
10
8
  import {useTrackHookUsage} from '../helpers/useTrackHookUsage'
11
9
  // used in an `{@link useDocumentProjection}` and `{@link useQuery}`
12
10
  // eslint-disable-next-line import/consistent-type-specifier-style, unused-imports/no-unused-imports
@@ -43,7 +41,7 @@ type UseDocumentOptions<
43
41
  TDocumentType extends string = string,
44
42
  TDataset extends string = string,
45
43
  TProjectId extends string = string,
46
- > = WithResourceNameSupport<DocumentOptions<TPath, TDocumentType, TDataset, TProjectId>>
44
+ > = DocumentHandle<TDocumentType, TDataset, TProjectId> & {path?: TPath}
47
45
 
48
46
  interface UseDocument {
49
47
  /** @internal */
@@ -1,6 +1,7 @@
1
- import {type DatasetHandle, type DocumentEvent, subscribeDocumentEvents} from '@sanity/sdk'
1
+ import {type DocumentEvent, subscribeDocumentEvents} from '@sanity/sdk'
2
2
  import {useCallback, useEffect, useInsertionEffect, useRef} from 'react'
3
3
 
4
+ import {type ResourceHandle} from '../../config/handles'
4
5
  import {useSanityInstance} from '../context/useSanityInstance'
5
6
  import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
6
7
  import {useTrackHookUsage} from '../helpers/useTrackHookUsage'
@@ -11,7 +12,7 @@ import {useTrackHookUsage} from '../helpers/useTrackHookUsage'
11
12
  export interface UseDocumentEventOptions<
12
13
  TDataset extends string = string,
13
14
  TProjectId extends string = string,
14
- > extends DatasetHandle<TDataset, TProjectId> {
15
+ > extends ResourceHandle<TDataset, TProjectId> {
15
16
  onEvent: (documentEvent: DocumentEvent) => void
16
17
  }
17
18
 
@@ -91,7 +92,7 @@ export function useDocumentEvent<
91
92
  return ref.current(documentEvent)
92
93
  }, [])
93
94
 
94
- const instance = useSanityInstance(datasetHandle)
95
+ const instance = useSanityInstance()
95
96
  useEffect(() => {
96
97
  return subscribeDocumentEvents(instance, {
97
98
  eventHandler: stableHandler,
@@ -1,8 +1,9 @@
1
1
  import {type DocumentAction, type DocumentPermissionsResult, getPermissionsState} from '@sanity/sdk'
2
- import {act, renderHook, waitFor} from '@testing-library/react'
2
+ import {renderHook as reactRenderHook} from '@testing-library/react'
3
3
  import {BehaviorSubject, firstValueFrom, Observable} from 'rxjs'
4
- import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
4
+ import {afterEach, beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
5
5
 
6
+ import {act, renderHook, waitFor} from '../../../test/test-utils'
6
7
  import {ResourceProvider} from '../../context/ResourceProvider'
7
8
  import {useDocumentPermissions} from './useDocumentPermissions'
8
9
 
@@ -24,12 +25,12 @@ vi.mock('rxjs', async (importOriginal) => {
24
25
  })
25
26
 
26
27
  describe('usePermissions', () => {
28
+ const mockResource = {projectId: 'project1', dataset: 'dataset1'}
27
29
  const mockAction: DocumentAction = {
28
30
  type: 'document.publish',
29
31
  documentId: 'doc1',
30
32
  documentType: 'article',
31
- projectId: 'project1',
32
- dataset: 'dataset1',
33
+ resource: mockResource,
33
34
  }
34
35
 
35
36
  const mockPermissionAllowed: DocumentPermissionsResult = {allowed: true}
@@ -46,8 +47,8 @@ describe('usePermissions', () => {
46
47
  }
47
48
 
48
49
  let permissionsSubject: BehaviorSubject<DocumentPermissionsResult | undefined>
49
- let mockSubscribe: ReturnType<typeof vi.fn>
50
- let mockGetCurrent: ReturnType<typeof vi.fn>
50
+ let mockSubscribe: Mock<(onStoreChanged?: () => void) => () => void>
51
+ let mockGetCurrent: Mock<() => DocumentPermissionsResult | undefined>
51
52
 
52
53
  beforeEach(() => {
53
54
  vi.clearAllMocks()
@@ -71,7 +72,7 @@ describe('usePermissions', () => {
71
72
  observable:
72
73
  permissionsSubject.asObservable() as unknown as Observable<DocumentPermissionsResult>,
73
74
  subscribe: mockSubscribe,
74
- getCurrent: mockGetCurrent,
75
+ getCurrent: mockGetCurrent as unknown as () => DocumentPermissionsResult,
75
76
  })
76
77
  })
77
78
 
@@ -85,20 +86,13 @@ describe('usePermissions', () => {
85
86
  permissionsSubject.next(mockPermissionAllowed)
86
87
  })
87
88
 
88
- const {result} = renderHook(() => useDocumentPermissions(mockAction), {
89
- wrapper: ({children}) => (
90
- <ResourceProvider
91
- projectId={mockAction.projectId}
92
- dataset={mockAction.dataset}
93
- fallback={null}
94
- >
95
- {children}
96
- </ResourceProvider>
97
- ),
98
- })
89
+ const {result} = renderHook(() => useDocumentPermissions(mockAction))
99
90
 
100
91
  // ResourceProvider handles the instance configuration
101
- expect(getPermissionsState).toHaveBeenCalledWith(expect.any(Object), {actions: [mockAction]})
92
+ expect(getPermissionsState).toHaveBeenCalledWith(
93
+ expect.any(Object),
94
+ expect.objectContaining({actions: [mockAction], resource: mockResource}),
95
+ )
102
96
  expect(result.current).toEqual(mockPermissionAllowed)
103
97
  })
104
98
 
@@ -108,17 +102,7 @@ describe('usePermissions', () => {
108
102
  permissionsSubject.next(mockPermissionDenied)
109
103
  })
110
104
 
111
- const {result} = renderHook(() => useDocumentPermissions(mockAction), {
112
- wrapper: ({children}) => (
113
- <ResourceProvider
114
- projectId={mockAction.projectId}
115
- dataset={mockAction.dataset}
116
- fallback={null}
117
- >
118
- {children}
119
- </ResourceProvider>
120
- ),
121
- })
105
+ const {result} = renderHook(() => useDocumentPermissions(mockAction))
122
106
 
123
107
  expect(result.current).toEqual(mockPermissionDenied)
124
108
  expect(result.current.allowed).toBe(false)
@@ -129,141 +113,58 @@ describe('usePermissions', () => {
129
113
  it('should accept an array of actions', () => {
130
114
  const actions = [mockAction, {...mockAction, documentId: 'doc2'}]
131
115
 
132
- renderHook(() => useDocumentPermissions(actions), {
133
- wrapper: ({children}) => (
134
- <ResourceProvider
135
- projectId={mockAction.projectId}
136
- dataset={mockAction.dataset}
137
- fallback={null}
138
- >
139
- {children}
140
- </ResourceProvider>
141
- ),
142
- })
143
-
144
- expect(getPermissionsState).toHaveBeenCalledWith(expect.any(Object), {actions})
145
- })
146
-
147
- it('should throw an error if actions have mismatched project IDs', () => {
148
- const actions = [
149
- mockAction,
150
- {...mockAction, projectId: 'different-project', documentId: 'doc2'},
151
- ]
152
-
153
- expect(() => {
154
- renderHook(() => useDocumentPermissions(actions), {
155
- wrapper: ({children}) => (
156
- <ResourceProvider
157
- projectId={mockAction.projectId}
158
- dataset={mockAction.dataset}
159
- fallback={null}
160
- >
161
- {children}
162
- </ResourceProvider>
163
- ),
164
- })
165
- }).toThrow(/Mismatched project IDs found in actions/)
166
- })
167
-
168
- it('should throw an error if actions have mismatched datasets', () => {
169
- const actions = [mockAction, {...mockAction, dataset: 'different-dataset', documentId: 'doc2'}]
116
+ renderHook(() => useDocumentPermissions(actions))
170
117
 
171
- expect(() => {
172
- renderHook(() => useDocumentPermissions(actions), {
173
- wrapper: ({children}) => (
174
- <ResourceProvider
175
- projectId={mockAction.projectId}
176
- dataset={mockAction.dataset}
177
- fallback={null}
178
- >
179
- {children}
180
- </ResourceProvider>
181
- ),
182
- })
183
- }).toThrow(/Mismatched datasets found in actions/)
118
+ expect(getPermissionsState).toHaveBeenCalledWith(
119
+ expect.any(Object),
120
+ expect.objectContaining({actions, resource: mockResource}),
121
+ )
184
122
  })
185
123
 
186
124
  it('should throw an error if actions have mismatched resources', () => {
187
125
  const actions = [
126
+ mockAction,
188
127
  {
189
- type: 'document.publish' as const,
190
- documentId: 'doc1',
191
- documentType: 'article',
192
- resource: {projectId: 'p1', dataset: 'd1'},
193
- },
194
- {
195
- type: 'document.publish' as const,
128
+ ...mockAction,
129
+ resource: {projectId: 'different-project', dataset: 'dataset1'},
196
130
  documentId: 'doc2',
197
- documentType: 'article',
198
- resource: {projectId: 'p2', dataset: 'd2'},
199
131
  },
200
132
  ]
201
133
 
202
134
  expect(() => {
203
- renderHook(() => useDocumentPermissions(actions), {
204
- wrapper: ({children}) => (
205
- <ResourceProvider
206
- projectId={mockAction.projectId}
207
- dataset={mockAction.dataset}
208
- fallback={null}
209
- >
210
- {children}
211
- </ResourceProvider>
212
- ),
213
- })
135
+ renderHook(() => useDocumentPermissions(actions))
214
136
  }).toThrow(/Mismatched resources found in actions/)
215
137
  })
216
138
 
217
- it('should throw an error when mixing projectId and resource (projectId first)', () => {
139
+ it('should throw an error if actions have mismatched datasets', () => {
218
140
  const actions = [
219
141
  mockAction,
220
142
  {
221
- type: 'document.publish' as const,
143
+ ...mockAction,
144
+ resource: {projectId: 'project1', dataset: 'different-dataset'},
222
145
  documentId: 'doc2',
223
- documentType: 'article',
224
- resource: {projectId: 'p', dataset: 'd'},
225
146
  },
226
147
  ]
227
148
 
228
149
  expect(() => {
229
- renderHook(() => useDocumentPermissions(actions), {
230
- wrapper: ({children}) => (
231
- <ResourceProvider
232
- projectId={mockAction.projectId}
233
- dataset={mockAction.dataset}
234
- fallback={null}
235
- >
236
- {children}
237
- </ResourceProvider>
238
- ),
239
- })
240
- }).toThrow(/Mismatches between projectId\/dataset options and resource/)
150
+ renderHook(() => useDocumentPermissions(actions))
151
+ }).toThrow(/Mismatched resources found in actions/)
241
152
  })
242
153
 
243
- it('should throw an error when mixing resource and projectId (resource first)', () => {
154
+ it('should throw an error when mixing different resources', () => {
244
155
  const actions = [
156
+ mockAction,
245
157
  {
246
158
  type: 'document.publish' as const,
247
- documentId: 'doc1',
159
+ documentId: 'doc2',
248
160
  documentType: 'article',
249
161
  resource: {projectId: 'p', dataset: 'd'},
250
162
  },
251
- mockAction,
252
163
  ]
253
164
 
254
165
  expect(() => {
255
- renderHook(() => useDocumentPermissions(actions), {
256
- wrapper: ({children}) => (
257
- <ResourceProvider
258
- projectId={mockAction.projectId}
259
- dataset={mockAction.dataset}
260
- fallback={null}
261
- >
262
- {children}
263
- </ResourceProvider>
264
- ),
265
- })
266
- }).toThrow(/Mismatches between projectId\/dataset options and resource/)
166
+ renderHook(() => useDocumentPermissions(actions))
167
+ }).toThrow(/Mismatched resources found in actions/)
267
168
  })
268
169
 
269
170
  it('should wait for permissions to be ready before rendering', async () => {
@@ -277,7 +178,7 @@ describe('usePermissions', () => {
277
178
  vi.mocked(firstValueFrom).mockReturnValueOnce(mockPromise)
278
179
 
279
180
  // This should throw the promise and suspend
280
- const {result} = renderHook(
181
+ const {result} = reactRenderHook(
281
182
  () => {
282
183
  try {
283
184
  return useDocumentPermissions(mockAction)
@@ -290,11 +191,7 @@ describe('usePermissions', () => {
290
191
  },
291
192
  {
292
193
  wrapper: ({children}) => (
293
- <ResourceProvider
294
- projectId={mockAction.projectId}
295
- dataset={mockAction.dataset}
296
- fallback={null}
297
- >
194
+ <ResourceProvider resource={mockResource} fallback={null}>
298
195
  {children}
299
196
  </ResourceProvider>
300
197
  ),
@@ -310,27 +207,38 @@ describe('usePermissions', () => {
310
207
 
311
208
  // Now it should render properly
312
209
  await waitFor(() => {
313
- expect(getPermissionsState).toHaveBeenCalledWith(expect.any(Object), {actions: [mockAction]})
210
+ expect(getPermissionsState).toHaveBeenCalledWith(
211
+ expect.any(Object),
212
+ expect.objectContaining({actions: [mockAction], resource: mockResource}),
213
+ )
314
214
  })
315
215
  })
316
216
 
217
+ it('throws when no resource is found from action or context', () => {
218
+ // Provide SanityInstance via ResourceProvider but no resource, so contextResource is undefined
219
+ expect(() => {
220
+ reactRenderHook(
221
+ () =>
222
+ useDocumentPermissions({
223
+ type: 'document.publish',
224
+ documentId: 'doc1',
225
+ documentType: 'article',
226
+ // no resource
227
+ } as never),
228
+ {
229
+ wrapper: ({children}) => <ResourceProvider fallback={null}>{children}</ResourceProvider>,
230
+ },
231
+ )
232
+ }).toThrow(/No resource found/)
233
+ })
234
+
317
235
  it('should react to permission state changes', async () => {
318
236
  // Start with permission allowed
319
237
  act(() => {
320
238
  permissionsSubject.next(mockPermissionAllowed)
321
239
  })
322
240
 
323
- const {result, rerender} = renderHook(() => useDocumentPermissions(mockAction), {
324
- wrapper: ({children}) => (
325
- <ResourceProvider
326
- projectId={mockAction.projectId}
327
- dataset={mockAction.dataset}
328
- fallback={null}
329
- >
330
- {children}
331
- </ResourceProvider>
332
- ),
333
- })
241
+ const {result, rerender} = renderHook(() => useDocumentPermissions(mockAction))
334
242
 
335
243
  expect(result.current).toEqual(mockPermissionAllowed)
336
244
 
@@ -1,10 +1,20 @@
1
1
  import {type DocumentAction, type DocumentPermissionsResult, getPermissionsState} from '@sanity/sdk'
2
- import {useCallback, useMemo, useSyncExternalStore} from 'react'
2
+ import {isDeepEqual} from '@sanity/sdk/_internal'
3
+ import {useCallback, useContext, useMemo, useSyncExternalStore} from 'react'
3
4
  import {filter, firstValueFrom} from 'rxjs'
4
5
 
6
+ import {ResourcesContext} from '../../context/ResourcesContext'
5
7
  import {useSanityInstance} from '../context/useSanityInstance'
8
+ import {
9
+ normalizeResourceOptions,
10
+ useEffectiveContextResource,
11
+ type WithResourceNameSupport,
12
+ } from '../helpers/useNormalizedResourceOptions'
6
13
  import {trackHookUsage} from '../helpers/useTrackHookUsage'
7
14
 
15
+ const noopSubscribe = () => () => {}
16
+ const returnUndefined = () => undefined
17
+
8
18
  /**
9
19
  *
10
20
  * @public
@@ -83,74 +93,87 @@ import {trackHookUsage} from '../helpers/useTrackHookUsage'
83
93
  * ```
84
94
  */
85
95
  export function useDocumentPermissions(
86
- actionOrActions: DocumentAction | DocumentAction[],
96
+ actionOrActions:
97
+ | WithResourceNameSupport<DocumentAction>
98
+ | WithResourceNameSupport<DocumentAction>[],
87
99
  ): DocumentPermissionsResult {
88
- const actions = useMemo(
89
- () => (Array.isArray(actionOrActions) ? actionOrActions : [actionOrActions]),
90
- [actionOrActions],
91
- )
92
- // if actions is an array, we need to check that all actions belong to the same project and dataset
93
- let projectId
94
- let dataset
95
- let resource
100
+ const instance = useSanityInstance()
101
+ trackHookUsage(instance, 'useDocumentPermissions')
102
+ const effectiveContextResource = useEffectiveContextResource()
103
+ const resources = useContext(ResourcesContext)
96
104
 
97
- for (const action of actions) {
98
- if (action.projectId) {
99
- if (resource) {
100
- throw new Error(
101
- `Mismatches between projectId/dataset options and resource in actions. Found projectId "${action.projectId}" and dataset "${action.dataset}" but expected resource "${resource}".`,
105
+ const {
106
+ actions: normalizedActions,
107
+ resource: actionResource,
108
+ error: validationError,
109
+ } = useMemo(() => {
110
+ const normalized = Array.isArray(actionOrActions)
111
+ ? actionOrActions.map((action) =>
112
+ normalizeResourceOptions(action, resources, effectiveContextResource),
102
113
  )
103
- }
104
- if (!projectId) projectId = action.projectId
105
- if (action.projectId !== projectId) {
106
- throw new Error(
107
- `Mismatched project IDs found in actions. All actions must belong to the same project. Found "${action.projectId}" but expected "${projectId}".`,
108
- )
109
- }
114
+ : [normalizeResourceOptions(actionOrActions, resources, effectiveContextResource)]
110
115
 
111
- if (action.dataset) {
112
- if (!dataset) dataset = action.dataset
113
- if (action.dataset !== dataset) {
114
- throw new Error(
115
- `Mismatched datasets found in actions. All actions must belong to the same dataset. Found "${action.dataset}" but expected "${dataset}".`,
116
- )
116
+ let resource
117
+ for (const action of normalized) {
118
+ if (action.resource) {
119
+ if (!resource) resource = action.resource
120
+ if (!isDeepEqual(action.resource, resource)) {
121
+ return {
122
+ actions: normalized,
123
+ resource,
124
+ error: new Error(
125
+ `Mismatched resources found in actions. All actions must belong to the same resource. Found "${JSON.stringify(action.resource)}" but expected "${JSON.stringify(resource)}".`,
126
+ ),
127
+ }
117
128
  }
118
129
  }
119
130
  }
131
+ return {actions: normalized, resource, error: undefined}
132
+ }, [actionOrActions, resources, effectiveContextResource])
120
133
 
121
- if (action.resource) {
122
- if (!resource) resource = action.resource
123
- if (action.resource !== resource) {
124
- throw new Error(
125
- `Mismatched resources found in actions. All actions must belong to the same resource. Found "${action.resource}" but expected "${resource}".`,
126
- )
127
- }
128
- if (projectId || dataset) {
129
- throw new Error(
130
- `Mismatches between projectId/dataset options and resource in actions. Found "${action.resource}" but expected project "${projectId}" and dataset "${dataset}".`,
131
- )
132
- }
133
- }
134
- }
134
+ const effectiveResource = actionResource ?? effectiveContextResource
135
+
136
+ // Keep hooks unconditional — validation errors and missing-resource errors are
137
+ // thrown after all hooks so that the hook call count stays stable across renders.
138
+ const permissionsOptions = useMemo(
139
+ () =>
140
+ effectiveResource
141
+ ? {
142
+ resource: effectiveResource,
143
+ // `Omit<>` on `DocumentAction` loses the discriminant; runtime values are still actions.
144
+ actions: normalizedActions as DocumentAction[],
145
+ }
146
+ : undefined,
147
+ [effectiveResource, normalizedActions],
148
+ )
149
+
150
+ const stateSource = useMemo(
151
+ () => (permissionsOptions ? getPermissionsState(instance, permissionsOptions) : undefined),
152
+ [permissionsOptions, instance],
153
+ )
135
154
 
136
- const instance = useSanityInstance({projectId, dataset})
137
- trackHookUsage(instance, 'useDocumentPermissions')
138
155
  const isDocumentReady = useCallback(
139
- () => getPermissionsState(instance, {actions}).getCurrent() !== undefined,
140
- [actions, instance],
156
+ () => stateSource !== undefined && stateSource.getCurrent() !== undefined,
157
+ [stateSource],
158
+ )
159
+
160
+ const result = useSyncExternalStore(
161
+ stateSource?.subscribe ?? noopSubscribe,
162
+ stateSource?.getCurrent ?? returnUndefined,
141
163
  )
164
+
165
+ // All hooks have been called — safe to throw now.
166
+ if (validationError) throw validationError
167
+ if (!effectiveResource) {
168
+ throw new Error(
169
+ 'No resource found. Provide a resource via the action handle or wrap with a resource context.',
170
+ )
171
+ }
142
172
  if (!isDocumentReady()) {
143
173
  throw firstValueFrom(
144
- getPermissionsState(instance, {actions}).observable.pipe(
145
- filter((result) => result !== undefined),
146
- ),
174
+ stateSource!.observable.pipe(filter((permissions) => permissions !== undefined)),
147
175
  )
148
176
  }
149
177
 
150
- const {subscribe, getCurrent} = useMemo(
151
- () => getPermissionsState(instance, {actions}),
152
- [actions, instance],
153
- )
154
-
155
- return useSyncExternalStore(subscribe, getCurrent) as DocumentPermissionsResult
178
+ return result as DocumentPermissionsResult
156
179
  }