@sanity/sdk-react 2.9.0 → 2.10.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 (44) hide show
  1. package/dist/index.d.ts +92 -26
  2. package/dist/index.js +304 -193
  3. package/dist/index.js.map +1 -1
  4. package/package.json +9 -11
  5. package/src/_exports/sdk-react.ts +4 -0
  6. package/src/components/SDKProvider.tsx +36 -8
  7. package/src/components/SanityApp.tsx +2 -2
  8. package/src/components/auth/AuthBoundary.tsx +8 -1
  9. package/src/components/auth/DashboardAccessRequest.tsx +37 -0
  10. package/src/components/auth/LoginError.test.tsx +191 -5
  11. package/src/components/auth/LoginError.tsx +100 -56
  12. package/src/components/errors/ChunkLoadError.test.tsx +59 -0
  13. package/src/components/errors/ChunkLoadError.tsx +56 -0
  14. package/src/components/errors/chunkReloadStorage.ts +57 -0
  15. package/src/context/ResourceProvider.tsx +5 -4
  16. package/src/context/ResourcesContext.tsx +7 -0
  17. package/src/context/SanityInstanceProvider.test.tsx +100 -0
  18. package/src/context/SanityInstanceProvider.tsx +71 -0
  19. package/src/hooks/auth/useVerifyOrgProjects.tsx +13 -6
  20. package/src/hooks/dashboard/useDispatchIntent.test.ts +6 -6
  21. package/src/hooks/dashboard/useDispatchIntent.ts +6 -6
  22. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.test.ts +15 -15
  23. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +13 -13
  24. package/src/hooks/document/useApplyDocumentActions.test.ts +10 -10
  25. package/src/hooks/document/useApplyDocumentActions.ts +17 -17
  26. package/src/hooks/document/useDocument.ts +5 -5
  27. package/src/hooks/document/useDocumentEvent.ts +4 -4
  28. package/src/hooks/document/useDocumentPermissions.test.tsx +10 -10
  29. package/src/hooks/document/useDocumentPermissions.ts +8 -8
  30. package/src/hooks/document/useDocumentSyncStatus.ts +2 -2
  31. package/src/hooks/document/useEditDocument.ts +2 -2
  32. package/src/hooks/documents/useDocuments.ts +9 -6
  33. package/src/hooks/helpers/useNormalizedResourceOptions.ts +131 -0
  34. package/src/hooks/helpers/useTrackHookUsage.ts +2 -2
  35. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +9 -8
  36. package/src/hooks/presence/usePresence.test.tsx +56 -9
  37. package/src/hooks/presence/usePresence.ts +23 -4
  38. package/src/hooks/preview/useDocumentPreview.tsx +8 -7
  39. package/src/hooks/projection/useDocumentProjection.ts +6 -6
  40. package/src/hooks/query/useQuery.ts +10 -9
  41. package/src/hooks/releases/useActiveReleases.ts +10 -10
  42. package/src/hooks/releases/usePerspective.ts +9 -9
  43. package/src/context/SourcesContext.tsx +0 -7
  44. package/src/hooks/helpers/useNormalizedSourceOptions.ts +0 -107
@@ -176,7 +176,7 @@ describe('useApplyDocumentActions', () => {
176
176
  }).toThrow(/Mismatched datasets found in actions/)
177
177
  })
178
178
 
179
- it('throws when actions have mismatched sources', async () => {
179
+ it('throws when actions have mismatched resources', async () => {
180
180
  const {result} = renderHook(() => useApplyDocumentActions())
181
181
  expect(() => {
182
182
  result.current([
@@ -184,19 +184,19 @@ describe('useApplyDocumentActions', () => {
184
184
  type: 'document.edit',
185
185
  documentType: 'post',
186
186
  documentId: 'abc',
187
- source: {projectId: 'p', dataset: 'd1'},
187
+ resource: {projectId: 'p', dataset: 'd1'},
188
188
  },
189
189
  {
190
190
  type: 'document.edit',
191
191
  documentType: 'post',
192
192
  documentId: 'def',
193
- source: {projectId: 'p', dataset: 'd2'},
193
+ resource: {projectId: 'p', dataset: 'd2'},
194
194
  },
195
195
  ])
196
- }).toThrow(/Mismatched sources found in actions/)
196
+ }).toThrow(/Mismatched resources found in actions/)
197
197
  })
198
198
 
199
- it('throws when mixing projectId and source (projectId first)', async () => {
199
+ it('throws when mixing projectId and resource (projectId first)', async () => {
200
200
  const {result} = renderHook(() => useApplyDocumentActions())
201
201
  expect(() => {
202
202
  result.current([
@@ -210,13 +210,13 @@ describe('useApplyDocumentActions', () => {
210
210
  type: 'document.edit',
211
211
  documentType: 'post',
212
212
  documentId: 'def',
213
- source: {projectId: 'p', dataset: 'd'},
213
+ resource: {projectId: 'p', dataset: 'd'},
214
214
  },
215
215
  ])
216
- }).toThrow(/Mismatches between projectId\/dataset options and source/)
216
+ }).toThrow(/Mismatches between projectId\/dataset options and resource/)
217
217
  })
218
218
 
219
- it('throws when mixing source and projectId (source first)', async () => {
219
+ it('throws when mixing resource and projectId (resource first)', async () => {
220
220
  const {result} = renderHook(() => useApplyDocumentActions())
221
221
  expect(() => {
222
222
  result.current([
@@ -224,7 +224,7 @@ describe('useApplyDocumentActions', () => {
224
224
  type: 'document.edit',
225
225
  documentType: 'post',
226
226
  documentId: 'abc',
227
- source: {projectId: 'p', dataset: 'd'},
227
+ resource: {projectId: 'p', dataset: 'd'},
228
228
  },
229
229
  {
230
230
  type: 'document.edit',
@@ -233,6 +233,6 @@ describe('useApplyDocumentActions', () => {
233
233
  projectId: 'p',
234
234
  },
235
235
  ])
236
- }).toThrow(/Mismatches between projectId\/dataset options and source/)
236
+ }).toThrow(/Mismatches between projectId\/dataset options and resource/)
237
237
  })
238
238
  })
@@ -7,12 +7,12 @@ import {
7
7
  import {type SanityDocument} from 'groq'
8
8
  import {useContext} from 'react'
9
9
 
10
- import {SourcesContext} from '../../context/SourcesContext'
10
+ import {ResourcesContext} from '../../context/ResourcesContext'
11
11
  import {useSanityInstance} from '../context/useSanityInstance'
12
12
  import {
13
- normalizeSourceOptions,
14
- type WithSourceNameSupport,
15
- } from '../helpers/useNormalizedSourceOptions'
13
+ normalizeResourceOptions,
14
+ type WithResourceNameSupport,
15
+ } from '../helpers/useNormalizedResourceOptions'
16
16
  // this import is used in an `{@link useEditDocument}`
17
17
  // eslint-disable-next-line unused-imports/no-unused-imports, import/consistent-type-specifier-style
18
18
  import type {useEditDocument} from './useEditDocument'
@@ -29,7 +29,7 @@ interface UseApplyDocumentActions {
29
29
  action:
30
30
  | DocumentAction<TDocumentType, TDataset, TProjectId>
31
31
  | DocumentAction<TDocumentType, TDataset, TProjectId>[],
32
- options?: WithSourceNameSupport<ApplyDocumentActionsOptions>,
32
+ options?: WithResourceNameSupport<ApplyDocumentActionsOptions>,
33
33
  ) => Promise<ActionsResult<SanityDocument<TDocumentType, `${TProjectId}.${TDataset}`>>>
34
34
  }
35
35
 
@@ -218,20 +218,20 @@ interface UseApplyDocumentActions {
218
218
  */
219
219
  export const useApplyDocumentActions: UseApplyDocumentActions = () => {
220
220
  const instance = useSanityInstance()
221
- const sources = useContext(SourcesContext)
221
+ const resources = useContext(ResourcesContext)
222
222
 
223
223
  return (actionOrActions, options) => {
224
224
  const actions = Array.isArray(actionOrActions) ? actionOrActions : [actionOrActions]
225
- const normalizedOptions = options ? normalizeSourceOptions(options, sources) : undefined
225
+ const normalizedOptions = options ? normalizeResourceOptions(options, resources) : undefined
226
226
 
227
227
  let projectId
228
228
  let dataset
229
- let source
229
+ let resource
230
230
  for (const action of actions) {
231
231
  if (action.projectId) {
232
- if (source) {
232
+ if (resource) {
233
233
  throw new Error(
234
- `Mismatches between projectId/dataset options and source in actions. Found projectId "${action.projectId}" and dataset "${action.dataset}" but expected source "${source}".`,
234
+ `Mismatches between projectId/dataset options and resource in actions. Found projectId "${action.projectId}" and dataset "${action.dataset}" but expected resource "${resource}".`,
235
235
  )
236
236
  }
237
237
  if (!projectId) projectId = action.projectId
@@ -251,16 +251,16 @@ export const useApplyDocumentActions: UseApplyDocumentActions = () => {
251
251
  }
252
252
  }
253
253
 
254
- if (action.source) {
255
- if (!source) source = action.source
256
- if (action.source !== source) {
254
+ if (action.resource) {
255
+ if (!resource) resource = action.resource
256
+ if (action.resource !== resource) {
257
257
  throw new Error(
258
- `Mismatched sources found in actions. All actions must belong to the same source. Found "${action.source}" but expected "${source}".`,
258
+ `Mismatched resources found in actions. All actions must belong to the same resource. Found "${action.resource}" but expected "${resource}".`,
259
259
  )
260
260
  }
261
261
  if (projectId || dataset) {
262
262
  throw new Error(
263
- `Mismatches between projectId/dataset options and source in actions. Found "${action.source}" but expected project "${projectId}" and dataset "${dataset}".`,
263
+ `Mismatches between projectId/dataset options and resource in actions. Found "${action.resource}" but expected project "${projectId}" and dataset "${dataset}".`,
264
264
  )
265
265
  }
266
266
  }
@@ -277,14 +277,14 @@ export const useApplyDocumentActions: UseApplyDocumentActions = () => {
277
277
 
278
278
  return applyDocumentActions(actualInstance, {
279
279
  actions,
280
- source,
280
+ resource,
281
281
  ...normalizedOptions,
282
282
  })
283
283
  }
284
284
 
285
285
  return applyDocumentActions(instance, {
286
286
  actions,
287
- source,
287
+ resource,
288
288
  ...normalizedOptions,
289
289
  })
290
290
  }
@@ -4,9 +4,9 @@ import {identity} from 'rxjs'
4
4
 
5
5
  import {createStateSourceHook} from '../helpers/createStateSourceHook'
6
6
  import {
7
- useNormalizedSourceOptions,
8
- type WithSourceNameSupport,
9
- } from '../helpers/useNormalizedSourceOptions'
7
+ useNormalizedResourceOptions,
8
+ type WithResourceNameSupport,
9
+ } from '../helpers/useNormalizedResourceOptions'
10
10
  import {useTrackHookUsage} from '../helpers/useTrackHookUsage'
11
11
  // used in an `{@link useDocumentProjection}` and `{@link useQuery}`
12
12
  // eslint-disable-next-line import/consistent-type-specifier-style, unused-imports/no-unused-imports
@@ -43,7 +43,7 @@ type UseDocumentOptions<
43
43
  TDocumentType extends string = string,
44
44
  TDataset extends string = string,
45
45
  TProjectId extends string = string,
46
- > = WithSourceNameSupport<DocumentOptions<TPath, TDocumentType, TDataset, TProjectId>>
46
+ > = WithResourceNameSupport<DocumentOptions<TPath, TDocumentType, TDataset, TProjectId>>
47
47
 
48
48
  interface UseDocument {
49
49
  /** @internal */
@@ -240,6 +240,6 @@ interface UseDocument {
240
240
  */
241
241
  export const useDocument = wrapHookWithData((options: UseDocumentOptions) => {
242
242
  useTrackHookUsage('useDocument')
243
- const normalizedOptions = useNormalizedSourceOptions(options)
243
+ const normalizedOptions = useNormalizedResourceOptions(options)
244
244
  return useDocumentValue(normalizedOptions)
245
245
  }) as UseDocument
@@ -2,7 +2,7 @@ import {type DatasetHandle, type DocumentEvent, subscribeDocumentEvents} from '@
2
2
  import {useCallback, useEffect, useInsertionEffect, useRef} from 'react'
3
3
 
4
4
  import {useSanityInstance} from '../context/useSanityInstance'
5
- import {useNormalizedSourceOptions} from '../helpers/useNormalizedSourceOptions'
5
+ import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
6
6
  import {useTrackHookUsage} from '../helpers/useTrackHookUsage'
7
7
 
8
8
  /**
@@ -79,7 +79,7 @@ export function useDocumentEvent<
79
79
  ): void {
80
80
  useTrackHookUsage('useDocumentEvent')
81
81
  // Destructure handler and datasetHandle from options
82
- const normalizedOptions = useNormalizedSourceOptions(options)
82
+ const normalizedOptions = useNormalizedResourceOptions(options)
83
83
  const {onEvent, ...datasetHandle} = normalizedOptions
84
84
  const ref = useRef(onEvent)
85
85
 
@@ -95,7 +95,7 @@ export function useDocumentEvent<
95
95
  useEffect(() => {
96
96
  return subscribeDocumentEvents(instance, {
97
97
  eventHandler: stableHandler,
98
- source: datasetHandle.source,
98
+ resource: datasetHandle.resource,
99
99
  })
100
- }, [instance, datasetHandle.source, stableHandler])
100
+ }, [instance, datasetHandle.resource, stableHandler])
101
101
  }
@@ -183,19 +183,19 @@ describe('usePermissions', () => {
183
183
  }).toThrow(/Mismatched datasets found in actions/)
184
184
  })
185
185
 
186
- it('should throw an error if actions have mismatched sources', () => {
186
+ it('should throw an error if actions have mismatched resources', () => {
187
187
  const actions = [
188
188
  {
189
189
  type: 'document.publish' as const,
190
190
  documentId: 'doc1',
191
191
  documentType: 'article',
192
- source: {projectId: 'p1', dataset: 'd1'},
192
+ resource: {projectId: 'p1', dataset: 'd1'},
193
193
  },
194
194
  {
195
195
  type: 'document.publish' as const,
196
196
  documentId: 'doc2',
197
197
  documentType: 'article',
198
- source: {projectId: 'p2', dataset: 'd2'},
198
+ resource: {projectId: 'p2', dataset: 'd2'},
199
199
  },
200
200
  ]
201
201
 
@@ -211,17 +211,17 @@ describe('usePermissions', () => {
211
211
  </ResourceProvider>
212
212
  ),
213
213
  })
214
- }).toThrow(/Mismatched sources found in actions/)
214
+ }).toThrow(/Mismatched resources found in actions/)
215
215
  })
216
216
 
217
- it('should throw an error when mixing projectId and source (projectId first)', () => {
217
+ it('should throw an error when mixing projectId and resource (projectId first)', () => {
218
218
  const actions = [
219
219
  mockAction,
220
220
  {
221
221
  type: 'document.publish' as const,
222
222
  documentId: 'doc2',
223
223
  documentType: 'article',
224
- source: {projectId: 'p', dataset: 'd'},
224
+ resource: {projectId: 'p', dataset: 'd'},
225
225
  },
226
226
  ]
227
227
 
@@ -237,16 +237,16 @@ describe('usePermissions', () => {
237
237
  </ResourceProvider>
238
238
  ),
239
239
  })
240
- }).toThrow(/Mismatches between projectId\/dataset options and source/)
240
+ }).toThrow(/Mismatches between projectId\/dataset options and resource/)
241
241
  })
242
242
 
243
- it('should throw an error when mixing source and projectId (source first)', () => {
243
+ it('should throw an error when mixing resource and projectId (resource first)', () => {
244
244
  const actions = [
245
245
  {
246
246
  type: 'document.publish' as const,
247
247
  documentId: 'doc1',
248
248
  documentType: 'article',
249
- source: {projectId: 'p', dataset: 'd'},
249
+ resource: {projectId: 'p', dataset: 'd'},
250
250
  },
251
251
  mockAction,
252
252
  ]
@@ -263,7 +263,7 @@ describe('usePermissions', () => {
263
263
  </ResourceProvider>
264
264
  ),
265
265
  })
266
- }).toThrow(/Mismatches between projectId\/dataset options and source/)
266
+ }).toThrow(/Mismatches between projectId\/dataset options and resource/)
267
267
  })
268
268
 
269
269
  it('should wait for permissions to be ready before rendering', async () => {
@@ -92,13 +92,13 @@ export function useDocumentPermissions(
92
92
  // if actions is an array, we need to check that all actions belong to the same project and dataset
93
93
  let projectId
94
94
  let dataset
95
- let source
95
+ let resource
96
96
 
97
97
  for (const action of actions) {
98
98
  if (action.projectId) {
99
- if (source) {
99
+ if (resource) {
100
100
  throw new Error(
101
- `Mismatches between projectId/dataset options and source in actions. Found projectId "${action.projectId}" and dataset "${action.dataset}" but expected source "${source}".`,
101
+ `Mismatches between projectId/dataset options and resource in actions. Found projectId "${action.projectId}" and dataset "${action.dataset}" but expected resource "${resource}".`,
102
102
  )
103
103
  }
104
104
  if (!projectId) projectId = action.projectId
@@ -118,16 +118,16 @@ export function useDocumentPermissions(
118
118
  }
119
119
  }
120
120
 
121
- if (action.source) {
122
- if (!source) source = action.source
123
- if (action.source !== source) {
121
+ if (action.resource) {
122
+ if (!resource) resource = action.resource
123
+ if (action.resource !== resource) {
124
124
  throw new Error(
125
- `Mismatched sources found in actions. All actions must belong to the same source. Found "${action.source}" but expected "${source}".`,
125
+ `Mismatched resources found in actions. All actions must belong to the same resource. Found "${action.resource}" but expected "${resource}".`,
126
126
  )
127
127
  }
128
128
  if (projectId || dataset) {
129
129
  throw new Error(
130
- `Mismatches between projectId/dataset options and source in actions. Found "${action.source}" but expected project "${projectId}" and dataset "${dataset}".`,
130
+ `Mismatches between projectId/dataset options and resource in actions. Found "${action.resource}" but expected project "${projectId}" and dataset "${dataset}".`,
131
131
  )
132
132
  }
133
133
  }
@@ -9,7 +9,7 @@ import {
9
9
  import {identity} from 'rxjs'
10
10
 
11
11
  import {createStateSourceHook} from '../helpers/createStateSourceHook'
12
- import {useNormalizedSourceOptions} from '../helpers/useNormalizedSourceOptions'
12
+ import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
13
13
 
14
14
  type UseDocumentSyncStatus = {
15
15
  /**
@@ -65,6 +65,6 @@ const useDocumentSyncStatusValue = createStateSourceHook({
65
65
  export const useDocumentSyncStatus: UseDocumentSyncStatus = (
66
66
  options: DocumentOptions<string | undefined>,
67
67
  ) => {
68
- const normalizedOptions = useNormalizedSourceOptions(options)
68
+ const normalizedOptions = useNormalizedResourceOptions(options)
69
69
  return useDocumentSyncStatusValue(normalizedOptions)
70
70
  }
@@ -10,7 +10,7 @@ import {type SanityDocument} from 'groq'
10
10
  import {useCallback} from 'react'
11
11
 
12
12
  import {useSanityInstance} from '../context/useSanityInstance'
13
- import {useNormalizedSourceOptions} from '../helpers/useNormalizedSourceOptions'
13
+ import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
14
14
  import {trackHookUsage} from '../helpers/useTrackHookUsage'
15
15
  import {useApplyDocumentActions} from './useApplyDocumentActions'
16
16
 
@@ -288,7 +288,7 @@ export function useEditDocument({
288
288
  }: DocumentOptions<string | undefined>): (updater: Updater<unknown>) => Promise<ActionsResult> {
289
289
  const instance = useSanityInstance(doc)
290
290
  trackHookUsage(instance, 'useEditDocument')
291
- const normalizedDoc = useNormalizedSourceOptions(doc)
291
+ const normalizedDoc = useNormalizedResourceOptions(doc)
292
292
 
293
293
  const apply = useApplyDocumentActions()
294
294
  const isDocumentReady = useCallback(
@@ -5,8 +5,7 @@ import {
5
5
  type QueryOptions,
6
6
  } from '@sanity/sdk'
7
7
  import {type SortOrderingItem} from '@sanity/types'
8
- import {pick} from 'lodash-es'
9
- import {useCallback, useEffect, useMemo, useState} from 'react'
8
+ import {useCallback, useMemo, useState} from 'react'
10
9
 
11
10
  import {useSanityInstance} from '../context/useSanityInstance'
12
11
  import {useTrackHookUsage} from '../helpers/useTrackHookUsage'
@@ -230,9 +229,11 @@ export function useDocuments<
230
229
  types: documentTypes,
231
230
  ...options,
232
231
  })
233
- useEffect(() => {
232
+ const [prevKey, setPrevKey] = useState(key)
233
+ if (prevKey !== key) {
234
+ setPrevKey(key)
234
235
  setLimit(batchSize)
235
- }, [key, batchSize])
236
+ }
236
237
 
237
238
  const filterClause = useMemo(() => {
238
239
  const conditions: string[] = []
@@ -281,9 +282,11 @@ export function useDocuments<
281
282
  query: `{"count":${countQuery},"data":${dataQuery}}`,
282
283
  params: {
283
284
  ...params,
285
+ // these are passed back to the user as part of each document handle
284
286
  __handle: {
285
- ...pick(instance.config, 'projectId', 'dataset', 'perspective'),
286
- ...pick(options, 'projectId', 'dataset', 'perspective'),
287
+ projectId: options.projectId ?? instance.config.projectId,
288
+ dataset: options.dataset ?? instance.config.dataset,
289
+ perspective: options.perspective ?? instance.config.perspective,
287
290
  },
288
291
  __types: documentTypes,
289
292
  },
@@ -0,0 +1,131 @@
1
+ import {type DocumentResource} from '@sanity/sdk'
2
+ import {useContext} from 'react'
3
+
4
+ import {ResourcesContext} from '../../context/ResourcesContext'
5
+
6
+ /**
7
+ * Adds React hook support (resourceName resolution) to core types.
8
+ * This wrapper allows hooks to accept `resourceName` as a convenience,
9
+ * which is then resolved to a `DocumentResource` at the React layer.
10
+ * For now, we are trying to avoid resource name resolution in core --
11
+ * functions having resources explicitly passed will reduce complexity.
12
+ *
13
+ * @typeParam T - The core type to extend (must have optional `resource` field)
14
+ * @beta
15
+ */
16
+ export type WithResourceNameSupport<T extends {resource?: DocumentResource}> = T & {
17
+ /**
18
+ * Optional name of a resource to resolve from context.
19
+ * If provided, will be resolved to a `DocumentResource` via `ResourcesContext`.
20
+ * @beta
21
+ */
22
+ resourceName?: string
23
+ /**
24
+ * @deprecated Use `resourceName` instead.
25
+ * @beta
26
+ */
27
+ sourceName?: string
28
+ }
29
+
30
+ /**
31
+ * Pure function that normalizes options by resolving `resourceName` to a `DocumentResource`
32
+ * using the provided resources map. Use this when options are only available at call time
33
+ * (e.g. inside a callback) and you cannot call the {@link useNormalizedResourceOptions} hook.
34
+ *
35
+ * @typeParam T - The options type (must include optional resource field)
36
+ * @param options - Options that may include `resourceName` and/or `resource`
37
+ * @param resources - Map of resource names to DocumentResource (e.g. from ResourcesContext)
38
+ * @returns Normalized options with `resourceName` removed and `resource` resolved
39
+ * @internal
40
+ */
41
+ export function normalizeResourceOptions<
42
+ T extends {
43
+ resource?: DocumentResource
44
+ resourceName?: string
45
+ source?: DocumentResource
46
+ sourceName?: string
47
+ },
48
+ >(
49
+ options: T,
50
+ resources: Record<string, DocumentResource>,
51
+ ): Omit<T, 'resourceName' | 'source' | 'sourceName'> {
52
+ const {resourceName, sourceName, source, ...rest} = options
53
+
54
+ // Coalesce deprecated aliases to their canonical equivalents
55
+ const effectiveResourceName = resourceName ?? sourceName
56
+ const effectiveResource = options.resource ?? source
57
+
58
+ if (!effectiveResourceName && !effectiveResource) {
59
+ return rest as Omit<T, 'resourceName' | 'source' | 'sourceName'>
60
+ }
61
+
62
+ const hasNameKey = Object.hasOwn(options, 'resourceName') || Object.hasOwn(options, 'sourceName')
63
+ const hasResourceKey = Object.hasOwn(options, 'resource') || Object.hasOwn(options, 'source')
64
+
65
+ if (hasNameKey && hasResourceKey) {
66
+ throw new Error(
67
+ `Resource name ${JSON.stringify(effectiveResourceName)} and resource ${JSON.stringify(effectiveResource)} cannot be used together.`,
68
+ )
69
+ }
70
+
71
+ let resolvedResource: DocumentResource | undefined
72
+
73
+ if (effectiveResource) {
74
+ resolvedResource = effectiveResource
75
+ }
76
+
77
+ if (effectiveResourceName && !Object.hasOwn(resources, effectiveResourceName)) {
78
+ throw new Error(
79
+ `There's no resource named ${JSON.stringify(effectiveResourceName)} in context. Please use <ResourceProvider>.`,
80
+ )
81
+ }
82
+
83
+ if (effectiveResourceName && resources[effectiveResourceName]) {
84
+ resolvedResource = resources[effectiveResourceName]
85
+ }
86
+
87
+ return {
88
+ ...rest,
89
+ resource: resolvedResource,
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Normalizes hook options by resolving `resourceName` to a `DocumentResource`.
95
+ * This hook ensures that options passed to core layer functions only contain
96
+ * `resource` (never `resourceName`), preventing duplicate cache keys and maintaining
97
+ * clean separation between React and core layers.
98
+ *
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 `resource` resolved
102
+ *
103
+ * @remarks
104
+ * Resolution priority:
105
+ * 1. If `resourceName` is provided, resolves it via `ResourcesContext` and uses that
106
+ * 2. Otherwise, uses the inline `resource` if provided
107
+ * 3. If neither is provided, returns options without a resource field
108
+ *
109
+ * @example
110
+ * ```tsx
111
+ * function useQuery(options: WithResourceNameSupport<QueryOptions>) {
112
+ * const instance = useSanityInstance(options)
113
+ * const normalized = useNormalizedOptions(options)
114
+ * // normalized now has resource but never resourceName
115
+ * const queryKey = getQueryKey(normalized)
116
+ * }
117
+ * ```
118
+ *
119
+ * @beta
120
+ */
121
+ export function useNormalizedResourceOptions<
122
+ T extends {
123
+ resource?: DocumentResource
124
+ resourceName?: string
125
+ source?: DocumentResource
126
+ sourceName?: string
127
+ },
128
+ >(options: T): Omit<T, 'resourceName' | 'source' | 'sourceName'> {
129
+ const resources = useContext(ResourcesContext)
130
+ return normalizeResourceOptions(options, resources)
131
+ }
@@ -18,8 +18,8 @@ import {useSanityInstance} from '../context/useSanityInstance'
18
18
  */
19
19
  export function useTrackHookUsage(hookName: string): void {
20
20
  const instance = useSanityInstance()
21
- const tracked = useRef(false)
22
- if (!tracked.current) {
21
+ const tracked = useRef<true | null>(null)
22
+ if (tracked.current === null) {
23
23
  tracked.current = true
24
24
  trackHookMounted(instance, hookName)
25
25
  }
@@ -1,7 +1,6 @@
1
1
  import {createGroqSearchFilter, type DocumentHandle, type QueryOptions} from '@sanity/sdk'
2
2
  import {type SortOrderingItem} from '@sanity/types'
3
- import {pick} from 'lodash-es'
4
- import {useCallback, useEffect, useMemo, useState} from 'react'
3
+ import {useCallback, useMemo, useState} from 'react'
5
4
 
6
5
  import {useSanityInstance} from '../context/useSanityInstance'
7
6
  import {useTrackHookUsage} from '../helpers/useTrackHookUsage'
@@ -243,11 +242,12 @@ export function usePaginatedDocuments<
243
242
  const instance = useSanityInstance(options)
244
243
  const [pageIndex, setPageIndex] = useState(0)
245
244
  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(() => {
245
+ // Reset pageIndex to 0 whenever any query parameter changes.
246
+ const [prevKey, setPrevKey] = useState(key)
247
+ if (prevKey !== key) {
248
+ setPrevKey(key)
249
249
  setPageIndex(0)
250
- }, [key])
250
+ }
251
251
 
252
252
  const startIndex = pageIndex * pageSize
253
253
  const endIndex = (pageIndex + 1) * pageSize
@@ -303,8 +303,9 @@ export function usePaginatedDocuments<
303
303
  ...params,
304
304
  __types: documentTypes,
305
305
  __handle: {
306
- ...pick(instance.config, 'projectId', 'dataset', 'perspective'),
307
- ...pick(options, 'projectId', 'dataset', 'perspective'),
306
+ projectId: options.projectId ?? instance.config.projectId,
307
+ dataset: options.dataset ?? instance.config.dataset,
308
+ perspective: options.perspective ?? instance.config.perspective,
308
309
  },
309
310
  },
310
311
  })
@@ -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
  })