@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.
Files changed (160) hide show
  1. package/README.md +103 -155
  2. package/dist/index.d.ts +1564 -682
  3. package/dist/index.js +1118 -612
  4. package/dist/index.js.map +1 -1
  5. package/package.json +51 -54
  6. package/src/_exports/index.ts +2 -4
  7. package/src/_exports/sdk-react.ts +26 -17
  8. package/src/components/SDKProvider.test.tsx +173 -32
  9. package/src/components/SDKProvider.tsx +87 -28
  10. package/src/components/SanityApp.test.tsx +68 -12
  11. package/src/components/SanityApp.tsx +94 -87
  12. package/src/components/auth/AuthBoundary.recovery.test.tsx +86 -0
  13. package/src/components/auth/AuthBoundary.test.tsx +26 -8
  14. package/src/components/auth/AuthBoundary.tsx +28 -6
  15. package/src/components/auth/DashboardAccessRequest.tsx +37 -0
  16. package/src/components/auth/LoginError.test.tsx +191 -5
  17. package/src/components/auth/LoginError.tsx +103 -59
  18. package/src/components/errors/ChunkLoadError.test.tsx +59 -0
  19. package/src/components/errors/ChunkLoadError.tsx +56 -0
  20. package/src/components/errors/CorsErrorComponent.tsx +2 -2
  21. package/src/components/errors/chunkReloadStorage.ts +57 -0
  22. package/src/config/handles.ts +29 -27
  23. package/src/constants.ts +5 -0
  24. package/src/context/ComlinkTokenRefresh.test.tsx +10 -27
  25. package/src/context/DefaultResourceContext.ts +3 -3
  26. package/src/context/OrganizationResourcesProvider.test.tsx +189 -0
  27. package/src/context/OrganizationResourcesProvider.tsx +111 -0
  28. package/src/context/PerspectiveContext.ts +3 -3
  29. package/src/context/ProjectContext.ts +15 -0
  30. package/src/context/ResourceProvider.test.tsx +72 -90
  31. package/src/context/ResourceProvider.tsx +59 -93
  32. package/src/context/SDKStudioContext.test.tsx +28 -33
  33. package/src/context/SanityInstanceProvider.test.tsx +100 -0
  34. package/src/context/SanityInstanceProvider.tsx +71 -0
  35. package/src/context/WorkbenchTokenRefresh.test.tsx +106 -0
  36. package/src/context/WorkbenchTokenRefresh.tsx +61 -0
  37. package/src/context/renderSanityApp.test.tsx +151 -49
  38. package/src/context/renderSanityApp.tsx +12 -8
  39. package/src/context/workbenchToken.ts +63 -0
  40. package/src/hooks/access/useCheckPermissions.test-d.ts +15 -0
  41. package/src/hooks/access/useCheckPermissions.test.tsx +53 -0
  42. package/src/hooks/access/useCheckPermissions.ts +24 -0
  43. package/src/hooks/agent/agentActions.test.tsx +1 -1
  44. package/src/hooks/agent/agentActions.ts +51 -71
  45. package/src/hooks/applications/useApplication.test-d.ts +30 -0
  46. package/src/hooks/applications/useApplication.ts +22 -0
  47. package/src/hooks/applications/useApplications.test-d.ts +31 -0
  48. package/src/hooks/applications/useApplications.ts +25 -0
  49. package/src/hooks/applications/useCreateUserApplication.test-d.ts +14 -0
  50. package/src/hooks/applications/useCreateUserApplication.ts +11 -0
  51. package/src/hooks/applications/useDeleteApplication.test-d.ts +12 -0
  52. package/src/hooks/applications/useDeleteApplication.ts +11 -0
  53. package/src/hooks/applications/useDeleteUserApplication.test-d.ts +14 -0
  54. package/src/hooks/applications/useDeleteUserApplication.ts +11 -0
  55. package/src/hooks/applications/useUpdateApplication.test-d.ts +12 -0
  56. package/src/hooks/applications/useUpdateApplication.ts +11 -0
  57. package/src/hooks/applications/useUpdateUserApplication.test-d.ts +14 -0
  58. package/src/hooks/applications/useUpdateUserApplication.ts +11 -0
  59. package/src/hooks/applications/useUserApplication.test-d.ts +11 -0
  60. package/src/hooks/applications/useUserApplication.ts +14 -0
  61. package/src/hooks/applications/useUserApplications.test-d.ts +11 -0
  62. package/src/hooks/applications/useUserApplications.ts +14 -0
  63. package/src/hooks/auth/useDashboardOrganizationId.test.tsx +2 -8
  64. package/src/hooks/auth/useVerifyOrgProjects.test.tsx +8 -32
  65. package/src/hooks/auth/useVerifyOrgProjects.tsx +13 -6
  66. package/src/hooks/client/useClient.test.tsx +1 -4
  67. package/src/hooks/client/useClient.ts +1 -0
  68. package/src/hooks/context/useResource.test.tsx +32 -0
  69. package/src/hooks/context/useResource.ts +24 -0
  70. package/src/hooks/context/useSanityInstance.test.tsx +72 -3
  71. package/src/hooks/context/useSanityInstance.ts +30 -8
  72. package/src/hooks/dashboard/useManageFavorite.test.tsx +110 -46
  73. package/src/hooks/dashboard/useManageFavorite.ts +10 -16
  74. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +5 -7
  75. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +2 -6
  76. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.test.ts +0 -2
  77. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.ts +1 -2
  78. package/src/hooks/dashboard/useWindowTitle.test.ts +244 -0
  79. package/src/hooks/dashboard/useWindowTitle.ts +114 -0
  80. package/src/hooks/datasets/useDatasets.test.tsx +116 -0
  81. package/src/hooks/datasets/useDatasets.ts +33 -13
  82. package/src/hooks/document/useApplyDocumentActions.test.tsx +48 -0
  83. package/src/hooks/document/useApplyDocumentActions.ts +66 -48
  84. package/src/hooks/document/useCreateDocument.test.tsx +83 -0
  85. package/src/hooks/document/useCreateDocument.ts +117 -0
  86. package/src/hooks/document/useDocument.test.tsx +37 -8
  87. package/src/hooks/document/useDocument.ts +131 -66
  88. package/src/hooks/document/useDocumentEvent.test.tsx +16 -4
  89. package/src/hooks/document/useDocumentEvent.ts +16 -10
  90. package/src/hooks/document/useDocumentPermissions.test.tsx +8 -7
  91. package/src/hooks/document/useDocumentPermissions.ts +71 -39
  92. package/src/hooks/document/useDocumentSyncStatus.test.ts +1 -0
  93. package/src/hooks/document/useDocumentSyncStatus.ts +13 -9
  94. package/src/hooks/document/useEditDocument.test.tsx +21 -14
  95. package/src/hooks/document/useEditDocument.ts +163 -17
  96. package/src/hooks/documents/useDocuments.test.tsx +64 -44
  97. package/src/hooks/documents/useDocuments.ts +36 -20
  98. package/src/hooks/helpers/createCallbackHook.test.tsx +13 -19
  99. package/src/hooks/helpers/createFetcherHook.test.tsx +180 -0
  100. package/src/hooks/helpers/createFetcherHook.ts +69 -0
  101. package/src/hooks/helpers/createMutationHook.test.tsx +125 -0
  102. package/src/hooks/helpers/createMutationHook.tsx +93 -0
  103. package/src/hooks/helpers/createStateSourceHook.test.tsx +10 -10
  104. package/src/hooks/helpers/createStateSourceHook.tsx +2 -1
  105. package/src/hooks/{document/useApplyDocumentActions.test.ts → helpers/useApplyActions.test.tsx} +92 -53
  106. package/src/hooks/helpers/useApplyActions.ts +68 -0
  107. package/src/hooks/helpers/useNormalizedResourceOptions.test.tsx +253 -0
  108. package/src/hooks/helpers/useNormalizedResourceOptions.ts +92 -50
  109. package/src/hooks/helpers/useResolvedProjectId.test.tsx +59 -0
  110. package/src/hooks/helpers/useResolvedProjectId.ts +35 -0
  111. package/src/hooks/helpers/useTrackHookUsage.ts +37 -0
  112. package/src/hooks/installations/useInstallation.test-d.ts +19 -0
  113. package/src/hooks/installations/useInstallation.ts +22 -0
  114. package/src/hooks/installations/useInstallations.test-d.ts +26 -0
  115. package/src/hooks/installations/useInstallations.ts +25 -0
  116. package/src/hooks/organizations/useOrganization.test-d.ts +60 -0
  117. package/src/hooks/organizations/useOrganization.test.ts +63 -0
  118. package/src/hooks/organizations/useOrganization.ts +34 -0
  119. package/src/hooks/organizations/useOrganizations.test-d.ts +68 -0
  120. package/src/hooks/organizations/useOrganizations.test.ts +63 -0
  121. package/src/hooks/organizations/useOrganizations.ts +39 -0
  122. package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +34 -27
  123. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +40 -22
  124. package/src/hooks/presence/usePresence.test.tsx +0 -15
  125. package/src/hooks/presence/usePresence.ts +9 -20
  126. package/src/hooks/preview/useDocumentPreview.test.tsx +0 -1
  127. package/src/hooks/preview/useDocumentPreview.tsx +10 -8
  128. package/src/hooks/projection/useDocumentProjection.test.tsx +78 -9
  129. package/src/hooks/projection/useDocumentProjection.ts +81 -2
  130. package/src/hooks/projects/useProject.test-d.ts +56 -0
  131. package/src/hooks/projects/useProject.test.tsx +120 -0
  132. package/src/hooks/projects/useProject.ts +42 -43
  133. package/src/hooks/projects/useProjects.test-d.ts +60 -0
  134. package/src/hooks/projects/useProjects.test.ts +45 -98
  135. package/src/hooks/projects/useProjects.ts +19 -26
  136. package/src/hooks/query/useQuery.test.tsx +6 -5
  137. package/src/hooks/query/useQuery.ts +91 -9
  138. package/src/hooks/releases/useActiveReleases.test.tsx +13 -7
  139. package/src/hooks/releases/useActiveReleases.ts +28 -24
  140. package/src/hooks/releases/useAllReleases.test.tsx +93 -0
  141. package/src/hooks/releases/useAllReleases.ts +62 -0
  142. package/src/hooks/releases/useApplyReleaseActions.test.tsx +66 -0
  143. package/src/hooks/releases/useApplyReleaseActions.ts +82 -0
  144. package/src/hooks/releases/usePerspective.test.tsx +18 -10
  145. package/src/hooks/releases/usePerspective.ts +13 -17
  146. package/src/hooks/users/useUser.test.tsx +3 -9
  147. package/src/hooks/users/useUser.ts +2 -0
  148. package/src/hooks/users/useUsers.test.tsx +103 -7
  149. package/src/hooks/users/useUsers.ts +37 -3
  150. package/src/utils/resolveOrgResources.test.ts +111 -0
  151. package/src/utils/resolveOrgResources.ts +69 -0
  152. package/src/hooks/context/useDefaultResource.test.tsx +0 -25
  153. package/src/hooks/context/useDefaultResource.ts +0 -30
  154. package/src/hooks/dashboard/useDispatchIntent.test.ts +0 -254
  155. package/src/hooks/dashboard/useDispatchIntent.ts +0 -157
  156. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.test.ts +0 -107
  157. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +0 -39
  158. package/src/hooks/datasets/useDatasets.test.ts +0 -66
  159. package/src/hooks/helpers/useNormalizedResourceOptions.test.ts +0 -65
  160. package/src/hooks/projects/useProject.test.ts +0 -79
@@ -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 DatasetResource, type SanityInstance} from '@sanity/sdk'
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({defaultResource: {projectId: 'p1', dataset: 'd'}})
69
- const mockInstance2 = createSanityInstance({defaultResource: {projectId: 'p2', dataset: 'd'}})
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: () => (instance.config.defaultResource as DatasetResource)?.projectId,
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>(
@@ -1,10 +1,11 @@
1
- import {applyDocumentActions, type SanityInstance} from '@sanity/sdk'
2
- import React from 'react'
1
+ import {applyDocumentActions, createSanityInstance} from '@sanity/sdk'
2
+ import {renderHook as reactRenderHook} from '@testing-library/react'
3
3
  import {describe, it} from 'vitest'
4
4
 
5
5
  import {renderHook} from '../../../test/test-utils'
6
+ import {SanityInstanceContext} from '../../context/SanityInstanceContext'
6
7
  import {useSanityInstance} from '../context/useSanityInstance'
7
- import {useApplyDocumentActions} from './useApplyDocumentActions'
8
+ import {useApplyActions} from './useApplyActions'
8
9
 
9
10
  vi.mock('@sanity/sdk', async (importOriginal) => {
10
11
  const original = await importOriginal<typeof import('@sanity/sdk')>()
@@ -13,21 +14,20 @@ vi.mock('@sanity/sdk', async (importOriginal) => {
13
14
 
14
15
  vi.mock('../context/useSanityInstance')
15
16
 
16
- const instance = {instanceId: 'test'} as unknown as SanityInstance
17
+ const instance = createSanityInstance({projectId: 'p', dataset: 'd'})
17
18
 
18
- describe('useApplyDocumentActions', () => {
19
+ describe('useApplyActions', () => {
19
20
  beforeEach(() => {
20
21
  vi.resetAllMocks()
21
22
  vi.mocked(useSanityInstance).mockReturnValueOnce(instance)
22
23
  })
23
24
 
24
- it('uses the SanityInstance and injects resource from context', async () => {
25
- const {result} = renderHook(() => useApplyDocumentActions())
25
+ it('uses the effective context resource', async () => {
26
+ const {result} = renderHook(() => useApplyActions())
26
27
  result.current({
27
28
  type: 'document.edit',
28
29
  documentType: 'post',
29
30
  documentId: 'abc',
30
- resource: {projectId: 'p123', dataset: 'd123'},
31
31
  })
32
32
 
33
33
  expect(applyDocumentActions).toHaveBeenCalledExactlyOnceWith(instance, {
@@ -36,21 +36,28 @@ describe('useApplyDocumentActions', () => {
36
36
  type: 'document.edit',
37
37
  documentType: 'post',
38
38
  documentId: 'abc',
39
- resource: {projectId: 'p123', dataset: 'd123'},
39
+ // resource named in test-utils
40
+ resource: {projectId: 'test', dataset: 'test'},
40
41
  },
41
42
  ],
42
- resource: {projectId: 'p123', dataset: 'd123'},
43
+ resource: {projectId: 'test', dataset: 'test'},
43
44
  })
44
45
  })
45
46
 
46
- it('passes resource from action to applyDocumentActions', async () => {
47
- const {result} = renderHook(() => useApplyDocumentActions())
48
- result.current({
49
- type: 'document.edit',
50
- documentType: 'post',
51
- documentId: 'abc',
52
- resource: {projectId: 'p123', dataset: 'd123'},
47
+ it('uses the SanityInstance when resource is not provided', async () => {
48
+ const {result} = reactRenderHook(() => useApplyActions(), {
49
+ wrapper: ({children}) => (
50
+ <SanityInstanceContext.Provider value={instance}>{children}</SanityInstanceContext.Provider>
51
+ ),
53
52
  })
53
+ result.current(
54
+ {
55
+ type: 'document.edit',
56
+ documentType: 'post',
57
+ documentId: 'abc',
58
+ },
59
+ {},
60
+ )
54
61
 
55
62
  expect(applyDocumentActions).toHaveBeenCalledExactlyOnceWith(instance, {
56
63
  actions: [
@@ -58,20 +65,21 @@ describe('useApplyDocumentActions', () => {
58
65
  type: 'document.edit',
59
66
  documentType: 'post',
60
67
  documentId: 'abc',
61
- resource: {projectId: 'p123', dataset: 'd123'},
68
+ resource: {projectId: 'p', dataset: 'd'},
62
69
  },
63
70
  ],
64
- resource: {projectId: 'p123', dataset: 'd123'},
71
+ resource: {projectId: 'p', dataset: 'd'},
65
72
  })
66
73
  })
67
74
 
68
- it('uses context resource when action has no resource', async () => {
69
- const {result} = renderHook(() => useApplyDocumentActions())
75
+ it('resolves resource from projectId and dataset on the action', async () => {
76
+ const {result} = renderHook(() => useApplyActions())
70
77
  result.current({
71
78
  type: 'document.edit',
72
79
  documentType: 'post',
73
80
  documentId: 'abc',
74
- resource: {projectId: 'test', dataset: 'test'},
81
+ projectId: 'p',
82
+ dataset: 'd123',
75
83
  })
76
84
 
77
85
  expect(applyDocumentActions).toHaveBeenCalledExactlyOnceWith(instance, {
@@ -80,55 +88,59 @@ describe('useApplyDocumentActions', () => {
80
88
  type: 'document.edit',
81
89
  documentType: 'post',
82
90
  documentId: 'abc',
83
- resource: {projectId: 'test', dataset: 'test'},
91
+ resource: {projectId: 'p', dataset: 'd123'},
84
92
  },
85
93
  ],
86
- resource: {projectId: 'test', dataset: 'test'},
94
+ resource: {projectId: 'p', dataset: 'd123'},
87
95
  })
88
96
  })
89
97
 
90
- it('throws when actions have mismatched resources', async () => {
91
- const {result} = renderHook(() => useApplyDocumentActions())
98
+ it('throws when actions have mismatched project IDs', async () => {
99
+ const {result} = renderHook(() => useApplyActions())
92
100
  expect(() => {
93
101
  result.current([
94
102
  {
95
103
  type: 'document.edit',
96
104
  documentType: 'post',
97
105
  documentId: 'abc',
98
- resource: {projectId: 'p', dataset: 'd1'},
106
+ projectId: 'p123',
107
+ dataset: 'd',
99
108
  },
100
109
  {
101
110
  type: 'document.edit',
102
111
  documentType: 'post',
103
112
  documentId: 'def',
104
- resource: {projectId: 'p', dataset: 'd2'},
113
+ projectId: 'p456',
114
+ dataset: 'd',
105
115
  },
106
116
  ])
107
117
  }).toThrow(/Mismatched resources found in actions/)
108
118
  })
109
119
 
110
- it('throws when actions have mismatched project IDs', async () => {
111
- const {result} = renderHook(() => useApplyDocumentActions())
120
+ it('throws when actions have mismatched datasets', async () => {
121
+ const {result} = renderHook(() => useApplyActions())
112
122
  expect(() => {
113
123
  result.current([
114
124
  {
115
125
  type: 'document.edit',
116
126
  documentType: 'post',
117
127
  documentId: 'abc',
118
- resource: {projectId: 'p123', dataset: 'd1'},
128
+ projectId: 'p',
129
+ dataset: 'd1',
119
130
  },
120
131
  {
121
132
  type: 'document.edit',
122
133
  documentType: 'post',
123
134
  documentId: 'def',
124
- resource: {projectId: 'p456', dataset: 'd1'},
135
+ projectId: 'p',
136
+ dataset: 'd2',
125
137
  },
126
138
  ])
127
139
  }).toThrow(/Mismatched resources found in actions/)
128
140
  })
129
141
 
130
- it('throws when actions have mismatched datasets', async () => {
131
- const {result} = renderHook(() => useApplyDocumentActions())
142
+ it('throws when actions have mismatched resources', async () => {
143
+ const {result} = renderHook(() => useApplyActions())
132
144
  expect(() => {
133
145
  result.current([
134
146
  {
@@ -147,34 +159,61 @@ describe('useApplyDocumentActions', () => {
147
159
  }).toThrow(/Mismatched resources found in actions/)
148
160
  })
149
161
 
150
- it('throws when no resource is found from actions or context', async () => {
151
- // Use a minimal wrapper without resource context so contextResource is undefined
152
- const {result} = renderHook(() => useApplyDocumentActions(), {
153
- wrapper: (props) => React.createElement(React.Fragment, null, props.children),
154
- })
155
-
162
+ it('throws when mixing projectId/dataset and resource with a mismatch (projectId first)', async () => {
163
+ const {result} = renderHook(() => useApplyActions())
156
164
  expect(() => {
157
- result.current({
158
- type: 'document.edit',
159
- documentType: 'post',
160
- documentId: 'abc',
161
- // no resource
162
- } as never)
163
- }).toThrow(/resource is required/)
165
+ result.current([
166
+ {
167
+ type: 'document.edit',
168
+ documentType: 'post',
169
+ documentId: 'abc',
170
+ projectId: 'p1',
171
+ dataset: 'd',
172
+ },
173
+ {
174
+ type: 'document.edit',
175
+ documentType: 'post',
176
+ documentId: 'def',
177
+ resource: {projectId: 'p2', dataset: 'd'},
178
+ },
179
+ ])
180
+ }).toThrow(/Mismatched resources found in actions/)
164
181
  })
165
182
 
166
- it('throws when options resource mismatches action resource', async () => {
167
- const {result} = renderHook(() => useApplyDocumentActions())
168
-
183
+ it('throws when mixing resource and projectId/dataset with a mismatch (resource first)', async () => {
184
+ const {result} = renderHook(() => useApplyActions())
169
185
  expect(() => {
170
- result.current(
186
+ result.current([
171
187
  {
172
188
  type: 'document.edit',
173
189
  documentType: 'post',
174
190
  documentId: 'abc',
175
- resource: {projectId: 'p1', dataset: 'd1'},
191
+ resource: {projectId: 'p1', dataset: 'd'},
192
+ },
193
+ {
194
+ type: 'document.edit',
195
+ documentType: 'post',
196
+ documentId: 'def',
197
+ projectId: 'p2',
198
+ dataset: 'd',
176
199
  },
177
- {resource: {projectId: 'p2', dataset: 'd2'}},
200
+ ])
201
+ }).toThrow(/Mismatched resources found in actions/)
202
+ })
203
+
204
+ it('throws when a top-level options resource conflicts with an action resource', async () => {
205
+ const {result} = renderHook(() => useApplyActions())
206
+ expect(() => {
207
+ result.current(
208
+ [
209
+ {
210
+ type: 'document.edit',
211
+ documentType: 'post',
212
+ documentId: 'abc',
213
+ resource: {projectId: 'p', dataset: 'd1'},
214
+ },
215
+ ],
216
+ {resource: {projectId: 'p', dataset: 'd2'}},
178
217
  )
179
218
  }).toThrow(/Mismatched resources found in actions/)
180
219
  })
@@ -0,0 +1,68 @@
1
+ import {type Action, type ActionsResult, applyDocumentActions} from '@sanity/sdk'
2
+ import {isDeepEqual} from '@sanity/sdk/_internal'
3
+ import {useContext} from 'react'
4
+
5
+ import {type ResourceHandle} from '../../config/handles'
6
+ import {ResourcesContext} from '../../context/ResourcesContext'
7
+ import {useSanityInstance} from '../context/useSanityInstance'
8
+ import {normalizeResourceOptions, useEffectiveContextResource} from './useNormalizedResourceOptions'
9
+
10
+ /**
11
+ * @internal
12
+ *
13
+ * Shared implementation behind `useApplyDocumentActions` and
14
+ * `useApplyReleaseActions`. Resolves the effective resource from the action
15
+ * handles, top-level `options`, or context (in that order), validates that
16
+ * everything agrees on a single resource, and forwards to
17
+ * `applyDocumentActions`.
18
+ *
19
+ * The two public hooks differ only in their input/output types — at runtime
20
+ * they're identical because core's `applyDocumentActions` accepts the full
21
+ * `Action` union.
22
+ */
23
+ export function useApplyActions(): (
24
+ actionOrActions: Action | Action[],
25
+ options?: ResourceHandle,
26
+ ) => Promise<ActionsResult> {
27
+ const instance = useSanityInstance()
28
+ const resources = useContext(ResourcesContext)
29
+ const effectiveContextResource = useEffectiveContextResource()
30
+
31
+ return (actionOrActions, options) => {
32
+ const actions = Array.isArray(actionOrActions) ? actionOrActions : [actionOrActions]
33
+ const optionsResource = options
34
+ ? normalizeResourceOptions(options, resources, effectiveContextResource).resource
35
+ : undefined
36
+
37
+ const normalizedActions = actions.map((action) =>
38
+ normalizeResourceOptions(action, resources, effectiveContextResource),
39
+ )
40
+
41
+ let resource
42
+ for (const action of normalizedActions) {
43
+ const actionResource = action['resource']
44
+ if (!resource && actionResource) resource = actionResource
45
+ if (!isDeepEqual(actionResource, resource)) {
46
+ throw new Error(
47
+ `Mismatched resources found in actions. All actions must belong to the same resource. Found "${JSON.stringify(actionResource)}" but expected "${JSON.stringify(resource)}".`,
48
+ )
49
+ }
50
+ }
51
+
52
+ if (optionsResource && resource && !isDeepEqual(optionsResource, resource)) {
53
+ throw new Error(
54
+ `Mismatched resources found in actions. Found top-level resource "${JSON.stringify(optionsResource)}" but expected resource from action handles "${JSON.stringify(resource)}".`,
55
+ )
56
+ }
57
+
58
+ const effectiveResource = resource ?? optionsResource ?? effectiveContextResource
59
+ if (!effectiveResource) {
60
+ throw new Error('No resource found. Provide a resource via the action handle or context.')
61
+ }
62
+
63
+ return applyDocumentActions(instance, {
64
+ actions: normalizedActions as Action[],
65
+ resource: effectiveResource,
66
+ })
67
+ }
68
+ }