Skip to content

Commit

Permalink
feat(sanity): add config for onUncaughtError (#7553)
Browse files Browse the repository at this point in the history
### Description

Adds ability to callback when an error happens and allows developers to
add custom error telemetry (for example)

I will drop the throw errors before merging, those are for testing
purposes

### What to review

- Does the code make sense? 
- Does the naming of the callback make sense?
- I tried in other places, however, while discussing it with others in
the team after not finding a place that would have context for the
`useSource`, the current place where it exists seems to be the better
option. If you all have any other suggestions I'd love to hear it!

### Testing

Automated tests are in place.
You can also manually trigger it by adding `throw new error("oh no new
error)` within the `imageInput` or the `studioComponents` and then going
to the `custom-components/structure` workspace and seeing the console
logs work

### Notes for release

Adds ability to callback when an error happens and allows developers to
use errors thrown by the studio

---------

Co-authored-by: Bjørge Næss <[email protected]>
  • Loading branch information
RitaDias and bjoerge authored Oct 7, 2024
1 parent 21d848d commit e3cf177
Show file tree
Hide file tree
Showing 19 changed files with 372 additions and 25 deletions.
1 change: 1 addition & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ const config = {
'ButtonProps',
'Dialog',
'DialogProps',
'ErrorBoundary',
'MenuButton',
'MenuButtonProps',
'MenuGroup',
Expand Down
13 changes: 13 additions & 0 deletions dev/test-studio/sanity.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,13 @@ const defaultWorkspace = {
projectId: 'ppsg7ml5',
dataset: 'test',
plugins: [sharedSettings()],

onUncaughtError: (error, errorInfo) => {
// eslint-disable-next-line no-console
console.log(error)
// eslint-disable-next-line no-console
console.log(errorInfo)
},
basePath: '/test',
icon: SanityMonogram,
// eslint-disable-next-line camelcase
Expand Down Expand Up @@ -246,6 +253,12 @@ export default defineConfig([
dataset: 'test',
plugins: [sharedSettings(), studioComponentsPlugin(), formComponentsPlugin()],
basePath: '/custom-components',
onUncaughtError: (error, errorInfo) => {
// eslint-disable-next-line no-console
console.log(error)
// eslint-disable-next-line no-console
console.log(errorInfo)
},
form: {
components: {
input: Input,
Expand Down
25 changes: 24 additions & 1 deletion packages/sanity/src/core/config/configPropertyReducers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {type AssetSource, type SchemaTypeDefinition} from '@sanity/types'
import {type ReactNode} from 'react'
import {type ErrorInfo, type ReactNode} from 'react'

import {type LocaleConfigContext, type LocaleDefinition, type LocaleResourceBundle} from '../i18n'
import {type Template, type TemplateItem} from '../templates'
Expand Down Expand Up @@ -310,6 +310,29 @@ export const documentCommentsEnabledReducer = (opts: {
return result
}

export const onUncaughtErrorResolver = (opts: {
config: PluginOptions
context: {error: Error; errorInfo: ErrorInfo}
}) => {
const {config, context} = opts
const flattenedConfig = flattenConfig(config, [])
flattenedConfig.forEach(({config: pluginConfig}) => {
// There is no concept of 'previous value' in this API. We only care about the final value.
// That is, if a plugin returns true, but the next plugin returns false, the result will be false.
// The last plugin 'wins'.
const resolver = pluginConfig.onUncaughtError

if (typeof resolver === 'function') return resolver(context.error, context.errorInfo)
if (!resolver) return undefined

throw new Error(
`Expected \`document.onUncaughtError\` to be a a function, but received ${getPrintableType(
resolver,
)}`,
)
})
}

export const internalTasksReducer = (opts: {
config: PluginOptions
}): {footerAction: ReactNode} | undefined => {
Expand Down
19 changes: 18 additions & 1 deletion packages/sanity/src/core/config/prepareConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import {type CurrentUser, type Schema, type SchemaValidationProblem} from '@sani
import {studioTheme} from '@sanity/ui'
import {type i18n} from 'i18next'
import {startCase} from 'lodash'
import {type ComponentType, createElement, type ElementType, isValidElement} from 'react'
import {
type ComponentType,
createElement,
type ElementType,
type ErrorInfo,
isValidElement,
} from 'react'
import {isValidElementType} from 'react-is'
import {map, shareReplay} from 'rxjs/operators'

Expand Down Expand Up @@ -32,6 +38,7 @@ import {
internalTasksReducer,
legacySearchEnabledReducer,
newDocumentOptionsResolver,
onUncaughtErrorResolver,
partialIndexingEnabledReducer,
resolveProductionUrlReducer,
schemaTemplatesReducer,
Expand Down Expand Up @@ -626,6 +633,16 @@ function resolveSource({
staticInitialValueTemplateItems,
options: config,
},
onUncaughtError: (error: Error, errorInfo: ErrorInfo) => {
return onUncaughtErrorResolver({
config,
context: {
error: error,
errorInfo: errorInfo,
},
})
},

beta: {
treeArrayEditing: {
// This beta feature is no longer available.
Expand Down
10 changes: 9 additions & 1 deletion packages/sanity/src/core/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
type SchemaTypeDefinition,
} from '@sanity/types'
import {type i18n} from 'i18next'
import {type ComponentType, type ReactNode} from 'react'
import {type ComponentType, type ErrorInfo, type ReactNode} from 'react'
import {type Observable} from 'rxjs'
import {type Router, type RouterState} from 'sanity/router'

Expand Down Expand Up @@ -392,6 +392,10 @@ export interface PluginOptions {
* @internal
*/
beta?: BetaFeatures
/** Configuration for error handling.
* @beta
*/
onUncaughtError?: (error: Error, errorInfo: ErrorInfo) => void
}

/** @internal */
Expand Down Expand Up @@ -781,6 +785,10 @@ export interface Source {
* @internal
*/
beta?: BetaFeatures
/** Configuration for error handling.
* @internal
*/
onUncaughtError?: (error: Error, errorInfo: ErrorInfo) => void
}

/** @internal */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {beforeAll, describe, expect, it, jest} from '@jest/globals'
import {render, screen} from '@testing-library/react'
import {type SanityClient} from 'sanity'

import {createMockSanityClient} from '../../../../test/mocks/mockSanityClient'
import {createTestProvider} from '../../../../test/testUtils/TestProvider'
import {FormBuilderInputErrorBoundary} from './FormBuilderInputErrorBoundary'

jest.mock('use-hot-module-reload', () => ({
useHotModuleReload: jest.fn(),
}))

describe('FormBuilderInputErrorBoundary', () => {
beforeAll(() => {
jest.clearAllMocks()
})

it('renders children when there is no error', async () => {
render(
<FormBuilderInputErrorBoundary>
<div data-testid="child">Child Component</div>
</FormBuilderInputErrorBoundary>,
)

expect(screen.getByTestId('child')).toBeInTheDocument()
})

it('calls onUncaughtError when an error is caught', async () => {
const onUncaughtError = jest.fn()

const ThrowErrorComponent = () => {
throw new Error('An EXPECTED, testing error occurred!')
}

const client = createMockSanityClient() as unknown as SanityClient

const TestProvider = await createTestProvider({
client,
config: {
name: 'default',
projectId: 'test',
dataset: 'test',
onUncaughtError,
},
})

render(
<TestProvider>
<FormBuilderInputErrorBoundary>
<ThrowErrorComponent />
</FormBuilderInputErrorBoundary>
</TestProvider>,
)

expect(onUncaughtError).toHaveBeenCalledTimes(1)
})
})
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {Box, Card, Code, ErrorBoundary, Stack, Text} from '@sanity/ui'
import {Box, Card, Code, Stack, Text} from '@sanity/ui'
import {useCallback, useMemo, useState} from 'react'
import {useHotModuleReload} from 'use-hot-module-reload'

import {ErrorBoundary} from '../../../ui-components/errorBoundary'
import {SchemaError} from '../../config'
import {isDev} from '../../environment'
import {useTranslation} from '../../i18n'
Expand Down
13 changes: 2 additions & 11 deletions packages/sanity/src/core/studio/StudioErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,6 @@
/* eslint-disable i18next/no-literal-string */
/* eslint-disable @sanity/i18n/no-attribute-string-literals */
import {
Box,
Card,
Code,
Container,
ErrorBoundary,
type ErrorBoundaryProps,
Heading,
Stack,
Text,
} from '@sanity/ui'
import {Box, Card, Code, Container, type ErrorBoundaryProps, Heading, Stack, Text} from '@sanity/ui'
import {
type ComponentType,
type ErrorInfo,
Expand All @@ -23,6 +13,7 @@ import {ErrorActions, isDev, isProd} from 'sanity'
import {styled} from 'styled-components'
import {useHotModuleReload} from 'use-hot-module-reload'

import {ErrorBoundary} from '../../ui-components'
import {SchemaError} from '../config'
import {errorReporter} from '../error/errorReporter'
import {CorsOriginError} from '../store'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {TrashIcon} from '@sanity/icons'
import {Box, Card, ErrorBoundary, Flex, Stack, Text} from '@sanity/ui'
import {Box, Card, Flex, Stack, Text} from '@sanity/ui'
import {type ErrorInfo, useCallback, useState} from 'react'
import FocusLock from 'react-focus-lock'

import {Button} from '../../../../../../../../ui-components'
import {Button, ErrorBoundary} from '../../../../../../../../ui-components'
import {supportsTouch} from '../../../../../../../util'
import {useSearchState} from '../../../contexts/search/useSearchState'
import {getFilterDefinition} from '../../../definitions/filters'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {ErrorBoundary} from '@sanity/ui'
import {type ComponentType, type ReactNode, useEffect, useState} from 'react'
import {combineLatest, of} from 'rxjs'
import {catchError, map} from 'rxjs/operators'

import {ErrorBoundary} from '../../../ui-components'
import {
ConfigResolutionError,
type Source,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import {describe, expect, it, jest} from '@jest/globals'
import {render, screen} from '@testing-library/react'
import {type SanityClient, type Workspace} from 'sanity'

import {createMockSanityClient} from '../../../../test/mocks/mockSanityClient'
import {createTestProvider} from '../../../../test/testUtils/TestProvider'
import {WorkspaceRouterProvider} from './WorkspaceRouterProvider'

jest.mock('../router/RouterHistoryContext', () => ({
useRouterHistory: () => ({
location: {pathname: '/'},
listen: jest.fn(),
}),
}))

jest.mock('../router', () => ({
createRouter: () => ({
getBasePath: jest.fn(),
decode: jest.fn(),
isNotFound: jest.fn(),
}),
}))

jest.mock('sanity/router', () => ({
RouterProvider: ({children}: {children: React.ReactNode}) => <div>{children}</div>,
IntentLink: () => <div>IntentLink</div>,
}))

jest.mock('./WorkspaceRouterProvider', () => ({
...(jest.requireActual('./WorkspaceRouterProvider') as object),
useRouterFromWorkspaceHistory: jest.fn(),
}))

describe('WorkspaceRouterProvider', () => {
const LoadingComponent = () => <div>Loading...</div>
const children = <div>Children</div>
const workspace = {
basePath: '',
tools: [],
icon: null,
unstable_sources: [],
scheduledPublishing: false,
document: {},
form: {},
search: {},
title: 'Default Workspace',
name: 'default',
projectId: 'test',
dataset: 'test',
schema: {},
templates: {},
currentUser: {},
authenticated: true,
auth: {},
getClient: jest.fn(),
i18n: {},
__internal: {},
type: 'workspace',
// Add other required properties with appropriate default values
} as unknown as Workspace

it('renders children when state is not null', () => {
render(
<WorkspaceRouterProvider LoadingComponent={LoadingComponent} workspace={workspace}>
{children}
</WorkspaceRouterProvider>,
)

expect(screen.getByText('Children')).toBeInTheDocument()
})

it('calls onUncaughtError when an error is caught', async () => {
const onUncaughtError = jest.fn()

const ThrowErrorComponent = () => {
throw new Error('An EXPECTED, testing error occurred!')
}

const client = createMockSanityClient() as unknown as SanityClient

const TestProvider = await createTestProvider({
client,
config: {
name: 'default',
projectId: 'test',
dataset: 'test',
onUncaughtError,
},
})

try {
render(
<TestProvider>
{/* prevents thrown error from breaking the test */}
<WorkspaceRouterProvider LoadingComponent={LoadingComponent} workspace={workspace}>
<ThrowErrorComponent />
</WorkspaceRouterProvider>
</TestProvider>,
)
} catch {
expect(onUncaughtError).toHaveBeenCalledTimes(1)
}
})
})
Loading

0 comments on commit e3cf177

Please sign in to comment.