@sanity/sdk-react 3.0.0-rc.0 → 3.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -155
- package/dist/index.d.ts +2488 -2019
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4435 -1529
- package/dist/index.js.map +1 -1
- package/package.json +55 -59
- package/src/_exports/index.ts +2 -4
- package/src/_exports/sdk-react.ts +30 -18
- 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 +93 -87
- package/src/components/auth/AuthBoundary.recovery.test.tsx +86 -0
- package/src/components/auth/AuthBoundary.test.tsx +7 -7
- package/src/components/auth/AuthBoundary.tsx +29 -6
- package/src/components/auth/DashboardAccessRequest.tsx +37 -0
- package/src/components/auth/LoginError.test.tsx +191 -5
- package/src/components/auth/LoginError.tsx +104 -61
- 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/ComlinkTokenRefresh.tsx +3 -5
- 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 +93 -90
- package/src/context/ResourceProvider.tsx +62 -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 +2 -2
- package/src/hooks/agent/agentActions.ts +52 -72
- package/src/hooks/agent/useAgentResourceContext.ts +1 -1
- 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/useDeleteApplication.test-d.ts +12 -0
- package/src/hooks/applications/useDeleteApplication.ts +11 -0
- package/src/hooks/applications/useUpdateApplication.test-d.ts +12 -0
- package/src/hooks/applications/useUpdateApplication.ts +11 -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/comlink/useFrameConnection.test.tsx +3 -2
- package/src/hooks/comlink/useFrameConnection.ts +1 -1
- package/src/hooks/comlink/useWindowConnection.test.tsx +4 -3
- package/src/hooks/comlink/useWindowConnection.ts +2 -3
- package/src/hooks/comments/useCommentActions.test.tsx +150 -0
- package/src/hooks/comments/useCommentActions.ts +109 -0
- package/src/hooks/comments/useCommentList.ts +79 -0
- package/src/hooks/comments/useCommentThreads.test.tsx +107 -0
- package/src/hooks/comments/useCommentThreads.ts +73 -0
- package/src/hooks/comments/useComments.test.tsx +242 -0
- package/src/hooks/comments/useComments.ts +59 -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 +18 -1
- package/src/hooks/context/useSanityInstance.ts +6 -6
- package/src/hooks/dashboard/useManageFavorite.test.tsx +110 -46
- package/src/hooks/dashboard/useManageFavorite.ts +11 -17
- 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 +2 -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 +31 -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 +226 -0
- package/src/hooks/helpers/useNormalizedResourceOptions.ts +79 -48
- 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 +35 -22
- package/src/hooks/presence/usePresence.test.tsx +0 -15
- package/src/hooks/presence/usePresence.ts +22 -20
- package/src/hooks/presence/usePresenceForDocument.test.tsx +141 -0
- package/src/hooks/presence/usePresenceForDocument.ts +104 -0
- package/src/hooks/presence/useReportPresence.test.tsx +202 -0
- package/src/hooks/presence/useReportPresence.ts +170 -0
- package/src/hooks/preview/useDocumentPreview.test.tsx +0 -1
- package/src/hooks/preview/useDocumentPreview.tsx +12 -14
- 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 +17 -32
- package/src/hooks/query/useQuery.test.tsx +6 -5
- package/src/hooks/query/useQuery.ts +94 -17
- 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 +4 -8
- package/src/hooks/users/useUsers.test.tsx +103 -7
- package/src/hooks/users/useUsers.ts +38 -5
- 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,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
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import {createSanityInstance} from '@sanity/sdk'
|
|
2
|
+
import {type MutationResult} from '@sanity/sdk/_internal'
|
|
3
|
+
import {act, renderHook} from '@testing-library/react'
|
|
4
|
+
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
5
|
+
|
|
6
|
+
import {useSanityInstance} from '../context/useSanityInstance'
|
|
7
|
+
import {createMutationHook} from './createMutationHook'
|
|
8
|
+
|
|
9
|
+
vi.mock('../context/useSanityInstance', () => ({
|
|
10
|
+
useSanityInstance: vi.fn(),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
const instance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
14
|
+
|
|
15
|
+
const settled = <T,>(data: T): MutationResult<T> => ({data, invalidated: Promise.resolve()})
|
|
16
|
+
|
|
17
|
+
describe('createMutationHook', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.clearAllMocks()
|
|
20
|
+
vi.mocked(useSanityInstance).mockReturnValue(instance)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('starts idle', () => {
|
|
24
|
+
const useThing = createMutationHook(vi.fn())
|
|
25
|
+
const {result} = renderHook(() => useThing())
|
|
26
|
+
|
|
27
|
+
expect(result.current.isPending).toBe(false)
|
|
28
|
+
expect(result.current.data).toBeUndefined()
|
|
29
|
+
expect(result.current.error).toBeUndefined()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('calls the mutation with the instance and resolves with the server data', async () => {
|
|
33
|
+
const mutation = vi.fn((_instance, input: string) =>
|
|
34
|
+
Promise.resolve(settled(`created:${input}`)),
|
|
35
|
+
)
|
|
36
|
+
const useThing = createMutationHook(mutation)
|
|
37
|
+
const {result} = renderHook(() => useThing())
|
|
38
|
+
|
|
39
|
+
let resolved: string | undefined
|
|
40
|
+
await act(async () => {
|
|
41
|
+
resolved = await result.current.mutate('a')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
expect(mutation).toHaveBeenCalledWith(instance, 'a')
|
|
45
|
+
expect(resolved).toBe('created:a')
|
|
46
|
+
expect(result.current.data).toBe('created:a')
|
|
47
|
+
expect(result.current.error).toBeUndefined()
|
|
48
|
+
expect(result.current.isPending).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('reports isPending while the mutation is in flight', async () => {
|
|
52
|
+
let release!: (value: MutationResult<string>) => void
|
|
53
|
+
const mutation = vi.fn(
|
|
54
|
+
() => new Promise<MutationResult<string>>((resolve) => (release = resolve)),
|
|
55
|
+
)
|
|
56
|
+
const useThing = createMutationHook(mutation)
|
|
57
|
+
const {result} = renderHook(() => useThing())
|
|
58
|
+
|
|
59
|
+
act(() => {
|
|
60
|
+
void result.current.mutate('a')
|
|
61
|
+
})
|
|
62
|
+
expect(result.current.isPending).toBe(true)
|
|
63
|
+
|
|
64
|
+
await act(async () => {
|
|
65
|
+
release(settled('done'))
|
|
66
|
+
})
|
|
67
|
+
expect(result.current.isPending).toBe(false)
|
|
68
|
+
expect(result.current.data).toBe('done')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('rejects on failure and captures the error in state', async () => {
|
|
72
|
+
const boom = new Error('nope')
|
|
73
|
+
const mutation = vi.fn(() => Promise.reject(boom))
|
|
74
|
+
const useThing = createMutationHook(mutation)
|
|
75
|
+
const {result} = renderHook(() => useThing())
|
|
76
|
+
|
|
77
|
+
await act(async () => {
|
|
78
|
+
await expect(result.current.mutate('a')).rejects.toBe(boom)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
expect(result.current.error).toBe(boom)
|
|
82
|
+
expect(result.current.data).toBeUndefined()
|
|
83
|
+
expect(result.current.isPending).toBe(false)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('reset() returns to idle', async () => {
|
|
87
|
+
const mutation = vi.fn((_instance, input: string) => Promise.resolve(settled(input)))
|
|
88
|
+
const useThing = createMutationHook(mutation)
|
|
89
|
+
const {result} = renderHook(() => useThing())
|
|
90
|
+
|
|
91
|
+
await act(async () => {
|
|
92
|
+
await result.current.mutate('a')
|
|
93
|
+
})
|
|
94
|
+
expect(result.current.data).toBe('a')
|
|
95
|
+
|
|
96
|
+
act(() => result.current.reset())
|
|
97
|
+
expect(result.current.data).toBeUndefined()
|
|
98
|
+
expect(result.current.error).toBeUndefined()
|
|
99
|
+
expect(result.current.isPending).toBe(false)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('only the latest call writes state, so a stale call cannot clobber it', async () => {
|
|
103
|
+
const releases: Array<(value: MutationResult<string>) => void> = []
|
|
104
|
+
const mutation = vi.fn(
|
|
105
|
+
() => new Promise<MutationResult<string>>((resolve) => releases.push(resolve)),
|
|
106
|
+
)
|
|
107
|
+
const useThing = createMutationHook(mutation)
|
|
108
|
+
const {result} = renderHook(() => useThing())
|
|
109
|
+
|
|
110
|
+
let first!: Promise<string>
|
|
111
|
+
let second!: Promise<string>
|
|
112
|
+
act(() => {
|
|
113
|
+
first = result.current.mutate('first')
|
|
114
|
+
second = result.current.mutate('second')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
await act(async () => {
|
|
118
|
+
releases[1]!(settled('second-result')) // latest resolves first
|
|
119
|
+
releases[0]!(settled('first-result')) // stale resolves after
|
|
120
|
+
await Promise.all([first, second])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
expect(result.current.data).toBe('second-result')
|
|
124
|
+
})
|
|
125
|
+
})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {type SanityInstance} from '@sanity/sdk'
|
|
2
|
+
import {type MutationResult} from '@sanity/sdk/_internal'
|
|
3
|
+
import {useCallback, useRef, useState} from 'react'
|
|
4
|
+
|
|
5
|
+
import {useSanityInstance} from '../context/useSanityInstance'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The value returned by a mutation-backed hook. The write-side counterpart to
|
|
9
|
+
* {@link FetcherHookResult}: local `{mutate, isPending, error, data, reset}`
|
|
10
|
+
* state around a core mutation action.
|
|
11
|
+
*
|
|
12
|
+
* @public
|
|
13
|
+
*/
|
|
14
|
+
export interface MutationHookResult<TInput, TResult> {
|
|
15
|
+
/**
|
|
16
|
+
* Runs the mutation. Resolves with the server response; rejects on failure
|
|
17
|
+
* (the failure is also captured in {@link MutationHookResult.error}, so a
|
|
18
|
+
* fire-and-forget caller should read `error` and an `await`ing caller should
|
|
19
|
+
* `try`/`catch`).
|
|
20
|
+
*/
|
|
21
|
+
mutate: (input: TInput) => Promise<TResult>
|
|
22
|
+
/** A mutation is currently in flight. */
|
|
23
|
+
isPending: boolean
|
|
24
|
+
/** The most recent failure; cleared by the next `mutate` or `reset`. */
|
|
25
|
+
error: unknown
|
|
26
|
+
/** The last successful server response, or `undefined` before the first success. */
|
|
27
|
+
data: TResult | undefined
|
|
28
|
+
/** Clears state back to idle and abandons any in-flight result. */
|
|
29
|
+
reset: () => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type MutationState<TResult> =
|
|
33
|
+
| {status: 'idle'; data: undefined; error: undefined}
|
|
34
|
+
| {status: 'pending'; data: TResult | undefined; error: undefined}
|
|
35
|
+
| {status: 'success'; data: TResult; error: undefined}
|
|
36
|
+
| {status: 'error'; data: TResult | undefined; error: unknown}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Builds a hook over a core mutation action (the result of `defineMutation`).
|
|
40
|
+
* The write-side counterpart to {@link createFetcherHook}: it binds the action
|
|
41
|
+
* to the current {@link SanityInstance} and tracks `{isPending, error, data}`
|
|
42
|
+
* locally. `mutate` resolves with the server response only — the mutation's
|
|
43
|
+
* `invalidated` promise is intentionally not exposed, since cache
|
|
44
|
+
* reconciliation is handled by the fetcher stores.
|
|
45
|
+
*
|
|
46
|
+
* @internal
|
|
47
|
+
*/
|
|
48
|
+
export function createMutationHook<TInput, TResult>(
|
|
49
|
+
mutation: (instance: SanityInstance, input: TInput) => Promise<MutationResult<TResult>>,
|
|
50
|
+
): () => MutationHookResult<TInput, TResult> {
|
|
51
|
+
const idle: MutationState<TResult> = {status: 'idle', data: undefined, error: undefined}
|
|
52
|
+
|
|
53
|
+
return function useMutationHook(): MutationHookResult<TInput, TResult> {
|
|
54
|
+
const instance = useSanityInstance()
|
|
55
|
+
const [state, setState] = useState<MutationState<TResult>>(idle)
|
|
56
|
+
// Increments per call; only the latest call is allowed to write state, so an
|
|
57
|
+
// earlier in-flight mutation can't clobber a later one (or a `reset`).
|
|
58
|
+
const callId = useRef(0)
|
|
59
|
+
|
|
60
|
+
const mutate = useCallback(
|
|
61
|
+
(input: TInput): Promise<TResult> => {
|
|
62
|
+
const id = ++callId.current
|
|
63
|
+
setState((prev) => ({status: 'pending', data: prev.data, error: undefined}))
|
|
64
|
+
return mutation(instance, input).then(
|
|
65
|
+
({data}) => {
|
|
66
|
+
if (id === callId.current) setState({status: 'success', data, error: undefined})
|
|
67
|
+
return data
|
|
68
|
+
},
|
|
69
|
+
(error: unknown) => {
|
|
70
|
+
if (id === callId.current) {
|
|
71
|
+
setState((prev) => ({status: 'error', data: prev.data, error}))
|
|
72
|
+
}
|
|
73
|
+
throw error
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
},
|
|
77
|
+
[instance],
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
const reset = useCallback(() => {
|
|
81
|
+
callId.current++ // abandon any in-flight response so it can't revive stale state
|
|
82
|
+
setState(idle)
|
|
83
|
+
}, [])
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
mutate,
|
|
87
|
+
isPending: state.status === 'pending',
|
|
88
|
+
error: state.error,
|
|
89
|
+
data: state.data,
|
|
90
|
+
reset,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {createSanityInstance, type
|
|
1
|
+
import {createSanityInstance, 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()
|
|
20
|
+
const mockInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
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()
|
|
42
|
+
const mockInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
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({
|
|
69
|
-
const mockInstance2 = createSanityInstance({
|
|
68
|
+
const mockInstance1 = createSanityInstance({projectId: 'p1', dataset: 'd'})
|
|
69
|
+
const mockInstance2 = createSanityInstance({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: () =>
|
|
75
|
+
getCurrent: () => instance.config.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()
|
|
92
|
+
const mockInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
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()
|
|
113
|
+
const mockInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
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()
|
|
132
|
+
const mockInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
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()
|
|
166
|
+
const mockInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
167
167
|
vi.mocked(useSanityInstance).mockReturnValue(mockInstance)
|
|
168
168
|
|
|
169
169
|
const mockState = {value: 'test'}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {type SanityInstance, type StateSource} from '@sanity/sdk'
|
|
1
|
+
import {type SanityConfig, type SanityInstance, type StateSource} from '@sanity/sdk'
|
|
2
2
|
import {useSyncExternalStore} from 'react'
|
|
3
3
|
|
|
4
4
|
import {useSanityInstance} from '../context/useSanityInstance'
|
|
@@ -12,6 +12,7 @@ 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
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export function createStateSourceHook<TParams extends unknown[], TState>(
|