@sanity/sdk-react 2.3.1 → 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.1",
3
+ "version": "2.4.0",
4
4
  "private": false,
5
5
  "description": "Sanity SDK React toolkit for Content OS",
6
6
  "keywords": [
@@ -51,30 +51,31 @@
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.1"
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
80
  "@repo/package.bundle": "3.82.0",
80
81
  "@repo/config-test": "0.0.1",
@@ -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,
@@ -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
  }
@@ -5,7 +5,6 @@ import {afterEach, beforeEach, describe, expect, it, type Mock, vi} from 'vitest
5
5
 
6
6
  import {useAuthState} from '../hooks/auth/useAuthState'
7
7
  import {useWindowConnection} from '../hooks/comlink/useWindowConnection'
8
- import {useSanityInstance} from '../hooks/context/useSanityInstance'
9
8
  import {ComlinkTokenRefreshProvider} from './ComlinkTokenRefresh'
10
9
  import {ResourceProvider} from './ResourceProvider'
11
10
 
@@ -27,24 +26,13 @@ vi.mock('../hooks/comlink/useWindowConnection', () => ({
27
26
  useWindowConnection: vi.fn(),
28
27
  }))
29
28
 
30
- vi.mock('../hooks/context/useSanityInstance', () => ({
31
- useSanityInstance: vi.fn(),
32
- }))
33
-
34
29
  // Use simpler mock typings
35
30
  const mockGetIsInDashboardState = getIsInDashboardState as Mock
36
31
  const mockSetAuthToken = setAuthToken as Mock
37
32
  const mockUseAuthState = useAuthState as Mock
38
33
  const mockUseWindowConnection = useWindowConnection as Mock
39
- const mockUseSanityInstance = useSanityInstance as unknown as Mock
40
34
 
41
35
  const mockFetch = vi.fn()
42
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
43
- const mockSanityInstance: any = {
44
- projectId: 'test',
45
- dataset: 'test',
46
- config: {studioMode: {enabled: false}},
47
- }
48
36
 
49
37
  describe('ComlinkTokenRefresh', () => {
50
38
  beforeEach(() => {
@@ -52,7 +40,6 @@ describe('ComlinkTokenRefresh', () => {
52
40
  mockGetIsInDashboardState.mockReturnValue({getCurrent: vi.fn(() => false)})
53
41
  mockUseAuthState.mockReturnValue({type: AuthStateType.LOGGED_IN})
54
42
  mockUseWindowConnection.mockReturnValue({fetch: mockFetch})
55
- mockUseSanityInstance.mockReturnValue(mockSanityInstance)
56
43
  })
57
44
 
58
45
  afterEach(() => {
@@ -127,6 +114,15 @@ describe('ComlinkTokenRefresh', () => {
127
114
  })
128
115
  mockFetch.mockResolvedValueOnce({token: 'new-token'})
129
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
+
130
126
  render(
131
127
  <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
132
128
  <ComlinkTokenRefreshProvider>
@@ -141,6 +137,14 @@ describe('ComlinkTokenRefresh', () => {
141
137
 
142
138
  expect(mockSetAuthToken).toHaveBeenCalledWith(expect.any(Object), 'new-token')
143
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()
144
148
  })
145
149
 
146
150
  it('should not set auth token if received token is null when not in studio mode', async () => {
@@ -243,20 +247,100 @@ describe('ComlinkTokenRefresh', () => {
243
247
  expect(mockFetch).toHaveBeenCalledWith('dashboard/v1/auth/tokens/create')
244
248
  })
245
249
 
246
- describe('when in studio mode', () => {
247
- beforeEach(() => {
248
- // Make the instance report studio mode enabled
249
- mockUseSanityInstance.mockReturnValue({
250
- ...mockSanityInstance,
251
- config: {studioMode: {enabled: true}},
252
- })
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'},
253
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
+ )
254
263
 
255
- it('should not render DashboardTokenRefresh when studio mode enabled', () => {
256
- render(
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}>
257
306
  <ComlinkTokenRefreshProvider>
258
307
  <div>Test</div>
259
- </ComlinkTokenRefreshProvider>,
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>,
260
344
  )
261
345
 
262
346
  // In studio mode, provider should return children directly
@@ -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
  })
@@ -0,0 +1,12 @@
1
+ import {type DocumentHandle, type DocumentSource} from '@sanity/sdk'
2
+ /**
3
+ * Document handle that optionally includes a source (e.g., media library source)
4
+ * or projectId and dataset for traditional dataset sources
5
+ * (but now marked optional since it's valid to just use a source)
6
+ * @beta
7
+ */
8
+ export interface DocumentHandleWithSource extends Omit<DocumentHandle, 'projectId' | 'dataset'> {
9
+ source?: DocumentSource
10
+ projectId?: string
11
+ dataset?: string
12
+ }