@sanity/sdk-react 2.7.0 → 3.0.0-rc.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 (88) hide show
  1. package/README.md +125 -63
  2. package/dist/index.d.ts +381 -571
  3. package/dist/index.js +450 -366
  4. package/dist/index.js.map +1 -1
  5. package/package.json +6 -8
  6. package/src/_exports/index.ts +4 -0
  7. package/src/_exports/sdk-react.ts +16 -0
  8. package/src/components/SDKProvider.test.tsx +23 -58
  9. package/src/components/SDKProvider.tsx +38 -30
  10. package/src/components/SanityApp.test.tsx +12 -68
  11. package/src/components/SanityApp.tsx +88 -65
  12. package/src/components/auth/AuthBoundary.test.tsx +11 -26
  13. package/src/components/auth/LoginError.test.tsx +5 -0
  14. package/src/components/auth/LoginError.tsx +23 -2
  15. package/src/config/handles.ts +53 -0
  16. package/src/context/ComlinkTokenRefresh.test.tsx +27 -10
  17. package/src/context/DefaultResourceContext.ts +10 -0
  18. package/src/context/PerspectiveContext.ts +12 -0
  19. package/src/context/ResourceProvider.test.tsx +99 -19
  20. package/src/context/ResourceProvider.tsx +103 -37
  21. package/src/context/ResourcesContext.tsx +7 -0
  22. package/src/context/SDKStudioContext.test.tsx +33 -28
  23. package/src/context/SDKStudioContext.ts +6 -0
  24. package/src/context/renderSanityApp.test.tsx +49 -151
  25. package/src/context/renderSanityApp.tsx +8 -12
  26. package/src/hooks/agent/agentActions.test.tsx +1 -1
  27. package/src/hooks/agent/agentActions.ts +56 -19
  28. package/src/hooks/auth/useDashboardOrganizationId.test.tsx +8 -2
  29. package/src/hooks/auth/useVerifyOrgProjects.test.tsx +32 -8
  30. package/src/hooks/client/useClient.test.tsx +4 -1
  31. package/src/hooks/client/useClient.ts +0 -1
  32. package/src/hooks/context/useDefaultResource.test.tsx +25 -0
  33. package/src/hooks/context/useDefaultResource.ts +30 -0
  34. package/src/hooks/context/useSanityInstance.test.tsx +2 -140
  35. package/src/hooks/context/useSanityInstance.ts +9 -53
  36. package/src/hooks/dashboard/useDispatchIntent.test.ts +24 -15
  37. package/src/hooks/dashboard/useDispatchIntent.ts +7 -7
  38. package/src/hooks/dashboard/useManageFavorite.test.tsx +34 -94
  39. package/src/hooks/dashboard/useManageFavorite.ts +16 -10
  40. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +7 -5
  41. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +6 -2
  42. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.test.ts +2 -0
  43. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.ts +2 -1
  44. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.test.ts +17 -38
  45. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +12 -19
  46. package/src/hooks/datasets/useDatasets.test.ts +8 -22
  47. package/src/hooks/datasets/useDatasets.ts +8 -16
  48. package/src/hooks/document/useApplyDocumentActions.test.ts +98 -52
  49. package/src/hooks/document/useApplyDocumentActions.ts +35 -37
  50. package/src/hooks/document/useDocument.test.tsx +8 -37
  51. package/src/hooks/document/useDocument.ts +78 -129
  52. package/src/hooks/document/useDocumentEvent.test.tsx +7 -19
  53. package/src/hooks/document/useDocumentEvent.ts +21 -19
  54. package/src/hooks/document/useDocumentPermissions.test.tsx +75 -84
  55. package/src/hooks/document/useDocumentPermissions.ts +41 -28
  56. package/src/hooks/document/useDocumentSyncStatus.test.ts +13 -3
  57. package/src/hooks/document/useDocumentSyncStatus.ts +19 -14
  58. package/src/hooks/document/useEditDocument.test.tsx +28 -70
  59. package/src/hooks/document/useEditDocument.ts +29 -149
  60. package/src/hooks/documents/useDocuments.test.tsx +44 -64
  61. package/src/hooks/documents/useDocuments.ts +19 -25
  62. package/src/hooks/helpers/createCallbackHook.test.tsx +19 -13
  63. package/src/hooks/helpers/createStateSourceHook.test.tsx +10 -10
  64. package/src/hooks/helpers/createStateSourceHook.tsx +2 -4
  65. package/src/hooks/helpers/useNormalizedResourceOptions.test.ts +65 -0
  66. package/src/hooks/helpers/useNormalizedResourceOptions.ts +127 -0
  67. package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +27 -34
  68. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +19 -20
  69. package/src/hooks/presence/usePresence.test.tsx +71 -9
  70. package/src/hooks/presence/usePresence.ts +28 -3
  71. package/src/hooks/preview/useDocumentPreview.test.tsx +85 -193
  72. package/src/hooks/preview/useDocumentPreview.tsx +42 -62
  73. package/src/hooks/projection/useDocumentProjection.test.tsx +9 -37
  74. package/src/hooks/projection/useDocumentProjection.ts +9 -82
  75. package/src/hooks/projects/useProject.test.ts +1 -2
  76. package/src/hooks/projects/useProject.ts +7 -8
  77. package/src/hooks/query/useQuery.test.tsx +5 -6
  78. package/src/hooks/query/useQuery.ts +12 -91
  79. package/src/hooks/releases/useActiveReleases.test.tsx +2 -2
  80. package/src/hooks/releases/useActiveReleases.ts +25 -13
  81. package/src/hooks/releases/usePerspective.test.tsx +9 -17
  82. package/src/hooks/releases/usePerspective.ts +29 -18
  83. package/src/hooks/users/useUser.test.tsx +9 -3
  84. package/src/hooks/users/useUser.ts +1 -1
  85. package/src/hooks/users/useUsers.test.tsx +5 -2
  86. package/src/hooks/users/useUsers.ts +1 -1
  87. package/src/context/SourcesContext.tsx +0 -7
  88. package/src/hooks/helpers/useNormalizedSourceOptions.ts +0 -85
@@ -1,4 +1,4 @@
1
- import {createSanityInstance, type SanityInstance} from '@sanity/sdk'
1
+ import {createSanityInstance, type DatasetResource, type SanityInstance} from '@sanity/sdk'
2
2
  import {renderHook} from '@testing-library/react'
3
3
  import {throwError} from 'rxjs'
4
4
  import {describe, expect, it, vi} from 'vitest'
@@ -17,7 +17,7 @@ describe('createStateSourceHook', () => {
17
17
  })
18
18
 
19
19
  it('should create a hook that provides access to state source', () => {
20
- const mockInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
20
+ const mockInstance = createSanityInstance()
21
21
  vi.mocked(useSanityInstance).mockReturnValue(mockInstance)
22
22
 
23
23
  const mockState = {count: 0}
@@ -39,7 +39,7 @@ describe('createStateSourceHook', () => {
39
39
  })
40
40
 
41
41
  it('should recreate state source when params change', () => {
42
- const mockInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
42
+ const mockInstance = createSanityInstance()
43
43
  vi.mocked(useSanityInstance).mockReturnValue(mockInstance)
44
44
 
45
45
  const subscribe = vi.fn()
@@ -65,14 +65,14 @@ describe('createStateSourceHook', () => {
65
65
  })
66
66
 
67
67
  it('should recreate state source when instance changes', () => {
68
- const mockInstance1 = createSanityInstance({projectId: 'p1', dataset: 'd'})
69
- const mockInstance2 = createSanityInstance({projectId: 'p2', dataset: 'd'})
68
+ const mockInstance1 = createSanityInstance({defaultResource: {projectId: 'p1', dataset: 'd'}})
69
+ const mockInstance2 = createSanityInstance({defaultResource: {projectId: 'p2', dataset: 'd'}})
70
70
 
71
71
  vi.mocked(useSanityInstance).mockReturnValueOnce(mockInstance1)
72
72
 
73
73
  const stateSourceFactory = vi.fn((instance: SanityInstance) => ({
74
74
  subscribe: vi.fn(),
75
- getCurrent: () => instance.config.projectId,
75
+ getCurrent: () => (instance.config.defaultResource as DatasetResource)?.projectId,
76
76
  observable: throwError(() => new Error('unexpected usage of observable')),
77
77
  }))
78
78
 
@@ -89,7 +89,7 @@ describe('createStateSourceHook', () => {
89
89
  })
90
90
 
91
91
  it('should handle subscription functionality', () => {
92
- const mockInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
92
+ const mockInstance = createSanityInstance()
93
93
  vi.mocked(useSanityInstance).mockReturnValue(mockInstance)
94
94
 
95
95
  const mockSubscribe = vi.fn()
@@ -110,7 +110,7 @@ describe('createStateSourceHook', () => {
110
110
  })
111
111
 
112
112
  it('should handle multiple parameters', () => {
113
- const mockInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
113
+ const mockInstance = createSanityInstance()
114
114
  vi.mocked(useSanityInstance).mockReturnValue(mockInstance)
115
115
 
116
116
  const stateSourceFactory = vi.fn(
@@ -129,7 +129,7 @@ describe('createStateSourceHook', () => {
129
129
  })
130
130
 
131
131
  it('should throw suspender promise when shouldSuspend is true', () => {
132
- const mockInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
132
+ const mockInstance = createSanityInstance()
133
133
  vi.mocked(useSanityInstance).mockReturnValue(mockInstance)
134
134
 
135
135
  const mockGetState = vi.fn().mockReturnValue({
@@ -163,7 +163,7 @@ describe('createStateSourceHook', () => {
163
163
  })
164
164
 
165
165
  it('should not suspend when shouldSuspend returns false', () => {
166
- const mockInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
166
+ const mockInstance = createSanityInstance()
167
167
  vi.mocked(useSanityInstance).mockReturnValue(mockInstance)
168
168
 
169
169
  const mockState = {value: 'test'}
@@ -1,4 +1,4 @@
1
- import {type SanityConfig, type SanityInstance, type StateSource} from '@sanity/sdk'
1
+ import {type SanityInstance, type StateSource} from '@sanity/sdk'
2
2
  import {useSyncExternalStore} from 'react'
3
3
 
4
4
  import {useSanityInstance} from '../context/useSanityInstance'
@@ -12,18 +12,16 @@ interface CreateStateSourceHookOptions<TParams extends unknown[], TState> {
12
12
  getState: StateSourceFactory<TParams, TState>
13
13
  shouldSuspend?: (instance: SanityInstance, ...params: TParams) => boolean
14
14
  suspender?: (instance: SanityInstance, ...params: TParams) => Promise<unknown>
15
- getConfig?: (...params: TParams) => SanityConfig | undefined
16
15
  }
17
16
 
18
17
  export function createStateSourceHook<TParams extends unknown[], TState>(
19
18
  options: StateSourceFactory<TParams, TState> | CreateStateSourceHookOptions<TParams, TState>,
20
19
  ): (...params: TParams) => TState {
21
20
  const getState = typeof options === 'function' ? options : options.getState
22
- const getConfig = 'getConfig' in options ? options.getConfig : undefined
23
21
  const suspense = 'shouldSuspend' in options && 'suspender' in options ? options : undefined
24
22
 
25
23
  function useHook(...params: TParams) {
26
- const instance = useSanityInstance(getConfig?.(...params))
24
+ const instance = useSanityInstance()
27
25
 
28
26
  if (suspense?.suspender && suspense?.shouldSuspend?.(instance, ...params)) {
29
27
  throw suspense.suspender(instance, ...params)
@@ -0,0 +1,65 @@
1
+ import {describe, expect, it} from 'vitest'
2
+
3
+ import {normalizeResourceOptions} from './useNormalizedResourceOptions'
4
+
5
+ describe('normalizeResourceOptions', () => {
6
+ it('throws when both resource and resourceName are provided together', () => {
7
+ const resource = {projectId: 'p', dataset: 'd'}
8
+
9
+ expect(() =>
10
+ normalizeResourceOptions(
11
+ {resource, resourceName: 'my-resource'},
12
+ {'my-resource': resource},
13
+ undefined,
14
+ ),
15
+ ).toThrow(/cannot be used together/)
16
+ })
17
+
18
+ it('throws when no resource can be resolved from any source', () => {
19
+ expect(() =>
20
+ normalizeResourceOptions(
21
+ {},
22
+ {}, // no named resources
23
+ undefined, // no context resource
24
+ ),
25
+ ).toThrow(/resource is required/)
26
+ })
27
+
28
+ it('uses explicit resource when provided', () => {
29
+ const resource = {projectId: 'project-a', dataset: 'staging'}
30
+
31
+ const normalized = normalizeResourceOptions(
32
+ {resource},
33
+ {},
34
+ {projectId: 'default', dataset: 'prod'},
35
+ )
36
+
37
+ expect(normalized).toEqual({resource: {projectId: 'project-a', dataset: 'staging'}})
38
+ })
39
+
40
+ it('resolves resource from resourceName', () => {
41
+ const resource = {projectId: 'p', dataset: 'd'}
42
+
43
+ const normalized = normalizeResourceOptions(
44
+ {resourceName: 'my-resource'},
45
+ {'my-resource': resource},
46
+ undefined,
47
+ )
48
+
49
+ expect(normalized).toEqual({resource})
50
+ })
51
+
52
+ it('throws when resourceName is not found in resources map', () => {
53
+ expect(() => normalizeResourceOptions({resourceName: 'missing'}, {}, undefined)).toThrow(
54
+ /no resource named/,
55
+ )
56
+ })
57
+
58
+ it('falls back to context resource when neither resource nor resourceName is provided', () => {
59
+ const contextResource = {projectId: 'default', dataset: 'prod'}
60
+
61
+ const normalized = normalizeResourceOptions({}, {}, contextResource)
62
+
63
+ expect(normalized).toEqual({resource: contextResource})
64
+ })
65
+ })
@@ -0,0 +1,127 @@
1
+ import {type DocumentResource, type PerspectiveHandle} from '@sanity/sdk'
2
+ import {useContext} from 'react'
3
+
4
+ import {ResourceContext} from '../../context/DefaultResourceContext'
5
+ import {PerspectiveContext} from '../../context/PerspectiveContext'
6
+ import {ResourcesContext} from '../../context/ResourcesContext'
7
+
8
+ /**
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
+ * Adds React hook support (resourceName resolution) to core types.
12
+ * @internal
13
+ */
14
+ export type WithResourceNameSupport<T> = Omit<T, 'resource'> & {
15
+ resource?: DocumentResource
16
+ /**
17
+ * Optional name of a resource to resolve from context.
18
+ * If provided, will be resolved to a `DocumentResource` via `ResourcesContext`.
19
+ * @beta
20
+ */
21
+ resourceName?: string
22
+ }
23
+
24
+ /**
25
+ * 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.
29
+ *
30
+ * @typeParam T - The options type (must include optional resource field)
31
+ * @param options - Options that may include `resourceName` and/or `resource`
32
+ * @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
36
+ * @internal
37
+ */
38
+ export function normalizeResourceOptions<
39
+ T extends {
40
+ resource?: DocumentResource
41
+ resourceName?: string
42
+ perspective?: unknown
43
+ },
44
+ >(
45
+ options: T,
46
+ resources: Record<string, DocumentResource>,
47
+ contextResource?: DocumentResource,
48
+ contextPerspective?: PerspectiveHandle['perspective'],
49
+ ): Omit<T, 'resourceName' | 'resource'> & {resource: DocumentResource} {
50
+ const {resourceName, ...rest} = options
51
+
52
+ if (resourceName && Object.hasOwn(options, 'resource')) {
53
+ throw new Error(
54
+ `Resource name ${JSON.stringify(resourceName)} and resource ${JSON.stringify(options.resource)} cannot be used together.`,
55
+ )
56
+ }
57
+
58
+ let resolvedResource: DocumentResource | undefined = options.resource
59
+
60
+ if (!resolvedResource && resourceName) {
61
+ if (!Object.hasOwn(resources, resourceName)) {
62
+ throw new Error(
63
+ `There's no resource named ${JSON.stringify(resourceName)} in context. ` +
64
+ 'Register it via the resources prop on <SanityApp>.',
65
+ )
66
+ }
67
+ resolvedResource = resources[resourceName]
68
+ }
69
+
70
+ if (!resolvedResource) {
71
+ resolvedResource = contextResource
72
+ }
73
+
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
+ )
78
+ }
79
+
80
+ const resolvedPerspective = Object.hasOwn(options, 'perspective')
81
+ ? options.perspective
82
+ : contextPerspective
83
+
84
+ return {
85
+ ...rest,
86
+ resource: resolvedResource,
87
+ ...(resolvedPerspective !== undefined && {perspective: resolvedPerspective}),
88
+ } as Omit<T, 'resourceName' | 'resource'> & {resource: DocumentResource}
89
+ }
90
+
91
+ /**
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.
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 defaults injected
102
+ *
103
+ * @remarks
104
+ * 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`
109
+ *
110
+ * Resolution priority for perspective:
111
+ * 1. If `perspective` is explicitly provided in options, uses it
112
+ * 2. Otherwise, injects the value from `PerspectiveContext`
113
+ *
114
+ * @internal
115
+ */
116
+ export function useNormalizedResourceOptions<
117
+ T extends {
118
+ resource?: DocumentResource
119
+ resourceName?: string
120
+ perspective?: unknown
121
+ },
122
+ >(options: T): Omit<T, 'resourceName' | 'resource'> & {resource: DocumentResource} {
123
+ const resources = useContext(ResourcesContext)
124
+ const contextResource = useContext(ResourceContext)
125
+ const contextPerspective = useContext(PerspectiveContext)
126
+ return normalizeResourceOptions(options, resources, contextResource, contextPerspective)
127
+ }
@@ -1,20 +1,14 @@
1
- import {act, renderHook} from '@testing-library/react'
1
+ import {type DatasetResource} from '@sanity/sdk'
2
2
  import {evaluateSync, parse, toJS} from 'groq-js'
3
3
  import {describe, vi} from 'vitest'
4
4
 
5
- import {ResourceProvider} from '../../context/ResourceProvider'
5
+ import {act, renderHook} from '../../../test/test-utils'
6
6
  import {useQuery} from '../query/useQuery'
7
7
  import {usePaginatedDocuments} from './usePaginatedDocuments'
8
8
 
9
9
  vi.mock('../query/useQuery')
10
10
 
11
11
  describe('usePaginatedDocuments', () => {
12
- const wrapper = ({children}: {children: React.ReactNode}) => (
13
- <ResourceProvider projectId="p" dataset="d" fallback={null}>
14
- {children}
15
- </ResourceProvider>
16
- )
17
-
18
12
  beforeEach(() => {
19
13
  const dataset = [
20
14
  {
@@ -83,16 +77,14 @@ describe('usePaginatedDocuments', () => {
83
77
 
84
78
  it('should respect custom page size', () => {
85
79
  const customPageSize = 2
86
- const {result} = renderHook(() => usePaginatedDocuments({pageSize: customPageSize}), {wrapper})
80
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize: customPageSize}))
87
81
 
88
82
  expect(result.current.pageSize).toBe(customPageSize)
89
83
  expect(result.current.data.length).toBeLessThanOrEqual(customPageSize)
90
84
  })
91
85
 
92
86
  it('should filter by document type', () => {
93
- const {result} = renderHook(() => usePaginatedDocuments({filter: '_type == "movie"'}), {
94
- wrapper,
95
- })
87
+ const {result} = renderHook(() => usePaginatedDocuments({filter: '_type == "movie"'}))
96
88
 
97
89
  expect(result.current.data.every((doc) => doc.documentType === 'movie')).toBe(true)
98
90
  expect(result.current.count).toBe(5) // 5 movies in the dataset
@@ -100,20 +92,18 @@ describe('usePaginatedDocuments', () => {
100
92
 
101
93
  // groq-js doesn't support search filters yet
102
94
  it.skip('should apply search filter', () => {
103
- const {result} = renderHook(() => usePaginatedDocuments({search: 'inter'}), {wrapper})
95
+ const {result} = renderHook(() => usePaginatedDocuments({search: 'inter'}))
104
96
 
105
97
  // Should match "Interstellar"
106
98
  expect(result.current.data.some((doc) => doc.documentId === 'movie3')).toBe(true)
107
99
  })
108
100
 
109
101
  it('should apply ordering', () => {
110
- const {result} = renderHook(
111
- () =>
112
- usePaginatedDocuments({
113
- filter: '_type == "movie"',
114
- orderings: [{field: 'releaseYear', direction: 'desc'}],
115
- }),
116
- {wrapper},
102
+ const {result} = renderHook(() =>
103
+ usePaginatedDocuments({
104
+ filter: '_type == "movie"',
105
+ orderings: [{field: 'releaseYear', direction: 'desc'}],
106
+ }),
117
107
  )
118
108
 
119
109
  // First item should be the most recent movie (Interstellar, 2014)
@@ -122,7 +112,7 @@ describe('usePaginatedDocuments', () => {
122
112
 
123
113
  it('should calculate pagination values correctly', () => {
124
114
  const pageSize = 2
125
- const {result} = renderHook(() => usePaginatedDocuments({pageSize}), {wrapper})
115
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
126
116
 
127
117
  expect(result.current.currentPage).toBe(1)
128
118
  expect(result.current.totalPages).toBe(3) // 6 items with page size 2
@@ -133,7 +123,7 @@ describe('usePaginatedDocuments', () => {
133
123
 
134
124
  it('should navigate to next page', () => {
135
125
  const pageSize = 2
136
- const {result} = renderHook(() => usePaginatedDocuments({pageSize}), {wrapper})
126
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
137
127
 
138
128
  expect(result.current.currentPage).toBe(1)
139
129
  expect(result.current.data.length).toBe(pageSize)
@@ -149,7 +139,7 @@ describe('usePaginatedDocuments', () => {
149
139
 
150
140
  it('should navigate to previous page', () => {
151
141
  const pageSize = 2
152
- const {result} = renderHook(() => usePaginatedDocuments({pageSize}), {wrapper})
142
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
153
143
 
154
144
  // Go to page 2 first
155
145
  act(() => {
@@ -169,7 +159,7 @@ describe('usePaginatedDocuments', () => {
169
159
 
170
160
  it('should navigate to first page', () => {
171
161
  const pageSize = 2
172
- const {result} = renderHook(() => usePaginatedDocuments({pageSize}), {wrapper})
162
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
173
163
 
174
164
  // Go to last page first
175
165
  act(() => {
@@ -189,7 +179,7 @@ describe('usePaginatedDocuments', () => {
189
179
 
190
180
  it('should navigate to last page', () => {
191
181
  const pageSize = 2
192
- const {result} = renderHook(() => usePaginatedDocuments({pageSize}), {wrapper})
182
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
193
183
 
194
184
  act(() => {
195
185
  result.current.lastPage()
@@ -201,7 +191,7 @@ describe('usePaginatedDocuments', () => {
201
191
 
202
192
  it('should navigate to specific page', () => {
203
193
  const pageSize = 2
204
- const {result} = renderHook(() => usePaginatedDocuments({pageSize}), {wrapper})
194
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
205
195
 
206
196
  act(() => {
207
197
  result.current.goToPage(2) // Go to page 2
@@ -226,7 +216,7 @@ describe('usePaginatedDocuments', () => {
226
216
 
227
217
  it('should set page availability flags correctly', () => {
228
218
  const pageSize = 2
229
- const {result} = renderHook(() => usePaginatedDocuments({pageSize}), {wrapper})
219
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
230
220
  // On first page
231
221
  expect(result.current.hasFirstPage).toBe(false)
232
222
  expect(result.current.hasPreviousPage).toBe(false)
@@ -254,7 +244,6 @@ describe('usePaginatedDocuments', () => {
254
244
  it('should reset current page when filter changes', () => {
255
245
  const {result, rerender} = renderHook((props) => usePaginatedDocuments(props), {
256
246
  initialProps: {pageSize: 2, filter: ''},
257
- wrapper,
258
247
  })
259
248
  // Initially, current page should be 1
260
249
  expect(result.current.currentPage).toBe(1)
@@ -270,15 +259,19 @@ describe('usePaginatedDocuments', () => {
270
259
  })
271
260
 
272
261
  it('should add projectId and dataset to document handles', () => {
273
- const {result} = renderHook(() => usePaginatedDocuments({}), {wrapper})
262
+ const {result} = renderHook(() => usePaginatedDocuments({}))
274
263
 
275
264
  // Check that the first document handle has the projectId and dataset
276
- expect(result.current.data[0].projectId).toBe('p')
277
- expect(result.current.data[0].dataset).toBe('d')
265
+ expect((result.current.data[0].resource as DatasetResource).projectId).toBe('test')
266
+ expect((result.current.data[0].resource as DatasetResource).dataset).toBe('test')
278
267
 
279
268
  // Verify all document handles have these properties
280
- expect(result.current.data.every((doc) => doc.projectId === 'p' && doc.dataset === 'd')).toBe(
281
- true,
282
- )
269
+ expect(
270
+ result.current.data.every(
271
+ (doc) =>
272
+ (doc.resource as DatasetResource).projectId === 'test' &&
273
+ (doc.resource as DatasetResource).dataset === 'test',
274
+ ),
275
+ ).toBe(true)
283
276
  })
284
277
  })
@@ -1,9 +1,10 @@
1
- import {createGroqSearchFilter, type DocumentHandle, type QueryOptions} from '@sanity/sdk'
1
+ import {createGroqSearchFilter, type QueryOptions} from '@sanity/sdk'
2
2
  import {type SortOrderingItem} from '@sanity/types'
3
3
  import {pick} from 'lodash-es'
4
4
  import {useCallback, useEffect, useMemo, useState} from 'react'
5
5
 
6
- import {useSanityInstance} from '../context/useSanityInstance'
6
+ import {type DocumentHandle, type ResourceHandle} from '../../config/handles'
7
+ import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
7
8
  import {useQuery} from '../query/useQuery'
8
9
 
9
10
  /**
@@ -16,7 +17,10 @@ export interface PaginatedDocumentsOptions<
16
17
  TDocumentType extends string = string,
17
18
  TDataset extends string = string,
18
19
  TProjectId extends string = string,
19
- > extends Omit<QueryOptions<TDocumentType, TDataset, TProjectId>, 'query'> {
20
+ >
21
+ extends
22
+ ResourceHandle<TProjectId, TDataset>,
23
+ Pick<QueryOptions<TDocumentType, TDataset, TProjectId>, 'params'> {
20
24
  documentType?: TDocumentType | TDocumentType[]
21
25
  /**
22
26
  * GROQ filter expression to apply to the query
@@ -137,18 +141,16 @@ export interface PaginatedDocumentsResponse<
137
141
  * @returns An object containing the list of document handles, pagination details, and functions to navigate between pages
138
142
  *
139
143
  * @remarks
140
- * - The returned document handles include projectId and dataset information from the current Sanity instance
144
+ * - The returned document handles include resource information from the current Sanity instance
141
145
  * - This makes them ready to use with document operations and other document hooks
142
- * - The hook automatically uses the correct Sanity instance based on the projectId and dataset in the options
146
+ * - The hook automatically uses the correct Sanity instance based on the resource in the options
143
147
  *
144
148
  * @example Paginated list of documents with navigation
145
149
  * ```tsx
146
150
  * import {
147
151
  * usePaginatedDocuments,
148
- * createDatasetHandle,
149
- * type DatasetHandle,
150
152
  * type DocumentHandle,
151
- * type SortOrderingItem,
153
+ * type DocumentResource,
152
154
  * useDocumentProjection
153
155
  * } from '@sanity/sdk-react'
154
156
  * import {Suspense} from 'react'
@@ -171,10 +173,10 @@ export interface PaginatedDocumentsResponse<
171
173
  * // Define props for the list component
172
174
  * interface PaginatedDocumentListProps {
173
175
  * documentType: string
174
- * dataset?: DatasetHandle
176
+ * resource?: DocumentResource
175
177
  * }
176
178
  *
177
- * function PaginatedDocumentList({documentType, dataset}: PaginatedDocumentListProps) {
179
+ * function PaginatedDocumentList({documentType, resource}: PaginatedDocumentListProps) {
178
180
  * const {
179
181
  * data,
180
182
  * isPending,
@@ -185,7 +187,7 @@ export interface PaginatedDocumentsResponse<
185
187
  * hasNextPage,
186
188
  * hasPreviousPage
187
189
  * } = usePaginatedDocuments({
188
- * ...dataset,
190
+ * resource,
189
191
  * documentType,
190
192
  * pageSize: 10,
191
193
  * orderings: [{field: '_createdAt', direction: 'desc'}],
@@ -217,8 +219,7 @@ export interface PaginatedDocumentsResponse<
217
219
  * }
218
220
  *
219
221
  * // Usage:
220
- * // const myDatasetHandle = createDatasetHandle({ projectId: 'p1', dataset: 'production' })
221
- * // <PaginatedDocumentList dataset={myDatasetHandle} documentType="post" />
222
+ * // <PaginatedDocumentList resource={{projectId: 'p1', dataset: 'production'}} documentType="post" />
222
223
  * ```
223
224
  */
224
225
  export function usePaginatedDocuments<
@@ -232,15 +233,15 @@ export function usePaginatedDocuments<
232
233
  params = {},
233
234
  orderings,
234
235
  search,
235
- ...options
236
+ ...rawOptions
236
237
  }: PaginatedDocumentsOptions<TDocumentType, TDataset, TProjectId>): PaginatedDocumentsResponse<
237
238
  TDocumentType,
238
239
  TDataset,
239
240
  TProjectId
240
241
  > {
241
- const instance = useSanityInstance(options)
242
+ const options = useNormalizedResourceOptions(rawOptions)
242
243
  const [pageIndex, setPageIndex] = useState(0)
243
- const key = JSON.stringify({filter, search, params, orderings, pageSize})
244
+ const key = JSON.stringify({filter, search, params, orderings, pageSize, ...options})
244
245
  // Reset the pageIndex to 0 whenever any query parameters (filter, search,
245
246
  // params, orderings) or pageSize changes
246
247
  useEffect(() => {
@@ -300,10 +301,8 @@ export function usePaginatedDocuments<
300
301
  params: {
301
302
  ...params,
302
303
  __types: documentTypes,
303
- __handle: {
304
- ...pick(instance.config, 'projectId', 'dataset', 'perspective'),
305
- ...pick(options, 'projectId', 'dataset', 'perspective'),
306
- },
304
+ // these are passed back to the user as part of each document handle
305
+ __handle: pick(options, ['resource', 'perspective']),
307
306
  },
308
307
  })
309
308
 
@@ -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,60 @@ 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 throw an error when used with a canvas resource', () => {
106
+ expect(() => {
107
+ renderHook(() => usePresence({resource: {canvasId: 'canvas123'}}), {
108
+ wrapper: ({children}) => (
109
+ <ResourceProvider
110
+ resource={{projectId: 'test-project', dataset: 'test-dataset'}}
111
+ fallback={null}
112
+ >
113
+ {children}
114
+ </ResourceProvider>
115
+ ),
116
+ })
117
+ }).toThrow('usePresence() does not support canvas resources')
118
+ })
119
+
120
+ it('should work with a dataset resource', () => {
121
+ const mockPresenceSource = {
122
+ getCurrent: vi.fn().mockReturnValue([]),
123
+ subscribe: vi.fn(() => () => {}),
124
+ observable: NEVER,
125
+ }
126
+ vi.mocked(getPresence).mockReturnValue(mockPresenceSource)
127
+
128
+ const {result, unmount} = renderHook(
129
+ () => usePresence({resource: {projectId: 'test-project', dataset: 'test-dataset'}}),
130
+ {
131
+ wrapper: ({children}) => (
132
+ <ResourceProvider
133
+ resource={{projectId: 'test-project', dataset: 'test-dataset'}}
134
+ fallback={null}
135
+ >
136
+ {children}
137
+ </ResourceProvider>
138
+ ),
139
+ },
140
+ )
141
+
142
+ expect(result.current.locations).toEqual([])
143
+ unmount()
144
+ })
83
145
  })