@sanity/sdk-react 3.0.0-rc.0 → 3.0.0-rc.1
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.
- package/README.md +103 -155
- package/dist/index.d.ts +1564 -682
- package/dist/index.js +1118 -612
- package/dist/index.js.map +1 -1
- package/package.json +51 -54
- package/src/_exports/index.ts +2 -4
- package/src/_exports/sdk-react.ts +26 -17
- package/src/components/SDKProvider.test.tsx +173 -32
- package/src/components/SDKProvider.tsx +87 -28
- package/src/components/SanityApp.test.tsx +68 -12
- package/src/components/SanityApp.tsx +94 -87
- package/src/components/auth/AuthBoundary.recovery.test.tsx +86 -0
- package/src/components/auth/AuthBoundary.test.tsx +26 -8
- package/src/components/auth/AuthBoundary.tsx +28 -6
- package/src/components/auth/DashboardAccessRequest.tsx +37 -0
- package/src/components/auth/LoginError.test.tsx +191 -5
- package/src/components/auth/LoginError.tsx +103 -59
- package/src/components/errors/ChunkLoadError.test.tsx +59 -0
- package/src/components/errors/ChunkLoadError.tsx +56 -0
- package/src/components/errors/CorsErrorComponent.tsx +2 -2
- package/src/components/errors/chunkReloadStorage.ts +57 -0
- package/src/config/handles.ts +29 -27
- package/src/constants.ts +5 -0
- package/src/context/ComlinkTokenRefresh.test.tsx +10 -27
- package/src/context/DefaultResourceContext.ts +3 -3
- package/src/context/OrganizationResourcesProvider.test.tsx +189 -0
- package/src/context/OrganizationResourcesProvider.tsx +111 -0
- package/src/context/PerspectiveContext.ts +3 -3
- package/src/context/ProjectContext.ts +15 -0
- package/src/context/ResourceProvider.test.tsx +72 -90
- package/src/context/ResourceProvider.tsx +59 -93
- package/src/context/SDKStudioContext.test.tsx +28 -33
- package/src/context/SanityInstanceProvider.test.tsx +100 -0
- package/src/context/SanityInstanceProvider.tsx +71 -0
- package/src/context/WorkbenchTokenRefresh.test.tsx +106 -0
- package/src/context/WorkbenchTokenRefresh.tsx +61 -0
- package/src/context/renderSanityApp.test.tsx +151 -49
- package/src/context/renderSanityApp.tsx +12 -8
- package/src/context/workbenchToken.ts +63 -0
- package/src/hooks/access/useCheckPermissions.test-d.ts +15 -0
- package/src/hooks/access/useCheckPermissions.test.tsx +53 -0
- package/src/hooks/access/useCheckPermissions.ts +24 -0
- package/src/hooks/agent/agentActions.test.tsx +1 -1
- package/src/hooks/agent/agentActions.ts +51 -71
- package/src/hooks/applications/useApplication.test-d.ts +30 -0
- package/src/hooks/applications/useApplication.ts +22 -0
- package/src/hooks/applications/useApplications.test-d.ts +31 -0
- package/src/hooks/applications/useApplications.ts +25 -0
- package/src/hooks/applications/useCreateUserApplication.test-d.ts +14 -0
- package/src/hooks/applications/useCreateUserApplication.ts +11 -0
- package/src/hooks/applications/useDeleteApplication.test-d.ts +12 -0
- package/src/hooks/applications/useDeleteApplication.ts +11 -0
- package/src/hooks/applications/useDeleteUserApplication.test-d.ts +14 -0
- package/src/hooks/applications/useDeleteUserApplication.ts +11 -0
- package/src/hooks/applications/useUpdateApplication.test-d.ts +12 -0
- package/src/hooks/applications/useUpdateApplication.ts +11 -0
- package/src/hooks/applications/useUpdateUserApplication.test-d.ts +14 -0
- package/src/hooks/applications/useUpdateUserApplication.ts +11 -0
- package/src/hooks/applications/useUserApplication.test-d.ts +11 -0
- package/src/hooks/applications/useUserApplication.ts +14 -0
- package/src/hooks/applications/useUserApplications.test-d.ts +11 -0
- package/src/hooks/applications/useUserApplications.ts +14 -0
- package/src/hooks/auth/useDashboardOrganizationId.test.tsx +2 -8
- package/src/hooks/auth/useVerifyOrgProjects.test.tsx +8 -32
- package/src/hooks/auth/useVerifyOrgProjects.tsx +13 -6
- package/src/hooks/client/useClient.test.tsx +1 -4
- package/src/hooks/client/useClient.ts +1 -0
- package/src/hooks/context/useResource.test.tsx +32 -0
- package/src/hooks/context/useResource.ts +24 -0
- package/src/hooks/context/useSanityInstance.test.tsx +72 -3
- package/src/hooks/context/useSanityInstance.ts +30 -8
- package/src/hooks/dashboard/useManageFavorite.test.tsx +110 -46
- package/src/hooks/dashboard/useManageFavorite.ts +10 -16
- package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +5 -7
- package/src/hooks/dashboard/useNavigateToStudioDocument.ts +2 -6
- package/src/hooks/dashboard/useRecordDocumentHistoryEvent.test.ts +0 -2
- package/src/hooks/dashboard/useRecordDocumentHistoryEvent.ts +1 -2
- package/src/hooks/dashboard/useWindowTitle.test.ts +244 -0
- package/src/hooks/dashboard/useWindowTitle.ts +114 -0
- package/src/hooks/datasets/useDatasets.test.tsx +116 -0
- package/src/hooks/datasets/useDatasets.ts +33 -13
- package/src/hooks/document/useApplyDocumentActions.test.tsx +48 -0
- package/src/hooks/document/useApplyDocumentActions.ts +66 -48
- package/src/hooks/document/useCreateDocument.test.tsx +83 -0
- package/src/hooks/document/useCreateDocument.ts +117 -0
- package/src/hooks/document/useDocument.test.tsx +37 -8
- package/src/hooks/document/useDocument.ts +131 -66
- package/src/hooks/document/useDocumentEvent.test.tsx +16 -4
- package/src/hooks/document/useDocumentEvent.ts +16 -10
- package/src/hooks/document/useDocumentPermissions.test.tsx +8 -7
- package/src/hooks/document/useDocumentPermissions.ts +71 -39
- package/src/hooks/document/useDocumentSyncStatus.test.ts +1 -0
- package/src/hooks/document/useDocumentSyncStatus.ts +13 -9
- package/src/hooks/document/useEditDocument.test.tsx +21 -14
- package/src/hooks/document/useEditDocument.ts +163 -17
- package/src/hooks/documents/useDocuments.test.tsx +64 -44
- package/src/hooks/documents/useDocuments.ts +36 -20
- package/src/hooks/helpers/createCallbackHook.test.tsx +13 -19
- package/src/hooks/helpers/createFetcherHook.test.tsx +180 -0
- package/src/hooks/helpers/createFetcherHook.ts +69 -0
- package/src/hooks/helpers/createMutationHook.test.tsx +125 -0
- package/src/hooks/helpers/createMutationHook.tsx +93 -0
- package/src/hooks/helpers/createStateSourceHook.test.tsx +10 -10
- package/src/hooks/helpers/createStateSourceHook.tsx +2 -1
- package/src/hooks/{document/useApplyDocumentActions.test.ts → helpers/useApplyActions.test.tsx} +92 -53
- package/src/hooks/helpers/useApplyActions.ts +68 -0
- package/src/hooks/helpers/useNormalizedResourceOptions.test.tsx +253 -0
- package/src/hooks/helpers/useNormalizedResourceOptions.ts +92 -50
- package/src/hooks/helpers/useResolvedProjectId.test.tsx +59 -0
- package/src/hooks/helpers/useResolvedProjectId.ts +35 -0
- package/src/hooks/helpers/useTrackHookUsage.ts +37 -0
- package/src/hooks/installations/useInstallation.test-d.ts +19 -0
- package/src/hooks/installations/useInstallation.ts +22 -0
- package/src/hooks/installations/useInstallations.test-d.ts +26 -0
- package/src/hooks/installations/useInstallations.ts +25 -0
- package/src/hooks/organizations/useOrganization.test-d.ts +60 -0
- package/src/hooks/organizations/useOrganization.test.ts +63 -0
- package/src/hooks/organizations/useOrganization.ts +34 -0
- package/src/hooks/organizations/useOrganizations.test-d.ts +68 -0
- package/src/hooks/organizations/useOrganizations.test.ts +63 -0
- package/src/hooks/organizations/useOrganizations.ts +39 -0
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +34 -27
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +40 -22
- package/src/hooks/presence/usePresence.test.tsx +0 -15
- package/src/hooks/presence/usePresence.ts +9 -20
- package/src/hooks/preview/useDocumentPreview.test.tsx +0 -1
- package/src/hooks/preview/useDocumentPreview.tsx +10 -8
- package/src/hooks/projection/useDocumentProjection.test.tsx +78 -9
- package/src/hooks/projection/useDocumentProjection.ts +81 -2
- package/src/hooks/projects/useProject.test-d.ts +56 -0
- package/src/hooks/projects/useProject.test.tsx +120 -0
- package/src/hooks/projects/useProject.ts +42 -43
- package/src/hooks/projects/useProjects.test-d.ts +60 -0
- package/src/hooks/projects/useProjects.test.ts +45 -98
- package/src/hooks/projects/useProjects.ts +19 -26
- package/src/hooks/query/useQuery.test.tsx +6 -5
- package/src/hooks/query/useQuery.ts +91 -9
- package/src/hooks/releases/useActiveReleases.test.tsx +13 -7
- package/src/hooks/releases/useActiveReleases.ts +28 -24
- package/src/hooks/releases/useAllReleases.test.tsx +93 -0
- package/src/hooks/releases/useAllReleases.ts +62 -0
- package/src/hooks/releases/useApplyReleaseActions.test.tsx +66 -0
- package/src/hooks/releases/useApplyReleaseActions.ts +82 -0
- package/src/hooks/releases/usePerspective.test.tsx +18 -10
- package/src/hooks/releases/usePerspective.ts +13 -17
- package/src/hooks/users/useUser.test.tsx +3 -9
- package/src/hooks/users/useUser.ts +2 -0
- package/src/hooks/users/useUsers.test.tsx +103 -7
- package/src/hooks/users/useUsers.ts +37 -3
- package/src/utils/resolveOrgResources.test.ts +111 -0
- package/src/utils/resolveOrgResources.ts +69 -0
- package/src/hooks/context/useDefaultResource.test.tsx +0 -25
- package/src/hooks/context/useDefaultResource.ts +0 -30
- package/src/hooks/dashboard/useDispatchIntent.test.ts +0 -254
- package/src/hooks/dashboard/useDispatchIntent.ts +0 -157
- package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.test.ts +0 -107
- package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +0 -39
- package/src/hooks/datasets/useDatasets.test.ts +0 -66
- package/src/hooks/helpers/useNormalizedResourceOptions.test.ts +0 -65
- package/src/hooks/projects/useProject.test.ts +0 -79
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {act, renderHook} from '@testing-library/react'
|
|
2
2
|
import {evaluateSync, parse, toJS} from 'groq-js'
|
|
3
3
|
import {describe, vi} from 'vitest'
|
|
4
4
|
|
|
5
|
-
import {act, renderHook} from '../../../test/test-utils'
|
|
6
5
|
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
7
|
-
import {ResourcesContext} from '../../context/ResourcesContext'
|
|
8
6
|
import {useQuery} from '../query/useQuery'
|
|
9
7
|
import {useDocuments} from './useDocuments'
|
|
10
8
|
|
|
@@ -79,13 +77,25 @@ describe('useDocuments', () => {
|
|
|
79
77
|
|
|
80
78
|
it('should respect custom page size', () => {
|
|
81
79
|
const customBatchSize = 2
|
|
82
|
-
const {result} = renderHook(() => useDocuments({batchSize: customBatchSize})
|
|
80
|
+
const {result} = renderHook(() => useDocuments({batchSize: customBatchSize}), {
|
|
81
|
+
wrapper: ({children}) => (
|
|
82
|
+
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
|
|
83
|
+
{children}
|
|
84
|
+
</ResourceProvider>
|
|
85
|
+
),
|
|
86
|
+
})
|
|
83
87
|
|
|
84
88
|
expect(result.current.data.length).toBe(customBatchSize)
|
|
85
89
|
})
|
|
86
90
|
|
|
87
91
|
it('should filter by document type', () => {
|
|
88
|
-
const {result} = renderHook(() => useDocuments({filter: '_type == "movie"'})
|
|
92
|
+
const {result} = renderHook(() => useDocuments({filter: '_type == "movie"'}), {
|
|
93
|
+
wrapper: ({children}) => (
|
|
94
|
+
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
|
|
95
|
+
{children}
|
|
96
|
+
</ResourceProvider>
|
|
97
|
+
),
|
|
98
|
+
})
|
|
89
99
|
|
|
90
100
|
expect(result.current.data.every((doc) => doc.documentType === 'movie')).toBe(true)
|
|
91
101
|
expect(result.current.count).toBe(5) // 5 movies in the dataset
|
|
@@ -93,18 +103,32 @@ describe('useDocuments', () => {
|
|
|
93
103
|
|
|
94
104
|
// groq-js doesn't support search filters yet
|
|
95
105
|
it.skip('should apply search filter', () => {
|
|
96
|
-
const {result} = renderHook(() => useDocuments({search: 'inter'})
|
|
106
|
+
const {result} = renderHook(() => useDocuments({search: 'inter'}), {
|
|
107
|
+
wrapper: ({children}) => (
|
|
108
|
+
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
|
|
109
|
+
{children}
|
|
110
|
+
</ResourceProvider>
|
|
111
|
+
),
|
|
112
|
+
})
|
|
97
113
|
|
|
98
114
|
// Should match "Interstellar"
|
|
99
115
|
expect(result.current.data.some((doc) => doc.documentId === 'movie3')).toBe(true)
|
|
100
116
|
})
|
|
101
117
|
|
|
102
118
|
it('should apply ordering', () => {
|
|
103
|
-
const {result} = renderHook(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
119
|
+
const {result} = renderHook(
|
|
120
|
+
() =>
|
|
121
|
+
useDocuments({
|
|
122
|
+
filter: '_type == "movie"',
|
|
123
|
+
orderings: [{field: 'releaseYear', direction: 'desc'}],
|
|
124
|
+
}),
|
|
125
|
+
{
|
|
126
|
+
wrapper: ({children}) => (
|
|
127
|
+
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
|
|
128
|
+
{children}
|
|
129
|
+
</ResourceProvider>
|
|
130
|
+
),
|
|
131
|
+
},
|
|
108
132
|
)
|
|
109
133
|
|
|
110
134
|
// First item should be the most recent movie (Interstellar, 2014)
|
|
@@ -113,7 +137,13 @@ describe('useDocuments', () => {
|
|
|
113
137
|
|
|
114
138
|
it('should load more data when loadMore is called', () => {
|
|
115
139
|
const batchSize = 2
|
|
116
|
-
const {result} = renderHook(() => useDocuments({batchSize: batchSize})
|
|
140
|
+
const {result} = renderHook(() => useDocuments({batchSize: batchSize}), {
|
|
141
|
+
wrapper: ({children}) => (
|
|
142
|
+
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
|
|
143
|
+
{children}
|
|
144
|
+
</ResourceProvider>
|
|
145
|
+
),
|
|
146
|
+
})
|
|
117
147
|
|
|
118
148
|
expect(result.current.data.length).toBe(batchSize)
|
|
119
149
|
|
|
@@ -125,7 +155,13 @@ describe('useDocuments', () => {
|
|
|
125
155
|
})
|
|
126
156
|
|
|
127
157
|
it('should indicate when there is more data to load', () => {
|
|
128
|
-
const {result} = renderHook(() => useDocuments({batchSize: 3})
|
|
158
|
+
const {result} = renderHook(() => useDocuments({batchSize: 3}), {
|
|
159
|
+
wrapper: ({children}) => (
|
|
160
|
+
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
|
|
161
|
+
{children}
|
|
162
|
+
</ResourceProvider>
|
|
163
|
+
),
|
|
164
|
+
})
|
|
129
165
|
expect(result.current.hasMore).toBe(true)
|
|
130
166
|
// Load all remaining data
|
|
131
167
|
act(() => {
|
|
@@ -138,6 +174,11 @@ describe('useDocuments', () => {
|
|
|
138
174
|
it('should reset limit when filter changes', () => {
|
|
139
175
|
const {result, rerender} = renderHook((props) => useDocuments(props), {
|
|
140
176
|
initialProps: {batchSize: 2, filter: ''},
|
|
177
|
+
wrapper: ({children}) => (
|
|
178
|
+
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
|
|
179
|
+
{children}
|
|
180
|
+
</ResourceProvider>
|
|
181
|
+
),
|
|
141
182
|
})
|
|
142
183
|
// Initially, data length equals pageSize (2)
|
|
143
184
|
expect(result.current.data.length).toBe(2)
|
|
@@ -153,44 +194,23 @@ describe('useDocuments', () => {
|
|
|
153
194
|
expect(result.current.data.length).toBe(2)
|
|
154
195
|
})
|
|
155
196
|
|
|
156
|
-
it('should add
|
|
157
|
-
const {result} = renderHook(() => useDocuments({})
|
|
158
|
-
|
|
159
|
-
// Check that the first document handle has the projectId and dataset
|
|
160
|
-
expect((result.current.data[0].resource as DatasetResource).projectId).toBe('test')
|
|
161
|
-
expect((result.current.data[0].resource as DatasetResource).dataset).toBe('test')
|
|
162
|
-
|
|
163
|
-
// Verify all document handles have these properties
|
|
164
|
-
expect(
|
|
165
|
-
result.current.data.every(
|
|
166
|
-
(doc) =>
|
|
167
|
-
(doc.resource as DatasetResource).projectId === 'test' &&
|
|
168
|
-
(doc.resource as DatasetResource).dataset === 'test',
|
|
169
|
-
),
|
|
170
|
-
).toBe(true)
|
|
171
|
-
})
|
|
172
|
-
|
|
173
|
-
it('should resolve resourceName to the named dataset resource', () => {
|
|
174
|
-
const resources = {
|
|
175
|
-
default: {projectId: 'test-project', dataset: 'test-dataset'},
|
|
176
|
-
secondary: {projectId: 'secondary-project', dataset: 'secondary-dataset'},
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const {result} = renderHook(() => useDocuments({resourceName: 'secondary'}), {
|
|
197
|
+
it('should add projectId and dataset to document handles', () => {
|
|
198
|
+
const {result} = renderHook(() => useDocuments({}), {
|
|
180
199
|
wrapper: ({children}) => (
|
|
181
|
-
<ResourceProvider
|
|
182
|
-
|
|
200
|
+
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
|
|
201
|
+
{children}
|
|
183
202
|
</ResourceProvider>
|
|
184
203
|
),
|
|
185
204
|
})
|
|
186
205
|
|
|
187
|
-
|
|
188
|
-
expect(
|
|
206
|
+
// Check that the first document handle has the projectId and dataset
|
|
207
|
+
expect(result.current.data[0].projectId).toBe('test-project')
|
|
208
|
+
expect(result.current.data[0].dataset).toBe('test-dataset')
|
|
209
|
+
|
|
210
|
+
// Verify all document handles have these properties
|
|
189
211
|
expect(
|
|
190
212
|
result.current.data.every(
|
|
191
|
-
(doc) =>
|
|
192
|
-
(doc.resource as DatasetResource).projectId === 'secondary-project' &&
|
|
193
|
-
(doc.resource as DatasetResource).dataset === 'secondary-dataset',
|
|
213
|
+
(doc) => doc.projectId === 'test-project' && doc.dataset === 'test-dataset',
|
|
194
214
|
),
|
|
195
215
|
).toBe(true)
|
|
196
216
|
})
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createGroqSearchFilter,
|
|
3
|
+
type DocumentHandle,
|
|
4
|
+
isDatasetResource,
|
|
5
|
+
type QueryOptions,
|
|
6
|
+
} from '@sanity/sdk'
|
|
7
|
+
import {pickProperties} from '@sanity/sdk/_internal'
|
|
2
8
|
import {type SortOrderingItem} from '@sanity/types'
|
|
3
|
-
import {
|
|
4
|
-
import {useCallback, useEffect, useMemo, useState} from 'react'
|
|
9
|
+
import {useCallback, useMemo, useState} from 'react'
|
|
5
10
|
|
|
6
|
-
import {type
|
|
11
|
+
import {type ResourceHandle} from '../../config/handles'
|
|
7
12
|
import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
|
|
13
|
+
import {useTrackHookUsage} from '../helpers/useTrackHookUsage'
|
|
8
14
|
import {useQuery} from '../query/useQuery'
|
|
9
15
|
|
|
10
16
|
const DEFAULT_BATCH_SIZE = 25
|
|
@@ -20,9 +26,7 @@ export interface DocumentsOptions<
|
|
|
20
26
|
TDataset extends string = string,
|
|
21
27
|
TProjectId extends string = string,
|
|
22
28
|
>
|
|
23
|
-
extends
|
|
24
|
-
ResourceHandle<TProjectId, TDataset>,
|
|
25
|
-
Pick<QueryOptions<TDocumentType, TDataset, TProjectId>, 'params'> {
|
|
29
|
+
extends ResourceHandle<TDataset, TProjectId>, Pick<QueryOptions, 'perspective' | 'params'> {
|
|
26
30
|
/**
|
|
27
31
|
* Filter documents by their `_type`. Can be a single type or an array of types.
|
|
28
32
|
*/
|
|
@@ -90,16 +94,18 @@ export interface DocumentsResponse<
|
|
|
90
94
|
* @returns An object containing the list of document handles, the loading state, the total count of retrieved document handles, and a function to load more
|
|
91
95
|
*
|
|
92
96
|
* @remarks
|
|
93
|
-
* - The returned document handles include
|
|
97
|
+
* - The returned document handles include projectId and dataset information from the current Sanity instance
|
|
94
98
|
* - This makes them ready to use with document operations and other document hooks
|
|
95
|
-
* - The hook automatically uses the correct Sanity instance based on the
|
|
99
|
+
* - The hook automatically uses the correct Sanity instance based on the projectId and dataset in the options
|
|
96
100
|
*
|
|
97
101
|
* @example Basic infinite list with loading more
|
|
98
102
|
* ```tsx
|
|
99
103
|
* import {
|
|
100
104
|
* useDocuments,
|
|
105
|
+
* createDatasetHandle,
|
|
106
|
+
* type DatasetHandle,
|
|
101
107
|
* type DocumentHandle,
|
|
102
|
-
* type
|
|
108
|
+
* type SortOrderingItem
|
|
103
109
|
* } from '@sanity/sdk-react'
|
|
104
110
|
* import {Suspense} from 'react'
|
|
105
111
|
*
|
|
@@ -115,14 +121,14 @@ export interface DocumentsResponse<
|
|
|
115
121
|
*
|
|
116
122
|
* // Define props for the list component
|
|
117
123
|
* interface DocumentListProps {
|
|
118
|
-
*
|
|
124
|
+
* dataset: DatasetHandle
|
|
119
125
|
* documentType: string
|
|
120
126
|
* search?: string
|
|
121
127
|
* }
|
|
122
128
|
*
|
|
123
|
-
* function DocumentList({
|
|
129
|
+
* function DocumentList({dataset, documentType, search}: DocumentListProps) {
|
|
124
130
|
* const { data, hasMore, isPending, loadMore, count } = useDocuments({
|
|
125
|
-
*
|
|
131
|
+
* ...dataset,
|
|
126
132
|
* documentType,
|
|
127
133
|
* search,
|
|
128
134
|
* batchSize: 10,
|
|
@@ -136,7 +142,7 @@ export interface DocumentsResponse<
|
|
|
136
142
|
* {data.map((docHandle) => (
|
|
137
143
|
* <li key={docHandle.documentId}>
|
|
138
144
|
* <Suspense fallback="Loading…">
|
|
139
|
-
* <MyDocumentComponent
|
|
145
|
+
* <MyDocumentComponent docHandle={docHandle} />
|
|
140
146
|
* </Suspense>
|
|
141
147
|
* </li>
|
|
142
148
|
* ))}
|
|
@@ -151,7 +157,8 @@ export interface DocumentsResponse<
|
|
|
151
157
|
* }
|
|
152
158
|
*
|
|
153
159
|
* // Usage:
|
|
154
|
-
* //
|
|
160
|
+
* // const myDatasetHandle = createDatasetHandle({ projectId: 'p1', dataset: 'production' })
|
|
161
|
+
* // <DocumentList dataset={myDatasetHandle} documentType="post" search="Sanity" />
|
|
155
162
|
* ```
|
|
156
163
|
*
|
|
157
164
|
* @example Using `filter` and `params` options for narrowing a collection
|
|
@@ -202,8 +209,8 @@ export function useDocuments<
|
|
|
202
209
|
TDataset,
|
|
203
210
|
TProjectId
|
|
204
211
|
> {
|
|
205
|
-
|
|
206
|
-
|
|
212
|
+
useTrackHookUsage('useDocuments')
|
|
213
|
+
const options = useNormalizedResourceOptions(rawOptions)
|
|
207
214
|
const [limit, setLimit] = useState(batchSize)
|
|
208
215
|
const documentTypes = useMemo(
|
|
209
216
|
() =>
|
|
@@ -224,9 +231,11 @@ export function useDocuments<
|
|
|
224
231
|
types: documentTypes,
|
|
225
232
|
...options,
|
|
226
233
|
})
|
|
227
|
-
|
|
234
|
+
const [prevKey, setPrevKey] = useState(key)
|
|
235
|
+
if (prevKey !== key) {
|
|
236
|
+
setPrevKey(key)
|
|
228
237
|
setLimit(batchSize)
|
|
229
|
-
}
|
|
238
|
+
}
|
|
230
239
|
|
|
231
240
|
const filterClause = useMemo(() => {
|
|
232
241
|
const conditions: string[] = []
|
|
@@ -276,7 +285,14 @@ export function useDocuments<
|
|
|
276
285
|
params: {
|
|
277
286
|
...params,
|
|
278
287
|
// these are passed back to the user as part of each document handle
|
|
279
|
-
__handle:
|
|
288
|
+
__handle: {
|
|
289
|
+
// keep projectId/dataset for backward compat until v4; resource is added
|
|
290
|
+
// intentionally so that hook consumers can resolve the correct resource
|
|
291
|
+
...(options.resource && isDatasetResource(options.resource)
|
|
292
|
+
? pickProperties(options.resource, ['projectId', 'dataset'])
|
|
293
|
+
: {}),
|
|
294
|
+
...pickProperties(options, ['perspective', 'resource']),
|
|
295
|
+
},
|
|
280
296
|
__types: documentTypes,
|
|
281
297
|
},
|
|
282
298
|
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {type SanityInstance} from '@sanity/sdk'
|
|
2
|
+
import {renderHook} from '@testing-library/react'
|
|
2
3
|
import {describe, expect, it, vi} from 'vitest'
|
|
3
4
|
|
|
4
|
-
import {renderHook} from '../../../test/test-utils'
|
|
5
5
|
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
6
6
|
import {createCallbackHook} from './createCallbackHook'
|
|
7
7
|
|
|
@@ -21,7 +21,13 @@ describe('createCallbackHook', () => {
|
|
|
21
21
|
const useTestHook = createCallbackHook(testCallback)
|
|
22
22
|
|
|
23
23
|
// Render the hook
|
|
24
|
-
const {result, rerender} = renderHook(() => useTestHook()
|
|
24
|
+
const {result, rerender} = renderHook(() => useTestHook(), {
|
|
25
|
+
wrapper: ({children}) => (
|
|
26
|
+
<ResourceProvider projectId="p" dataset="d" fallback={null}>
|
|
27
|
+
{children}
|
|
28
|
+
</ResourceProvider>
|
|
29
|
+
),
|
|
30
|
+
})
|
|
25
31
|
|
|
26
32
|
// Test the callback with parameters
|
|
27
33
|
const result1 = result.current('test', 123)
|
|
@@ -38,17 +44,13 @@ describe('createCallbackHook', () => {
|
|
|
38
44
|
|
|
39
45
|
it('should create new callback when instance changes', () => {
|
|
40
46
|
// Create a test callback
|
|
41
|
-
const testCallback = (instance: SanityInstance) => instance.config.
|
|
47
|
+
const testCallback = (instance: SanityInstance) => instance.config.projectId
|
|
42
48
|
|
|
43
49
|
// Create and render our hook with first provider
|
|
44
50
|
const useTestHook = createCallbackHook(testCallback)
|
|
45
51
|
const {result, unmount} = renderHook(() => useTestHook(), {
|
|
46
52
|
wrapper: ({children}) => (
|
|
47
|
-
<ResourceProvider
|
|
48
|
-
studio={{projectId: 'p1'}}
|
|
49
|
-
resource={{projectId: 'p1', dataset: 'd'}}
|
|
50
|
-
fallback={null}
|
|
51
|
-
>
|
|
53
|
+
<ResourceProvider projectId="p1" dataset="d" fallback={null}>
|
|
52
54
|
{children}
|
|
53
55
|
</ResourceProvider>
|
|
54
56
|
),
|
|
@@ -64,11 +66,7 @@ describe('createCallbackHook', () => {
|
|
|
64
66
|
// Re-render with different provider configuration
|
|
65
67
|
const {result: result2} = renderHook(() => useTestHook(), {
|
|
66
68
|
wrapper: ({children}) => (
|
|
67
|
-
<ResourceProvider
|
|
68
|
-
studio={{projectId: 'p2'}}
|
|
69
|
-
resource={{projectId: 'p2', dataset: 'd'}}
|
|
70
|
-
fallback={null}
|
|
71
|
-
>
|
|
69
|
+
<ResourceProvider projectId="p2" dataset="d" fallback={null}>
|
|
72
70
|
{children}
|
|
73
71
|
</ResourceProvider>
|
|
74
72
|
),
|
|
@@ -87,7 +85,7 @@ describe('createCallbackHook', () => {
|
|
|
87
85
|
method: string,
|
|
88
86
|
data: object,
|
|
89
87
|
) => ({
|
|
90
|
-
url: `${instance.config.
|
|
88
|
+
url: `${instance.config.projectId}${path}`,
|
|
91
89
|
method,
|
|
92
90
|
data,
|
|
93
91
|
})
|
|
@@ -95,11 +93,7 @@ describe('createCallbackHook', () => {
|
|
|
95
93
|
const useTestHook = createCallbackHook(testCallback)
|
|
96
94
|
const {result} = renderHook(() => useTestHook(), {
|
|
97
95
|
wrapper: ({children}) => (
|
|
98
|
-
<ResourceProvider
|
|
99
|
-
studio={{projectId: 'p'}}
|
|
100
|
-
resource={{projectId: 'p', dataset: 'd'}}
|
|
101
|
-
fallback={null}
|
|
102
|
-
>
|
|
96
|
+
<ResourceProvider projectId="p" dataset="d" fallback={null}>
|
|
103
97
|
{children}
|
|
104
98
|
</ResourceProvider>
|
|
105
99
|
),
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import {createSanityInstance} from '@sanity/sdk'
|
|
2
|
+
import {defineFetcher, type Fetcher, type FetcherSnapshot} from '@sanity/sdk/_internal'
|
|
3
|
+
import {renderHook} from '@testing-library/react'
|
|
4
|
+
import {of, throwError} from 'rxjs'
|
|
5
|
+
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
6
|
+
|
|
7
|
+
import {useSanityInstance} from '../context/useSanityInstance'
|
|
8
|
+
import {createFetcherHook} from './createFetcherHook'
|
|
9
|
+
|
|
10
|
+
vi.mock('../context/useSanityInstance', () => ({
|
|
11
|
+
useSanityInstance: vi.fn(),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
const instance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
15
|
+
|
|
16
|
+
const makeFetcher = (snapshot: FetcherSnapshot<string>): Fetcher<[id: string], string> => {
|
|
17
|
+
const fetcher = {
|
|
18
|
+
getState: vi.fn(() => ({
|
|
19
|
+
subscribe: vi.fn(() => () => {}),
|
|
20
|
+
getCurrent: () => snapshot,
|
|
21
|
+
observable: throwError(() => new Error('unexpected usage of observable')),
|
|
22
|
+
})),
|
|
23
|
+
resolveState: vi.fn(() => Promise.resolve('resolved')),
|
|
24
|
+
refetch: vi.fn(() => Promise.resolve('refetched')),
|
|
25
|
+
invalidate: vi.fn(),
|
|
26
|
+
invalidateAll: vi.fn(),
|
|
27
|
+
setData: vi.fn(() => ({undo: vi.fn()})),
|
|
28
|
+
}
|
|
29
|
+
return fetcher as unknown as Fetcher<[id: string], string>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const success: FetcherSnapshot<string> = {
|
|
33
|
+
status: 'success',
|
|
34
|
+
data: 'DATA',
|
|
35
|
+
error: undefined,
|
|
36
|
+
isFetching: false,
|
|
37
|
+
dataUpdatedAt: 1,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('createFetcherHook', () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
vi.clearAllMocks()
|
|
43
|
+
vi.mocked(useSanityInstance).mockReturnValue(instance)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('returns {data, isFetching, error, refetch} from a success snapshot', () => {
|
|
47
|
+
const fetcher = makeFetcher(success)
|
|
48
|
+
const useThing = createFetcherHook(fetcher)
|
|
49
|
+
const {result} = renderHook(() => useThing('a'))
|
|
50
|
+
|
|
51
|
+
expect(result.current.data).toBe('DATA')
|
|
52
|
+
expect(result.current.isFetching).toBe(false)
|
|
53
|
+
expect(result.current.error).toBeUndefined()
|
|
54
|
+
expect(typeof result.current.refetch).toBe('function')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('surfaces isFetching and the background error on the success arm', () => {
|
|
58
|
+
const boom = new Error('background')
|
|
59
|
+
const fetcher = makeFetcher({...success, isFetching: true, error: boom})
|
|
60
|
+
const useThing = createFetcherHook(fetcher)
|
|
61
|
+
const {result} = renderHook(() => useThing('a'))
|
|
62
|
+
|
|
63
|
+
expect(result.current.isFetching).toBe(true)
|
|
64
|
+
expect(result.current.error).toBe(boom)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('refetch() calls the fetcher with the instance and params', () => {
|
|
68
|
+
const fetcher = makeFetcher(success)
|
|
69
|
+
const useThing = createFetcherHook(fetcher)
|
|
70
|
+
const {result} = renderHook(() => useThing('a'))
|
|
71
|
+
|
|
72
|
+
const returned = result.current.refetch()
|
|
73
|
+
|
|
74
|
+
expect(fetcher.refetch).toHaveBeenCalledWith(instance, 'a')
|
|
75
|
+
expect(returned).toBeInstanceOf(Promise)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('suspends on a pending snapshot by throwing resolveState', () => {
|
|
79
|
+
const fetcher = makeFetcher({
|
|
80
|
+
status: 'pending',
|
|
81
|
+
data: undefined,
|
|
82
|
+
error: undefined,
|
|
83
|
+
isFetching: true,
|
|
84
|
+
dataUpdatedAt: undefined,
|
|
85
|
+
})
|
|
86
|
+
const useThing = createFetcherHook(fetcher)
|
|
87
|
+
const {result} = renderHook(() => {
|
|
88
|
+
try {
|
|
89
|
+
return useThing('a')
|
|
90
|
+
} catch (thrown) {
|
|
91
|
+
return thrown
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
expect(fetcher.resolveState).toHaveBeenCalledWith(instance, 'a')
|
|
96
|
+
expect(result.current).toBe(vi.mocked(fetcher.resolveState).mock.results[0]!.value)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('throws the error arm to the error boundary', () => {
|
|
100
|
+
const boom = new Error('fatal')
|
|
101
|
+
const fetcher = makeFetcher({
|
|
102
|
+
status: 'error',
|
|
103
|
+
data: undefined,
|
|
104
|
+
error: boom,
|
|
105
|
+
isFetching: false,
|
|
106
|
+
dataUpdatedAt: undefined,
|
|
107
|
+
})
|
|
108
|
+
const useThing = createFetcherHook(fetcher)
|
|
109
|
+
const {result} = renderHook(() => {
|
|
110
|
+
try {
|
|
111
|
+
return useThing('a')
|
|
112
|
+
} catch (thrown) {
|
|
113
|
+
return thrown
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
expect(result.current).toBe(boom)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// Regression: the mocked fetcher above can never reproduce the real bug — its
|
|
121
|
+
// `getCurrent` returns a fixed snapshot object. A real fetcher store replaces
|
|
122
|
+
// the cache entry on every (un)subscribe, so `getCurrent` yields a new snapshot
|
|
123
|
+
// reference each time. Feeding that straight through `useSyncExternalStore`
|
|
124
|
+
// makes React see the store "change" on every commit and re-render forever
|
|
125
|
+
// ("Maximum update depth exceeded", React #185).
|
|
126
|
+
it('does not re-render forever over a real fetcher store (SDK-1448)', () => {
|
|
127
|
+
const fetcher = defineFetcher<[id: string], string>({
|
|
128
|
+
name: `regression-loop-${Math.random().toString(36).slice(2)}`,
|
|
129
|
+
getKey: (_instance, id) => id,
|
|
130
|
+
fetch: () => (id) => of(`DATA:${id}`),
|
|
131
|
+
})
|
|
132
|
+
// Seed a success entry so we skip Suspense and land in useSyncExternalStore.
|
|
133
|
+
fetcher.setData(instance, ['a'], 'DATA:a')
|
|
134
|
+
|
|
135
|
+
const useThing = createFetcherHook(fetcher)
|
|
136
|
+
|
|
137
|
+
let renders = 0
|
|
138
|
+
const {result} = renderHook(() => {
|
|
139
|
+
renders += 1
|
|
140
|
+
if (renders > 25) throw new Error(`infinite render loop: ${renders} renders`)
|
|
141
|
+
return useThing('a')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
expect(result.current.data).toBe('DATA:a')
|
|
145
|
+
expect(renders).toBeLessThan(25)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// The realistic case: `data` is an object, not a primitive. The store hands
|
|
149
|
+
// back a fresh snapshot object on every (un)subscribe, so the dedup must keep
|
|
150
|
+
// returning the *same* `data` reference while the entry is unchanged —
|
|
151
|
+
// otherwise consumers that key off `data` identity (memo deps, effects)
|
|
152
|
+
// re-render on every commit even though nothing changed. This is the property
|
|
153
|
+
// the snapshot-equality check guards.
|
|
154
|
+
it('keeps a stable object data reference across re-renders (SDK-1448)', () => {
|
|
155
|
+
const data = {id: 'a', title: 'Thing', tags: ['x', 'y']}
|
|
156
|
+
const fetcher = defineFetcher<[id: string], typeof data>({
|
|
157
|
+
name: `regression-object-${Math.random().toString(36).slice(2)}`,
|
|
158
|
+
getKey: (_instance, id) => id,
|
|
159
|
+
fetch: () => () => of(data),
|
|
160
|
+
})
|
|
161
|
+
fetcher.setData(instance, ['a'], data)
|
|
162
|
+
|
|
163
|
+
const useThing = createFetcherHook(fetcher)
|
|
164
|
+
|
|
165
|
+
let renders = 0
|
|
166
|
+
const seen: unknown[] = []
|
|
167
|
+
const {result} = renderHook(() => {
|
|
168
|
+
renders += 1
|
|
169
|
+
if (renders > 25) throw new Error(`infinite render loop: ${renders} renders`)
|
|
170
|
+
const value = useThing('a')
|
|
171
|
+
seen.push(value.data)
|
|
172
|
+
return value
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
expect(result.current.data).toBe(data)
|
|
176
|
+
expect(renders).toBeLessThan(25)
|
|
177
|
+
// Every commit observed the identical object — no churn from fresh snapshots.
|
|
178
|
+
expect(seen.every((d) => d === data)).toBe(true)
|
|
179
|
+
})
|
|
180
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {type Fetcher, type FetcherSnapshot, isDeepEqual} from '@sanity/sdk/_internal'
|
|
2
|
+
import {useRef, useSyncExternalStore} from 'react'
|
|
3
|
+
|
|
4
|
+
import {useSanityInstance} from '../context/useSanityInstance'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The value returned by a fetcher-backed hook. The hook suspends until the
|
|
8
|
+
* first fetch succeeds, so `data` is always present once your component renders.
|
|
9
|
+
*
|
|
10
|
+
* @public
|
|
11
|
+
*/
|
|
12
|
+
export interface FetcherHookResult<TData> {
|
|
13
|
+
/** The resolved data. Guaranteed present — the hook suspends until the first fetch succeeds. */
|
|
14
|
+
data: TData
|
|
15
|
+
/** A fetch for this entry is in flight (background revalidation or a `refetch()`). */
|
|
16
|
+
isFetching: boolean
|
|
17
|
+
/** The most recent background-fetch failure while data still renders; cleared by the next success. */
|
|
18
|
+
error: unknown
|
|
19
|
+
/** Imperatively refetch, bypassing staleness. Resolves with the refreshed data. */
|
|
20
|
+
refetch: () => Promise<TData>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Builds a Suspense hook over a {@link Fetcher}: it suspends until the first
|
|
25
|
+
* success via the snapshot's `status`, throws the error arm to the nearest error
|
|
26
|
+
* boundary, and returns the live `{data, isFetching, error, refetch}` envelope.
|
|
27
|
+
*
|
|
28
|
+
* @internal
|
|
29
|
+
*/
|
|
30
|
+
export function createFetcherHook<TParams extends unknown[], TData>(
|
|
31
|
+
fetcher: Fetcher<TParams, TData>,
|
|
32
|
+
): (...params: TParams) => FetcherHookResult<TData> {
|
|
33
|
+
return function useFetcherHook(...params: TParams): FetcherHookResult<TData> {
|
|
34
|
+
const instance = useSanityInstance()
|
|
35
|
+
const source = fetcher.getState(instance, ...params)
|
|
36
|
+
|
|
37
|
+
// No entry/data yet — suspend on the first fetch (matches an 'error' below never reaching here).
|
|
38
|
+
if (source.getCurrent().status === 'pending') {
|
|
39
|
+
throw fetcher.resolveState(instance, ...params)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The store replaces its cache entry on every (un)subscribe (it bumps a
|
|
43
|
+
// subscription counter), so `getCurrent()` hands back a fresh snapshot object
|
|
44
|
+
// with identical fields on each commit. Passed raw to `useSyncExternalStore`
|
|
45
|
+
// — which compares by `Object.is` — that reads as a perpetual change and
|
|
46
|
+
// re-renders forever. Reuse the previous snapshot while its fields are
|
|
47
|
+
// unchanged so React sees a stable reference.
|
|
48
|
+
const previous = useRef<FetcherSnapshot<TData> | null>(null)
|
|
49
|
+
const snapshot = useSyncExternalStore(source.subscribe, () => {
|
|
50
|
+
const next = source.getCurrent()
|
|
51
|
+
const prev = previous.current
|
|
52
|
+
if (prev && isDeepEqual(prev, next)) return prev
|
|
53
|
+
previous.current = next
|
|
54
|
+
return next
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
if (snapshot.status !== 'success') {
|
|
58
|
+
// 'pending' already suspended above; 'error' surfaces to the error boundary.
|
|
59
|
+
throw snapshot.status === 'error' ? snapshot.error : fetcher.resolveState(instance, ...params)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
data: snapshot.data,
|
|
64
|
+
isFetching: snapshot.isFetching,
|
|
65
|
+
error: snapshot.error,
|
|
66
|
+
refetch: () => fetcher.refetch(instance, ...params),
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|