@sanity/sdk-react 3.12.0 → 3.13.0-next.20261105164925

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-next.20261105164925",
4
4
  "private": false,
5
5
  "description": "Sanity SDK React toolkit for Content OS",
6
6
  "keywords": [
@@ -46,7 +46,7 @@
46
46
  "@module-federation/runtime": "^2.9.2",
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-next.20261105164925",
50
50
  "@sanity/types": "^6.21.0",
51
51
  "react-compiler-runtime": "^1.0.0",
52
52
  "react-error-boundary": "^6.1.6",
@@ -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
+ }