@sanity/sdk-react 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/sdk-react",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "private": false,
5
5
  "description": "Sanity SDK React toolkit for Content OS",
6
6
  "keywords": [
@@ -51,33 +51,34 @@
51
51
  "react-compiler-runtime": "19.1.0-rc.2",
52
52
  "react-error-boundary": "^5.0.0",
53
53
  "rxjs": "^7.8.2",
54
- "@sanity/sdk": "2.3.0"
54
+ "@sanity/sdk": "2.4.0"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@sanity/browserslist-config": "^1.0.5",
58
- "@sanity/comlink": "^3.0.4",
58
+ "@sanity/comlink": "^3.1.1",
59
59
  "@sanity/pkg-utils": "^7.2.2",
60
- "@sanity/prettier-config": "^1.0.3",
61
- "@testing-library/jest-dom": "^6.6.3",
60
+ "@sanity/prettier-config": "^1.0.6",
61
+ "@testing-library/jest-dom": "^6.9.1",
62
62
  "@testing-library/react": "^16.3.0",
63
- "@types/react": "^19.1.2",
64
- "@types/react-dom": "^19.1.3",
65
- "@vitejs/plugin-react": "^4.4.1",
66
- "@vitest/coverage-v8": "3.1.2",
63
+ "@types/node": "^22.19.1",
64
+ "@types/react": "^19.2.7",
65
+ "@types/react-dom": "^19.2.3",
66
+ "@vitejs/plugin-react": "^4.7.0",
67
+ "@vitest/coverage-v8": "3.2.4",
67
68
  "babel-plugin-react-compiler": "19.1.0-rc.1",
68
69
  "eslint": "^9.22.0",
69
- "groq-js": "^1.19.0",
70
+ "groq-js": "^1.22.0",
70
71
  "jsdom": "^25.0.1",
71
- "prettier": "^3.5.3",
72
- "react": "^19.1.0",
73
- "react-dom": "^19.1.0",
72
+ "prettier": "^3.7.3",
73
+ "react": "^19.2.1",
74
+ "react-dom": "^19.2.1",
74
75
  "rollup-plugin-visualizer": "^5.14.0",
75
76
  "typescript": "^5.8.3",
76
77
  "vite": "^6.3.4",
77
- "vitest": "^3.1.2",
78
+ "vitest": "^3.2.4",
78
79
  "@repo/config-eslint": "0.0.0",
79
- "@repo/config-test": "0.0.1",
80
80
  "@repo/package.bundle": "3.82.0",
81
+ "@repo/config-test": "0.0.1",
81
82
  "@repo/package.config": "0.0.1",
82
83
  "@repo/tsconfig": "0.0.1"
83
84
  },
@@ -6,6 +6,13 @@ export {SanityApp, type SanityAppProps} from '../components/SanityApp'
6
6
  export {SDKProvider, type SDKProviderProps} from '../components/SDKProvider'
7
7
  export {ComlinkTokenRefreshProvider} from '../context/ComlinkTokenRefresh'
8
8
  export {ResourceProvider, type ResourceProviderProps} from '../context/ResourceProvider'
9
+ export {
10
+ useAgentGenerate,
11
+ useAgentPatch,
12
+ useAgentPrompt,
13
+ useAgentTransform,
14
+ useAgentTranslate,
15
+ } from '../hooks/agent/agentActions'
9
16
  export {useAuthState} from '../hooks/auth/useAuthState'
10
17
  export {useAuthToken} from '../hooks/auth/useAuthToken'
11
18
  export {useCurrentUser} from '../hooks/auth/useCurrentUser'
@@ -29,6 +36,7 @@ export {
29
36
  } from '../hooks/comlink/useWindowConnection'
30
37
  export {useSanityInstance} from '../hooks/context/useSanityInstance'
31
38
  export {useDashboardNavigate} from '../hooks/dashboard/useDashboardNavigate'
39
+ export {useDispatchIntent} from '../hooks/dashboard/useDispatchIntent'
32
40
  export {useManageFavorite} from '../hooks/dashboard/useManageFavorite'
33
41
  export {
34
42
  type NavigateToStudioResult,
@@ -7,6 +7,7 @@ import {ComlinkTokenRefreshProvider} from '../../context/ComlinkTokenRefresh'
7
7
  import {useAuthState} from '../../hooks/auth/useAuthState'
8
8
  import {useLoginUrl} from '../../hooks/auth/useLoginUrl'
9
9
  import {useVerifyOrgProjects} from '../../hooks/auth/useVerifyOrgProjects'
10
+ import {useSanityInstance} from '../../hooks/context/useSanityInstance'
10
11
  import {CorsErrorComponent} from '../errors/CorsErrorComponent'
11
12
  import {isInIframe} from '../utils'
12
13
  import {AuthError} from './AuthError'
@@ -154,17 +155,21 @@ function AuthSwitch({
154
155
  ...props
155
156
  }: AuthSwitchProps) {
156
157
  const authState = useAuthState()
157
- const orgError = useVerifyOrgProjects(!verifyOrganization, projectIds)
158
+ const instance = useSanityInstance()
159
+ const studioModeEnabled = instance.config.studioMode?.enabled
160
+ const disableVerifyOrg =
161
+ !verifyOrganization || studioModeEnabled || authState.type !== AuthStateType.LOGGED_IN
162
+ const orgError = useVerifyOrgProjects(disableVerifyOrg, projectIds)
158
163
 
159
164
  const isLoggedOut = authState.type === AuthStateType.LOGGED_OUT && !authState.isDestroyingSession
160
165
  const loginUrl = useLoginUrl()
161
166
 
162
167
  useEffect(() => {
163
- if (isLoggedOut && !isInIframe()) {
164
- // We don't want to redirect to login if we're in the Dashboard
168
+ if (isLoggedOut && !isInIframe() && !studioModeEnabled) {
169
+ // We don't want to redirect to login if we're in the Dashboard nor in studio mode
165
170
  window.location.href = loginUrl
166
171
  }
167
- }, [isLoggedOut, loginUrl])
172
+ }, [isLoggedOut, loginUrl, studioModeEnabled])
168
173
 
169
174
  // Only check the error if verification is enabled
170
175
  if (verifyOrganization && orgError) {
@@ -1,5 +1,10 @@
1
1
  import {ClientError} from '@sanity/client'
2
- import {AuthStateType} from '@sanity/sdk'
2
+ import {
3
+ AuthStateType,
4
+ getClientErrorApiBody,
5
+ getClientErrorApiDescription,
6
+ isProjectUserNotFoundClientError,
7
+ } from '@sanity/sdk'
3
8
  import {useCallback, useEffect, useState} from 'react'
4
9
  import {type FallbackProps} from 'react-error-boundary'
5
10
 
@@ -35,6 +40,7 @@ export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.
35
40
  const [authErrorMessage, setAuthErrorMessage] = useState(
36
41
  'Please try again or contact support if the problem persists.',
37
42
  )
43
+ const [showRetryCta, setShowRetryCta] = useState(true)
38
44
 
39
45
  const handleRetry = useCallback(async () => {
40
46
  await logout()
@@ -44,18 +50,28 @@ export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.
44
50
  useEffect(() => {
45
51
  if (error instanceof ClientError) {
46
52
  if (error.statusCode === 401) {
47
- handleRetry()
53
+ // Surface a friendly message for projectUserNotFoundError (do not logout/refresh)
54
+ if (isProjectUserNotFoundClientError(error)) {
55
+ const description = getClientErrorApiDescription(error)
56
+ if (description) setAuthErrorMessage(description)
57
+ setShowRetryCta(false)
58
+ } else {
59
+ setShowRetryCta(true)
60
+ handleRetry()
61
+ }
48
62
  } else if (error.statusCode === 404) {
49
- const errorMessage = error.response.body.message || ''
63
+ const errorMessage = getClientErrorApiBody(error)?.message || ''
50
64
  if (errorMessage.startsWith('Session with sid') && errorMessage.endsWith('not found')) {
51
65
  setAuthErrorMessage('The session ID is invalid or expired.')
52
66
  } else {
53
67
  setAuthErrorMessage('The login link is invalid or expired. Please try again.')
54
68
  }
69
+ setShowRetryCta(true)
55
70
  }
56
71
  }
57
72
  if (authState.type !== AuthStateType.ERROR && error instanceof ConfigurationError) {
58
73
  setAuthErrorMessage(error.message)
74
+ setShowRetryCta(true)
59
75
  }
60
76
  }, [authState, handleRetry, error])
61
77
 
@@ -63,10 +79,14 @@ export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.
63
79
  <Error
64
80
  heading={error instanceof AuthError ? 'Authentication Error' : 'Configuration Error'}
65
81
  description={authErrorMessage}
66
- cta={{
67
- text: 'Retry',
68
- onClick: handleRetry,
69
- }}
82
+ cta={
83
+ showRetryCta
84
+ ? {
85
+ text: 'Retry',
86
+ onClick: handleRetry,
87
+ }
88
+ : undefined
89
+ }
70
90
  />
71
91
  )
72
92
  }
@@ -89,7 +89,8 @@ describe('ComlinkTokenRefresh', () => {
89
89
  mockGetIsInDashboardState.mockReturnValue({getCurrent: () => true})
90
90
  })
91
91
 
92
- it('should initialize useWindowConnection with correct parameters', () => {
92
+ it('should initialize useWindowConnection with correct parameters when not in studio mode', () => {
93
+ // Simulate studio mode disabled by default
93
94
  render(
94
95
  <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
95
96
  <ComlinkTokenRefreshProvider>
@@ -106,13 +107,22 @@ describe('ComlinkTokenRefresh', () => {
106
107
  )
107
108
  })
108
109
 
109
- it('should handle received token', async () => {
110
+ it('should handle received token when not in studio mode', async () => {
110
111
  mockUseAuthState.mockReturnValue({
111
112
  type: AuthStateType.ERROR,
112
113
  error: {statusCode: 401, message: 'Unauthorized'},
113
114
  })
114
115
  mockFetch.mockResolvedValueOnce({token: 'new-token'})
115
116
 
117
+ // Insert an Unauthorized error container that should be removed on success
118
+ const errorContainer = document.createElement('div')
119
+ errorContainer.id = '__sanityError'
120
+ const child = document.createElement('div')
121
+ child.textContent =
122
+ 'Uncaught error: Unauthorized - A valid session is required for this endpoint'
123
+ errorContainer.appendChild(child)
124
+ document.body.appendChild(errorContainer)
125
+
116
126
  render(
117
127
  <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
118
128
  <ComlinkTokenRefreshProvider>
@@ -127,9 +137,17 @@ describe('ComlinkTokenRefresh', () => {
127
137
 
128
138
  expect(mockSetAuthToken).toHaveBeenCalledWith(expect.any(Object), 'new-token')
129
139
  expect(mockFetch).toHaveBeenCalledTimes(1)
140
+ expect(mockFetch).toHaveBeenCalledWith('dashboard/v1/auth/tokens/create')
141
+ // Assert setAuthToken was called with instance matching provider config
142
+ const instanceArg = mockSetAuthToken.mock.calls[0][0]
143
+ expect(instanceArg.config).toEqual(
144
+ expect.objectContaining({projectId: 'test-project', dataset: 'test-dataset'}),
145
+ )
146
+ // Unauthorized error container should be removed
147
+ expect(document.getElementById('__sanityError')).toBeNull()
130
148
  })
131
149
 
132
- it('should not set auth token if received token is null', async () => {
150
+ it('should not set auth token if received token is null when not in studio mode', async () => {
133
151
  mockUseAuthState.mockReturnValue({
134
152
  type: AuthStateType.ERROR,
135
153
  error: {statusCode: 401, message: 'Unauthorized'},
@@ -151,7 +169,7 @@ describe('ComlinkTokenRefresh', () => {
151
169
  expect(mockSetAuthToken).not.toHaveBeenCalled()
152
170
  })
153
171
 
154
- it('should handle fetch errors gracefully', async () => {
172
+ it('should handle fetch errors gracefully when not in studio mode', async () => {
155
173
  mockUseAuthState.mockReturnValue({
156
174
  type: AuthStateType.ERROR,
157
175
  error: {statusCode: 401, message: 'Unauthorized'},
@@ -174,7 +192,7 @@ describe('ComlinkTokenRefresh', () => {
174
192
  })
175
193
 
176
194
  describe('Automatic token refresh', () => {
177
- it('should not request new token for non-401 errors', async () => {
195
+ it('should not request new token for non-401 errors when not in studio mode', async () => {
178
196
  mockUseAuthState.mockReturnValue({type: AuthStateType.LOGGED_IN})
179
197
  const {rerender} = render(
180
198
  <ResourceProvider fallback={null}>
@@ -205,7 +223,7 @@ describe('ComlinkTokenRefresh', () => {
205
223
  expect(mockFetch).not.toHaveBeenCalled()
206
224
  })
207
225
 
208
- it('should request new token on LOGGED_OUT state', async () => {
226
+ it('should request new token on LOGGED_OUT state when not in studio mode', async () => {
209
227
  mockUseAuthState.mockReturnValue({type: AuthStateType.LOGGED_IN})
210
228
  const {rerender} = render(
211
229
  <ResourceProvider fallback={null}>
@@ -228,6 +246,108 @@ describe('ComlinkTokenRefresh', () => {
228
246
 
229
247
  expect(mockFetch).toHaveBeenCalledWith('dashboard/v1/auth/tokens/create')
230
248
  })
249
+
250
+ it('dedupes multiple 401 errors while a refresh is in progress', async () => {
251
+ mockUseAuthState.mockReturnValue({
252
+ type: AuthStateType.ERROR,
253
+ error: {statusCode: 401, message: 'Unauthorized'},
254
+ })
255
+ // Return a promise we resolve later to keep in-progress true for a bit
256
+ let resolveFetch: (v: {token: string | null}) => void
257
+ mockFetch.mockImplementation(
258
+ () =>
259
+ new Promise<{token: string | null}>((resolve) => {
260
+ resolveFetch = resolve
261
+ }),
262
+ )
263
+
264
+ const {rerender} = render(
265
+ <ResourceProvider fallback={null}>
266
+ <ComlinkTokenRefreshProvider>
267
+ <div>Test</div>
268
+ </ComlinkTokenRefreshProvider>
269
+ </ResourceProvider>,
270
+ )
271
+
272
+ // Trigger a second 401 while the first request is still in progress
273
+ mockUseAuthState.mockReturnValue({
274
+ type: AuthStateType.ERROR,
275
+ error: {statusCode: 401, message: 'Unauthorized again'},
276
+ })
277
+ act(() => {
278
+ rerender(
279
+ <ResourceProvider fallback={null}>
280
+ <ComlinkTokenRefreshProvider>
281
+ <div>Test</div>
282
+ </ComlinkTokenRefreshProvider>
283
+ </ResourceProvider>,
284
+ )
285
+ })
286
+
287
+ // Only one fetch should be in-flight
288
+ expect(mockFetch).toHaveBeenCalledTimes(1)
289
+
290
+ // Finish the first fetch
291
+ await act(async () => {
292
+ resolveFetch!({token: null})
293
+ })
294
+ })
295
+
296
+ it('requests again after timeout if previous request did not resolve', async () => {
297
+ mockUseAuthState.mockReturnValue({
298
+ type: AuthStateType.ERROR,
299
+ error: {statusCode: 401, message: 'Unauthorized'},
300
+ })
301
+ // First call never resolves
302
+ mockFetch.mockImplementationOnce(() => new Promise(() => {}))
303
+
304
+ const {rerender} = render(
305
+ <ResourceProvider fallback={null}>
306
+ <ComlinkTokenRefreshProvider>
307
+ <div>Test</div>
308
+ </ComlinkTokenRefreshProvider>
309
+ </ResourceProvider>,
310
+ )
311
+
312
+ expect(mockFetch).toHaveBeenCalledTimes(1)
313
+
314
+ // After timeout elapses, a subsequent 401 should trigger another fetch
315
+ await act(async () => {
316
+ await vi.advanceTimersByTimeAsync(10000)
317
+ })
318
+
319
+ mockUseAuthState.mockReturnValue({
320
+ type: AuthStateType.ERROR,
321
+ error: {statusCode: 401, message: 'Unauthorized again'},
322
+ })
323
+ act(() => {
324
+ rerender(
325
+ <ResourceProvider fallback={null}>
326
+ <ComlinkTokenRefreshProvider>
327
+ <div>Test</div>
328
+ </ComlinkTokenRefreshProvider>
329
+ </ResourceProvider>,
330
+ )
331
+ })
332
+
333
+ expect(mockFetch).toHaveBeenCalledTimes(2)
334
+ })
335
+
336
+ describe('when in studio mode', () => {
337
+ it('should not render DashboardTokenRefresh when studio mode enabled', () => {
338
+ render(
339
+ <ResourceProvider fallback={null} studioMode={{enabled: true}}>
340
+ <ComlinkTokenRefreshProvider>
341
+ <div>Test</div>
342
+ </ComlinkTokenRefreshProvider>
343
+ </ResourceProvider>,
344
+ )
345
+
346
+ // In studio mode, provider should return children directly
347
+ // So window connection should not be initialized
348
+ expect(mockUseWindowConnection).not.toHaveBeenCalled()
349
+ })
350
+ })
231
351
  })
232
352
  })
233
353
  })
@@ -130,8 +130,9 @@ function DashboardTokenRefresh({children}: PropsWithChildren) {
130
130
  export const ComlinkTokenRefreshProvider: React.FC<PropsWithChildren> = ({children}) => {
131
131
  const instance = useSanityInstance()
132
132
  const isInDashboard = useMemo(() => getIsInDashboardState(instance).getCurrent(), [instance])
133
+ const studioModeEnabled = !!instance.config.studioMode?.enabled
133
134
 
134
- if (isInDashboard) {
135
+ if (isInDashboard && !studioModeEnabled) {
135
136
  return <DashboardTokenRefresh>{children}</DashboardTokenRefresh>
136
137
  }
137
138
 
@@ -0,0 +1,78 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import {renderHook} from '@testing-library/react'
3
+ import {of} from 'rxjs'
4
+ import {describe, expect, it, vi} from 'vitest'
5
+
6
+ import {ResourceProvider} from '../../context/ResourceProvider'
7
+ import {
8
+ useAgentGenerate,
9
+ useAgentPatch,
10
+ useAgentPrompt,
11
+ useAgentTransform,
12
+ useAgentTranslate,
13
+ } from './agentActions'
14
+
15
+ vi.mock('@sanity/sdk', async (orig) => {
16
+ const actual = await orig()
17
+ return {
18
+ ...(actual as Record<string, any>),
19
+ agentGenerate: vi.fn(() => of('gen')),
20
+ agentTransform: vi.fn(() => of('xform')),
21
+ agentTranslate: vi.fn(() => of('xlate')),
22
+ agentPrompt: vi.fn(() => of('prompted')),
23
+ agentPatch: vi.fn(() => of('patched')),
24
+ }
25
+ })
26
+
27
+ describe('agent action hooks', () => {
28
+ const wrapper = ({children}: {children: React.ReactNode}) => (
29
+ <ResourceProvider projectId="p" dataset="d" fallback={null}>
30
+ {children}
31
+ </ResourceProvider>
32
+ )
33
+
34
+ it('useAgentGenerate returns a callable that delegates to core', async () => {
35
+ const {result} = renderHook(() => useAgentGenerate(), {wrapper})
36
+ const value = await new Promise<any>((resolve, reject) => {
37
+ result.current({} as any).subscribe({
38
+ next: (v) => resolve(v),
39
+ error: reject,
40
+ })
41
+ })
42
+ expect(value).toBe('gen')
43
+ })
44
+
45
+ it('useAgentTransform returns a callable that delegates to core', async () => {
46
+ const {result} = renderHook(() => useAgentTransform(), {wrapper})
47
+ const value = await new Promise<any>((resolve, reject) => {
48
+ result.current({} as any).subscribe({
49
+ next: (v) => resolve(v),
50
+ error: reject,
51
+ })
52
+ })
53
+ expect(value).toBe('xform')
54
+ })
55
+
56
+ it('useAgentTranslate returns a callable that delegates to core', async () => {
57
+ const {result} = renderHook(() => useAgentTranslate(), {wrapper})
58
+ const value = await new Promise<any>((resolve, reject) => {
59
+ result.current({} as any).subscribe({
60
+ next: (v) => resolve(v),
61
+ error: reject,
62
+ })
63
+ })
64
+ expect(value).toBe('xlate')
65
+ })
66
+
67
+ it('useAgentPrompt returns a callable that delegates to core', async () => {
68
+ const {result} = renderHook(() => useAgentPrompt(), {wrapper})
69
+ const value = await result.current({} as any)
70
+ expect(value).toBe('prompted')
71
+ })
72
+
73
+ it('useAgentPatch returns a callable that delegates to core', async () => {
74
+ const {result} = renderHook(() => useAgentPatch(), {wrapper})
75
+ const value = await result.current({} as any)
76
+ expect(value).toBe('patched')
77
+ })
78
+ })
@@ -0,0 +1,136 @@
1
+ import {
2
+ agentGenerate,
3
+ type AgentGenerateOptions,
4
+ agentPatch,
5
+ type AgentPatchOptions,
6
+ type AgentPatchResult,
7
+ agentPrompt,
8
+ type AgentPromptOptions,
9
+ type AgentPromptResult,
10
+ agentTransform,
11
+ type AgentTransformOptions,
12
+ agentTranslate,
13
+ type AgentTranslateOptions,
14
+ type SanityInstance,
15
+ } from '@sanity/sdk'
16
+ import {firstValueFrom} from 'rxjs'
17
+
18
+ import {createCallbackHook} from '../helpers/createCallbackHook'
19
+
20
+ interface Subscription {
21
+ unsubscribe(): void
22
+ }
23
+
24
+ interface Observer<T> {
25
+ next?: (value: T) => void
26
+ error?: (err: unknown) => void
27
+ complete?: () => void
28
+ }
29
+
30
+ interface Subscribable<T> {
31
+ subscribe(observer: Observer<T>): Subscription
32
+ subscribe(
33
+ next: (value: T) => void,
34
+ error?: (err: unknown) => void,
35
+ complete?: () => void,
36
+ ): Subscription
37
+ }
38
+
39
+ /**
40
+ * @alpha
41
+ * Generates content for a document (or specific fields) via Sanity Agent Actions.
42
+ * - Uses instruction templates with `$variables` and supports `instructionParams` (constants, fields, documents, GROQ queries).
43
+ * - Can target specific paths/fields; supports image generation when targeting image fields.
44
+ * - Supports optional `temperature`, `async`, `noWrite`, and `conditionalPaths`.
45
+ *
46
+ * Returns a stable callback that triggers the action and yields a Subscribable stream.
47
+ */
48
+ export const useAgentGenerate: () => (options: AgentGenerateOptions) => Subscribable<unknown> =
49
+ createCallbackHook(agentGenerate) as unknown as () => (
50
+ options: AgentGenerateOptions,
51
+ ) => Subscribable<unknown>
52
+
53
+ /**
54
+ * @alpha
55
+ * Transforms an existing document or selected fields using Sanity Agent Actions.
56
+ * - Accepts `instruction` and `instructionParams` (constants, fields, documents, GROQ queries).
57
+ * - Can write to the same or a different `targetDocument` (create/edit), and target specific paths.
58
+ * - Supports per-path image transform instructions and image description operations.
59
+ * - Optional `temperature`, `async`, `noWrite`, `conditionalPaths`.
60
+ *
61
+ * Returns a stable callback that triggers the action and yields a Subscribable stream.
62
+ */
63
+ export const useAgentTransform: () => (options: AgentTransformOptions) => Subscribable<unknown> =
64
+ createCallbackHook(agentTransform) as unknown as () => (
65
+ options: AgentTransformOptions,
66
+ ) => Subscribable<unknown>
67
+
68
+ /**
69
+ * @alpha
70
+ * Translates documents or fields using Sanity Agent Actions.
71
+ * - Configure `fromLanguage`/`toLanguage`, optional `styleGuide`, and `protectedPhrases`.
72
+ * - Can write into a different `targetDocument`, and/or store language in a field.
73
+ * - Optional `temperature`, `async`, `noWrite`, `conditionalPaths`.
74
+ *
75
+ * Returns a stable callback that triggers the action and yields a Subscribable stream.
76
+ */
77
+ export const useAgentTranslate: () => (options: AgentTranslateOptions) => Subscribable<unknown> =
78
+ createCallbackHook(agentTranslate) as unknown as () => (
79
+ options: AgentTranslateOptions,
80
+ ) => Subscribable<unknown>
81
+
82
+ /**
83
+ * @alpha
84
+ * Prompts the LLM using the same instruction template format as other actions.
85
+ * - `format`: 'string' or 'json' (instruction must contain the word "json" for JSON responses).
86
+ * - Optional `temperature`.
87
+ *
88
+ * Returns a stable callback that triggers the action and resolves a Promise with the prompt result.
89
+ */
90
+ function promptAdapter(
91
+ instance: SanityInstance,
92
+ options: AgentPromptOptions,
93
+ ): Promise<AgentPromptResult> {
94
+ return firstValueFrom(agentPrompt(instance, options))
95
+ }
96
+
97
+ /**
98
+ * @alpha
99
+ * Prompts the LLM using the same instruction template format as other actions.
100
+ * - `format`: 'string' or 'json' (instruction must contain the word "json" for JSON responses).
101
+ * - Optional `temperature`.
102
+ *
103
+ * Returns a stable callback that triggers the action and resolves a Promise with the prompt result.
104
+ */
105
+ export const useAgentPrompt: () => (options: AgentPromptOptions) => Promise<AgentPromptResult> =
106
+ createCallbackHook(promptAdapter)
107
+
108
+ /**
109
+ * @alpha
110
+ * Schema-aware patching with Sanity Agent Actions.
111
+ * - Validates provided paths/values against the document schema and merges object values safely.
112
+ * - Prevents duplicate keys and supports array appends (including after a specific keyed item).
113
+ * - Accepts `documentId` or `targetDocument` (mutually exclusive).
114
+ * - Optional `async`, `noWrite`, `conditionalPaths`.
115
+ *
116
+ * Returns a stable callback that triggers the action and resolves a Promise with the patch result.
117
+ */
118
+ function patchAdapter(
119
+ instance: SanityInstance,
120
+ options: AgentPatchOptions,
121
+ ): Promise<AgentPatchResult> {
122
+ return firstValueFrom(agentPatch(instance, options))
123
+ }
124
+
125
+ /**
126
+ * @alpha
127
+ * Schema-aware patching with Sanity Agent Actions.
128
+ * - Validates provided paths/values against the document schema and merges object values safely.
129
+ * - Prevents duplicate keys and supports array appends (including after a specific keyed item).
130
+ * - Accepts `documentId` or `targetDocument` (mutually exclusive).
131
+ * - Optional `async`, `noWrite`, `conditionalPaths`.
132
+ *
133
+ * Returns a stable callback that triggers the action and resolves a Promise with the patch result.
134
+ */
135
+ export const useAgentPatch: () => (options: AgentPatchOptions) => Promise<AgentPatchResult> =
136
+ createCallbackHook(patchAdapter)
@@ -0,0 +1,42 @@
1
+ import {renderHook} from '@testing-library/react'
2
+ import {describe, expect, it, vi} from 'vitest'
3
+
4
+ import {ResourceProvider} from '../../context/ResourceProvider'
5
+ import {useClient} from './useClient'
6
+
7
+ describe('useClient', () => {
8
+ const wrapper = ({children}: {children: React.ReactNode}) => (
9
+ <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
10
+ {children}
11
+ </ResourceProvider>
12
+ )
13
+
14
+ it('should throw a helpful error when called without options', () => {
15
+ // Suppress console.error for this test since we expect an error to be thrown
16
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
17
+
18
+ expect(() => {
19
+ // @ts-expect-error Testing missing options
20
+ renderHook(() => useClient(), {wrapper})
21
+ }).toThrowError(/requires a configuration object with at least an "apiVersion" property/)
22
+
23
+ consoleErrorSpy.mockRestore()
24
+ })
25
+
26
+ it('should throw a helpful error when called with undefined', () => {
27
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
28
+
29
+ expect(() => {
30
+ // @ts-expect-error Testing undefined options
31
+ renderHook(() => useClient(undefined), {wrapper})
32
+ }).toThrowError(/requires a configuration object with at least an "apiVersion" property/)
33
+
34
+ consoleErrorSpy.mockRestore()
35
+ })
36
+
37
+ it('should return a client when called with valid options', () => {
38
+ const {result} = renderHook(() => useClient({apiVersion: '2024-11-12'}), {wrapper})
39
+ expect(result.current).toBeDefined()
40
+ expect(result.current.fetch).toBeDefined()
41
+ })
42
+ })
@@ -1,5 +1,4 @@
1
- import {getClientState} from '@sanity/sdk'
2
- import {identity} from 'rxjs'
1
+ import {type ClientOptions, getClientState, type SanityInstance} from '@sanity/sdk'
3
2
 
4
3
  import {createStateSourceHook} from '../helpers/createStateSourceHook'
5
4
 
@@ -31,6 +30,14 @@ import {createStateSourceHook} from '../helpers/createStateSourceHook'
31
30
  * @function
32
31
  */
33
32
  export const useClient = createStateSourceHook({
34
- getState: getClientState,
35
- getConfig: identity,
33
+ getState: (instance: SanityInstance, options: ClientOptions) => {
34
+ if (!options || typeof options !== 'object') {
35
+ throw new Error(
36
+ 'useClient() requires a configuration object with at least an "apiVersion" property. ' +
37
+ 'Example: useClient({ apiVersion: "2024-11-12" })',
38
+ )
39
+ }
40
+ return getClientState(instance, options)
41
+ },
42
+ getConfig: (options: ClientOptions) => options,
36
43
  })