@sanity/sdk-react 3.12.0 → 3.13.0-app-functions.20261106191857

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": "3.12.0",
3
+ "version": "3.13.0-app-functions.20261106191857",
4
4
  "private": false,
5
5
  "description": "Sanity SDK React toolkit for Content OS",
6
6
  "keywords": [
@@ -43,10 +43,10 @@
43
43
  "access": "public"
44
44
  },
45
45
  "dependencies": {
46
- "@module-federation/runtime": "^2.9.2",
46
+ "@module-federation/runtime": "^2.9.3",
47
47
  "@sanity/client": "^8.14.1",
48
48
  "@sanity/message-protocol": "^0.24.0",
49
- "@sanity/sdk": "3.12.0",
49
+ "@sanity/sdk": "3.13.0-app-functions.20261106191857",
50
50
  "@sanity/types": "^6.21.0",
51
51
  "react-compiler-runtime": "^1.0.0",
52
52
  "react-error-boundary": "^6.1.6",
@@ -66,10 +66,10 @@
66
66
  "@types/node": "^24.19.2",
67
67
  "@types/react": "^19.3.0",
68
68
  "@types/react-dom": "^19.3.0",
69
- "@vitejs/plugin-react": "^6.1.2",
70
- "@vitest/coverage-v8": "^5.0.5",
69
+ "@vitejs/plugin-react": "^6.2.1",
70
+ "@vitest/coverage-v8": "^5.1.0",
71
71
  "babel-plugin-react-compiler": "^1.0.0",
72
- "groq": "^6.21.0",
72
+ "groq": "^6.22.0",
73
73
  "groq-js": "^2.0.1",
74
74
  "jsdom": "^30.1.3",
75
75
  "oxfmt": "^0.75.0",
@@ -78,8 +78,8 @@
78
78
  "react-dom": "^19.3.1",
79
79
  "rolldown": "^1.2.12",
80
80
  "typescript": "^7.0.2",
81
- "vite": "^8.3.5",
82
- "vitest": "^5.0.5",
81
+ "vite": "^8.3.6",
82
+ "vitest": "^5.1.0",
83
83
  "xstate": "5.33.3"
84
84
  },
85
85
  "peerDependencies": {
@@ -5,6 +5,11 @@ export {AuthBoundary, type AuthBoundaryProps} from '../components/auth/AuthBound
5
5
  export {SanityApp, type SanityAppProps} from '../components/SanityApp'
6
6
  export {SDKProvider, type SDKProviderProps} from '../components/SDKProvider'
7
7
  export {type DocumentHandle, type DocumentTypeHandle, type ResourceHandle} from '../config/handles'
8
+ export {
9
+ type AppFunctionsConfig,
10
+ AppFunctionsProvider,
11
+ type AppFunctionsProviderProps,
12
+ } from '../context/AppFunctionsProvider'
8
13
  export {ComlinkTokenRefreshProvider} from '../context/ComlinkTokenRefresh'
9
14
  export {renderSanityApp} from '../context/renderSanityApp'
10
15
  export {ResourceProvider, type ResourceProviderProps} from '../context/ResourceProvider'
@@ -73,6 +78,7 @@ export {
73
78
  type DocumentsResponse,
74
79
  useDocuments,
75
80
  } from '../hooks/documents/useDocuments'
81
+ export {useFunctions} from '../hooks/functions/useFunctions'
76
82
  export {type FetcherHookResult} from '../hooks/helpers/createFetcherHook'
77
83
  export {type MutationHookResult} from '../hooks/helpers/createMutationHook'
78
84
  export {useInstallation} from '../hooks/installations/useInstallation'
@@ -0,0 +1,69 @@
1
+ import {reportError} from '@sanity/sdk/_internal'
2
+ import {render, screen} from '@testing-library/react'
3
+ import {Component, type ReactNode} from 'react'
4
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
5
+
6
+ import {SDKProvider} from './SDKProvider'
7
+
8
+ // Renders the real `react-error-boundary`, which skips `onError` when the fallback rethrows.
9
+ vi.mock('@sanity/sdk/_internal', async (importOriginal) => ({
10
+ ...(await importOriginal<object>()),
11
+ reportError: vi.fn(),
12
+ }))
13
+ vi.mock('../context/ResourceProvider', () => ({
14
+ ResourceProvider: ({children}: {children: ReactNode}) => children,
15
+ }))
16
+ vi.mock('./auth/AuthBoundary', () => ({
17
+ AuthBoundary: ({children}: {children: ReactNode}) => children,
18
+ }))
19
+ vi.mock('../context/OrganizationResourcesProvider', () => ({
20
+ OrganizationResourcesProvider: ({children}: {children: ReactNode}) => children,
21
+ }))
22
+
23
+ class AppBoundary extends Component<{children: ReactNode}, {caught: boolean}> {
24
+ override state = {caught: false}
25
+ static getDerivedStateFromError() {
26
+ return {caught: true}
27
+ }
28
+ override render() {
29
+ return this.state.caught ? 'app boundary caught it' : this.props.children
30
+ }
31
+ }
32
+
33
+ function Thrower({error}: {error: Error}): never {
34
+ throw error
35
+ }
36
+
37
+ describe('SDKProvider error reporting', () => {
38
+ beforeEach(() => {
39
+ vi.mocked(reportError).mockClear()
40
+ vi.spyOn(console, 'error').mockImplementation(() => {})
41
+ })
42
+
43
+ it('reports an error it rethrows, and the app boundary still receives it', () => {
44
+ const error = new TypeError('sdk bug')
45
+ render(
46
+ <AppBoundary>
47
+ <SDKProvider config={{projectId: 'p', dataset: 'd'}} fallback={null}>
48
+ <Thrower error={error} />
49
+ </SDKProvider>
50
+ </AppBoundary>,
51
+ )
52
+
53
+ expect(screen.getByText('app boundary caught it')).toBeInTheDocument()
54
+ expect(reportError).toHaveBeenCalledWith(error, {errorBoundary: 'SDKProvider'})
55
+ })
56
+
57
+ it('does not report a chunk load error, which it renders itself', () => {
58
+ const error = Object.assign(new Error('Loading chunk 5 failed.'), {name: 'ChunkLoadError'})
59
+ render(
60
+ <AppBoundary>
61
+ <SDKProvider config={{projectId: 'p', dataset: 'd'}} fallback={null}>
62
+ <Thrower error={error} />
63
+ </SDKProvider>
64
+ </AppBoundary>,
65
+ )
66
+
67
+ expect(reportError).not.toHaveBeenCalled()
68
+ })
69
+ })
@@ -13,6 +13,9 @@ import {ResourceProvider} from '../context/ResourceProvider'
13
13
  import {AuthBoundary, type AuthBoundaryProps} from './auth/AuthBoundary'
14
14
  import {ChunkLoadError} from './errors/ChunkLoadError'
15
15
  import {clearChunkReloadFlag} from './errors/chunkReloadStorage'
16
+ import {reportBoundaryError} from './errors/reportBoundaryError'
17
+
18
+ const reportSDKProviderError = reportBoundaryError('SDKProvider')
16
19
 
17
20
  /**
18
21
  * @internal
@@ -119,6 +122,8 @@ function ChunkAwareFallback(fallbackProps: FallbackProps): ReactElement {
119
122
  if (isImportError(fallbackProps.error)) {
120
123
  return <ChunkLoadError {...fallbackProps} />
121
124
  }
125
+ // Report here, not in `onError`: React skips `onError` on a boundary whose fallback rethrows
126
+ reportSDKProviderError(fallbackProps.error)
122
127
  // Re-throw so downstream boundaries (e.g. AuthBoundary) handle other errors.
123
128
  throw fallbackProps.error
124
129
  }
@@ -0,0 +1,80 @@
1
+ import {createSanityInstance} from '@sanity/sdk'
2
+ import {reportError} from '@sanity/sdk/_internal'
3
+ import {render, screen} from '@testing-library/react'
4
+ import {Component, type ReactNode} from 'react'
5
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
6
+
7
+ import {AuthBoundary} from './AuthBoundary'
8
+ import {AuthError} from './AuthError'
9
+
10
+ // These tests render the real `react-error-boundary`. `AuthBoundary.test.tsx` replaces it with a
11
+ // mock that always calls `componentDidCatch`, which React does not do for a boundary whose
12
+ // fallback rethrows.
13
+ vi.mock('@sanity/sdk/_internal', async (importOriginal) => ({
14
+ ...(await importOriginal<object>()),
15
+ reportError: vi.fn(),
16
+ }))
17
+ vi.mock('../../hooks/auth/useAuthState', () => ({
18
+ useAuthState: vi.fn(() => ({type: 'logged-in', token: 'token', currentUser: null})),
19
+ }))
20
+ const instance = createSanityInstance()
21
+ vi.mock('../../hooks/context/useSanityInstance', () => ({useSanityInstance: () => instance}))
22
+ vi.mock('../../hooks/auth/useLoginUrl')
23
+ vi.mock('../../hooks/auth/useOAuthAuthorize', () => ({useOAuthAuthorize: vi.fn(() => vi.fn())}))
24
+ vi.mock('../../hooks/auth/useVerifyOrgProjects')
25
+ vi.mock('../../hooks/auth/useHandleAuthCallback', () => ({
26
+ useHandleAuthCallback: vi.fn(() => async () => {}),
27
+ }))
28
+ vi.mock('../../hooks/auth/useLogOut', () => ({useLogOut: vi.fn(() => async () => {})}))
29
+ vi.mock('../../hooks/comlink/useWindowConnection', () => ({
30
+ useWindowConnection: vi.fn(() => ({fetch: vi.fn()})),
31
+ }))
32
+ vi.mock('../utils', () => ({isInIframe: vi.fn(() => false)}))
33
+
34
+ /** Stands in for the app's own boundary that receives errors the SDK rethrows. */
35
+ class AppBoundary extends Component<{children: ReactNode}, {caught: boolean}> {
36
+ override state = {caught: false}
37
+ static getDerivedStateFromError() {
38
+ return {caught: true}
39
+ }
40
+ override render() {
41
+ return this.state.caught ? 'app boundary caught it' : this.props.children
42
+ }
43
+ }
44
+
45
+ function Thrower({error}: {error: Error}): never {
46
+ throw error
47
+ }
48
+
49
+ describe('AuthBoundary error reporting', () => {
50
+ beforeEach(() => {
51
+ vi.mocked(reportError).mockClear()
52
+ vi.spyOn(console, 'error').mockImplementation(() => {})
53
+ })
54
+
55
+ it('reports a plain error from a child and still rethrows it to the app boundary', () => {
56
+ const error = new TypeError('sdk bug')
57
+ render(
58
+ <AppBoundary>
59
+ <AuthBoundary>
60
+ <Thrower error={error} />
61
+ </AuthBoundary>
62
+ </AppBoundary>,
63
+ )
64
+
65
+ expect(screen.getByText('app boundary caught it')).toBeInTheDocument()
66
+ expect(reportError).toHaveBeenCalledWith(error, {errorBoundary: 'AuthBoundary'})
67
+ })
68
+
69
+ it('does not report an auth error, which the boundary renders itself', () => {
70
+ render(
71
+ <AppBoundary>
72
+ <AuthBoundary>
73
+ <Thrower error={new AuthError(new Error('login failed'))} />
74
+ </AuthBoundary>
75
+ </AppBoundary>,
76
+ )
77
+
78
+ expect(reportError).not.toHaveBeenCalled()
79
+ })
80
+ })
@@ -13,6 +13,7 @@ import {useVerifyOrgProjects} from '../../hooks/auth/useVerifyOrgProjects'
13
13
  import {useSanityInstance} from '../../hooks/context/useSanityInstance'
14
14
  import {ChunkLoadError} from '../errors/ChunkLoadError'
15
15
  import {CorsErrorComponent} from '../errors/CorsErrorComponent'
16
+ import {reportBoundaryError} from '../errors/reportBoundaryError'
16
17
  import {isInIframe} from '../utils'
17
18
  import {AuthError} from './AuthError'
18
19
  import {ConfigurationError} from './ConfigurationError'
@@ -34,6 +35,8 @@ if (isInIframe() && !document.querySelector('[data-sanity-core]')) {
34
35
  document.head.appendChild(script)
35
36
  }
36
37
 
38
+ const reportAuthBoundaryError = reportBoundaryError('AuthBoundary')
39
+
37
40
  /**
38
41
  * @internal
39
42
  */
@@ -124,6 +127,9 @@ export function AuthBoundary({
124
127
 
125
128
  const FallbackComponent = useMemo(() => {
126
129
  return function LoginComponentWithLayoutProps(fallbackProps: FallbackProps) {
130
+ // `LoginError` rethrows errors it does not render, and React skips `onError` on a boundary
131
+ // whose fallback throws, so the report has to happen here
132
+ reportAuthBoundaryError(fallbackProps.error)
127
133
  // Chunk-load errors from any lazy-loaded code beneath this boundary
128
134
  // (typically the consumer's app) get the chunk-aware fallback instead
129
135
  // of being misreported as auth errors.
@@ -0,0 +1,38 @@
1
+ import {CorsOriginError} from '@sanity/client'
2
+ import {reportError} from '@sanity/sdk/_internal'
3
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
4
+
5
+ import {AuthError} from '../auth/AuthError'
6
+ import {ConfigurationError} from '../auth/ConfigurationError'
7
+ import {reportBoundaryError} from './reportBoundaryError'
8
+
9
+ vi.mock('@sanity/sdk/_internal', () => ({reportError: vi.fn()}))
10
+
11
+ describe('reportBoundaryError', () => {
12
+ beforeEach(() => {
13
+ vi.mocked(reportError).mockClear()
14
+ })
15
+
16
+ it('reports an unexpected error with the boundary name', () => {
17
+ const error = new TypeError('boom')
18
+ reportBoundaryError('AuthBoundary')(error)
19
+ expect(reportError).toHaveBeenCalledWith(error, {errorBoundary: 'AuthBoundary'})
20
+ })
21
+
22
+ it.each([
23
+ ['AuthError', new AuthError(new Error('login failed'))],
24
+ ['ConfigurationError', new ConfigurationError({message: 'bad config'})],
25
+ ['CorsOriginError', new CorsOriginError({projectId: 'abc'})],
26
+ [
27
+ 'a CorsOriginError from another copy of @sanity/client',
28
+ Object.assign(new Error('CorsOriginError'), {name: 'CorsOriginError'}),
29
+ ],
30
+ [
31
+ 'a chunk load error',
32
+ Object.assign(new Error('Loading chunk 5 failed.'), {name: 'ChunkLoadError'}),
33
+ ],
34
+ ])('does not report %s', (_name, error) => {
35
+ reportBoundaryError('AuthBoundary')(error)
36
+ expect(reportError).not.toHaveBeenCalled()
37
+ })
38
+ })
@@ -0,0 +1,33 @@
1
+ import {CorsOriginError} from '@sanity/client'
2
+ import {isImportError} from '@sanity/sdk'
3
+ import {reportError} from '@sanity/sdk/_internal'
4
+
5
+ import {AuthError} from '../auth/AuthError'
6
+ import {ConfigurationError} from '../auth/ConfigurationError'
7
+
8
+ /**
9
+ * Creates a function that reports an error an `ErrorBoundary` caught to Sentry. Call it from the
10
+ * boundary's `FallbackComponent`, not from `onError`: the SDK fallbacks rethrow errors they do not
11
+ * render, and React skips `onError` on a boundary whose fallback throws. Fallbacks render more
12
+ * than once for one error, which the reporter's deduplication absorbs.
13
+ *
14
+ * The boundaries also catch errors thrown by the app rendered beneath them, so the reporter keeps
15
+ * only errors whose throwing frame is in the SDK. Auth, configuration, CORS, and chunk load errors
16
+ * are expected states and are never reported.
17
+ */
18
+ export function reportBoundaryError(errorBoundary: string) {
19
+ return (error: unknown): void => {
20
+ if (
21
+ error instanceof AuthError ||
22
+ error instanceof ConfigurationError ||
23
+ // The module bundle can hold a second copy of `@sanity/client`, so `instanceof` alone can
24
+ // miss an error thrown by the other copy
25
+ error instanceof CorsOriginError ||
26
+ (error instanceof Error && error.name === 'CorsOriginError') ||
27
+ isImportError(error)
28
+ ) {
29
+ return
30
+ }
31
+ reportError(error, {errorBoundary})
32
+ }
33
+ }
@@ -0,0 +1,13 @@
1
+ import {type AppFunctionContract} from '@sanity/sdk/_internal'
2
+ import {createContext} from 'react'
3
+
4
+ /** Function stack binding supplied by the CLI-generated app entry. @internal */
5
+ export interface AppFunctionsConfig {
6
+ /** Generated client contracts, supplied by the CLI. */
7
+ functions?: Record<string, AppFunctionContract>
8
+ stackId: string
9
+ organizationId: string
10
+ }
11
+
12
+ /** App-local defaults for authenticated function clients. @internal */
13
+ export const AppFunctionsContext = createContext<AppFunctionsConfig | undefined>(undefined)
@@ -0,0 +1,20 @@
1
+ import {type ReactNode} from 'react'
2
+
3
+ import {type AppFunctionsConfig, AppFunctionsContext} from './AppFunctionsContext'
4
+
5
+ export type {AppFunctionsConfig} from './AppFunctionsContext'
6
+
7
+ /** Props for the CLI's function binding provider. @internal */
8
+ export interface AppFunctionsProviderProps {
9
+ value: AppFunctionsConfig
10
+ children: ReactNode
11
+ }
12
+
13
+ /**
14
+ * Provide a function stack to useClient within this application's React tree.
15
+ * Authentication stays with the SDK instance. The CLI supplies stack IDs and contracts.
16
+ * @internal
17
+ */
18
+ export function AppFunctionsProvider({value, children}: AppFunctionsProviderProps): ReactNode {
19
+ return <AppFunctionsContext.Provider value={value}>{children}</AppFunctionsContext.Provider>
20
+ }
@@ -1,7 +1,15 @@
1
- import {type ClientOptions, getClientState, type SanityInstance} from '@sanity/sdk'
1
+ import {type SanityClient} from '@sanity/client'
2
+ import {type ClientOptions, getClientState} from '@sanity/sdk'
3
+ import {useContext} from 'react'
2
4
 
5
+ import {AppFunctionsContext} from '../../context/AppFunctionsContext'
3
6
  import {createStateSourceHook} from '../helpers/createStateSourceHook'
4
7
 
8
+ const useClientState = createStateSourceHook({
9
+ getState: getClientState,
10
+ getConfig: (options: ClientOptions) => options,
11
+ })
12
+
5
13
  /**
6
14
  * A React hook that provides a client that subscribes to changes in your application,
7
15
  *
@@ -29,15 +37,21 @@ import {createStateSourceHook} from '../helpers/createStateSourceHook'
29
37
  * @public
30
38
  * @function
31
39
  */
32
- export const useClient = createStateSourceHook({
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,
43
- })
40
+ export function useClient(options: ClientOptions): SanityClient {
41
+ const binding = useContext(AppFunctionsContext)
42
+ if (!options || typeof options !== 'object') {
43
+ throw new Error(
44
+ 'useClient() requires a configuration object with at least an "apiVersion" property. ' +
45
+ 'Example: useClient({ apiVersion: "2024-11-12" })',
46
+ )
47
+ }
48
+ return useClientState(
49
+ binding
50
+ ? {
51
+ ...options,
52
+ stackId: options.stackId ?? binding.stackId,
53
+ organizationId: options.organizationId ?? binding.organizationId,
54
+ }
55
+ : options,
56
+ )
57
+ }
@@ -0,0 +1,28 @@
1
+ import {type AppFunctions} from '@sanity/sdk'
2
+ import {createAppFunctions} from '@sanity/sdk/_internal'
3
+ import {useContext, useMemo} from 'react'
4
+
5
+ import {AppFunctionsContext} from '../../context/AppFunctionsContext'
6
+ import {useClient} from '../client/useClient'
7
+
8
+ /**
9
+ * Return the app's typed, authenticated functions. Types are generated by Sanity CLI.
10
+ * Each call waits for the function's result and validates it against the generated contract.
11
+ * @example
12
+ * ```tsx
13
+ * const functions = useFunctions()
14
+ * const page = await functions.callWikipedia({timeout: 30_000})
15
+ * ```
16
+ * @public
17
+ */
18
+ export function useFunctions(): AppFunctions {
19
+ const binding = useContext(AppFunctionsContext)
20
+ const client = useClient({apiVersion: '2026-06-15', scope: 'global'})
21
+ const contracts = binding?.functions
22
+ return useMemo(() => {
23
+ if (!contracts) {
24
+ throw new Error('App function contracts are missing. Build the app with Sanity CLI.')
25
+ }
26
+ return createAppFunctions(client, contracts)
27
+ }, [client, contracts])
28
+ }