diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 25c7a3d66f0..52785bb452e 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -192,6 +192,7 @@ const config = { 'ButtonProps', 'Dialog', 'DialogProps', + 'ErrorBoundary', 'MenuButton', 'MenuButtonProps', 'MenuGroup', diff --git a/dev/test-studio/sanity.config.ts b/dev/test-studio/sanity.config.ts index 43eefde3dc4..f2b4bf85270 100644 --- a/dev/test-studio/sanity.config.ts +++ b/dev/test-studio/sanity.config.ts @@ -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 @@ -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, diff --git a/packages/sanity/src/core/config/configPropertyReducers.ts b/packages/sanity/src/core/config/configPropertyReducers.ts index 0c93ce5cc1c..c2b6a66205a 100644 --- a/packages/sanity/src/core/config/configPropertyReducers.ts +++ b/packages/sanity/src/core/config/configPropertyReducers.ts @@ -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' @@ -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 => { diff --git a/packages/sanity/src/core/config/prepareConfig.ts b/packages/sanity/src/core/config/prepareConfig.ts index f90070cbbf6..054250acc5b 100644 --- a/packages/sanity/src/core/config/prepareConfig.ts +++ b/packages/sanity/src/core/config/prepareConfig.ts @@ -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' @@ -32,6 +38,7 @@ import { internalTasksReducer, legacySearchEnabledReducer, newDocumentOptionsResolver, + onUncaughtErrorResolver, partialIndexingEnabledReducer, resolveProductionUrlReducer, schemaTemplatesReducer, @@ -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. diff --git a/packages/sanity/src/core/config/types.ts b/packages/sanity/src/core/config/types.ts index 6f223b42e60..f8ef6d8ffdf 100644 --- a/packages/sanity/src/core/config/types.ts +++ b/packages/sanity/src/core/config/types.ts @@ -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' @@ -392,6 +392,10 @@ export interface PluginOptions { * @internal */ beta?: BetaFeatures + /** Configuration for error handling. + * @beta + */ + onUncaughtError?: (error: Error, errorInfo: ErrorInfo) => void } /** @internal */ @@ -781,6 +785,10 @@ export interface Source { * @internal */ beta?: BetaFeatures + /** Configuration for error handling. + * @internal + */ + onUncaughtError?: (error: Error, errorInfo: ErrorInfo) => void } /** @internal */ diff --git a/packages/sanity/src/core/form/studio/FormBuilderInputErrorBoundary.test.tsx b/packages/sanity/src/core/form/studio/FormBuilderInputErrorBoundary.test.tsx new file mode 100644 index 00000000000..297b489ac0e --- /dev/null +++ b/packages/sanity/src/core/form/studio/FormBuilderInputErrorBoundary.test.tsx @@ -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( + +
Child Component
+
, + ) + + 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( + + + + + , + ) + + expect(onUncaughtError).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/sanity/src/core/form/studio/FormBuilderInputErrorBoundary.tsx b/packages/sanity/src/core/form/studio/FormBuilderInputErrorBoundary.tsx index 8bf4b795a90..17caafefdb0 100644 --- a/packages/sanity/src/core/form/studio/FormBuilderInputErrorBoundary.tsx +++ b/packages/sanity/src/core/form/studio/FormBuilderInputErrorBoundary.tsx @@ -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' diff --git a/packages/sanity/src/core/studio/StudioErrorBoundary.tsx b/packages/sanity/src/core/studio/StudioErrorBoundary.tsx index 32b0de92a00..d1e87257fc3 100644 --- a/packages/sanity/src/core/studio/StudioErrorBoundary.tsx +++ b/packages/sanity/src/core/studio/StudioErrorBoundary.tsx @@ -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, @@ -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' diff --git a/packages/sanity/src/core/studio/components/navbar/search/components/filters/filter/FilterForm.tsx b/packages/sanity/src/core/studio/components/navbar/search/components/filters/filter/FilterForm.tsx index 237fc7d500f..005603a0e90 100644 --- a/packages/sanity/src/core/studio/components/navbar/search/components/filters/filter/FilterForm.tsx +++ b/packages/sanity/src/core/studio/components/navbar/search/components/filters/filter/FilterForm.tsx @@ -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' diff --git a/packages/sanity/src/core/studio/workspaceLoader/WorkspaceLoader.tsx b/packages/sanity/src/core/studio/workspaceLoader/WorkspaceLoader.tsx index b6186b1368f..3899eba68a3 100644 --- a/packages/sanity/src/core/studio/workspaceLoader/WorkspaceLoader.tsx +++ b/packages/sanity/src/core/studio/workspaceLoader/WorkspaceLoader.tsx @@ -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, diff --git a/packages/sanity/src/core/studio/workspaceLoader/WorkspaceRouterProvider.test.tsx b/packages/sanity/src/core/studio/workspaceLoader/WorkspaceRouterProvider.test.tsx new file mode 100644 index 00000000000..14044f080e0 --- /dev/null +++ b/packages/sanity/src/core/studio/workspaceLoader/WorkspaceRouterProvider.test.tsx @@ -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}) =>
{children}
, + IntentLink: () =>
IntentLink
, +})) + +jest.mock('./WorkspaceRouterProvider', () => ({ + ...(jest.requireActual('./WorkspaceRouterProvider') as object), + useRouterFromWorkspaceHistory: jest.fn(), +})) + +describe('WorkspaceRouterProvider', () => { + const LoadingComponent = () =>
Loading...
+ const children =
Children
+ 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( + + {children} + , + ) + + 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( + + {/* prevents thrown error from breaking the test */} + + + + , + ) + } catch { + expect(onUncaughtError).toHaveBeenCalledTimes(1) + } + }) +}) diff --git a/packages/sanity/src/core/studio/workspaceLoader/WorkspaceRouterProvider.tsx b/packages/sanity/src/core/studio/workspaceLoader/WorkspaceRouterProvider.tsx index eeaeb2f99c2..7032a02d867 100644 --- a/packages/sanity/src/core/studio/workspaceLoader/WorkspaceRouterProvider.tsx +++ b/packages/sanity/src/core/studio/workspaceLoader/WorkspaceRouterProvider.tsx @@ -3,6 +3,7 @@ import { type ComponentType, type MutableRefObject, type ReactNode, + useCallback, useEffect, useMemo, useRef, @@ -10,6 +11,7 @@ import { import {type Router, RouterProvider, type RouterState} from 'sanity/router' import {useSyncExternalStoreWithSelector} from 'use-sync-external-store/with-selector.js' +import {ErrorBoundary} from '../../../ui-components' import {type Tool, type Workspace} from '../../config' import {createRouter, type RouterHistory, type RouterStateEvent} from '../router' import {decodeUrlState, resolveDefaultState, resolveIntentState} from '../router/helpers' @@ -31,14 +33,21 @@ export function WorkspaceRouterProvider({ const router = useMemo(() => createRouter({basePath, tools}), [basePath, tools]) const [state, onNavigate] = useRouterFromWorkspaceHistory(history, router, tools) + const handleCatchError = useCallback(({error}: {error: Error}) => { + /** catches errors in studio that bubble up, throwing the error */ + throw error + }, []) + // `state` is only null if the Studio is somehow rendering in SSR or using hydrateRoot in combination with `unstable_noAuthBoundary`. // Which makes this loading condition extremely rare, most of the time it'll render `RouteProvider` right away. if (!state) return return ( - - {children} - + + + {children} + + ) } diff --git a/packages/sanity/src/structure/components/confirmDeleteDialog/index.tsx b/packages/sanity/src/structure/components/confirmDeleteDialog/index.tsx index 9d4a76510aa..ddc95ef0ee1 100644 --- a/packages/sanity/src/structure/components/confirmDeleteDialog/index.tsx +++ b/packages/sanity/src/structure/components/confirmDeleteDialog/index.tsx @@ -1,8 +1,8 @@ -import {Box, ErrorBoundary, Text} from '@sanity/ui' +import {Box, Text} from '@sanity/ui' import {type ComponentProps, useCallback, useId, useState} from 'react' import {useTranslation} from 'sanity' -import {Dialog} from '../../../ui-components' +import {Dialog, ErrorBoundary} from '../../../ui-components' import {structureLocaleNamespace} from '../../i18n' import {ConfirmDeleteDialog, type ConfirmDeleteDialogProps} from './ConfirmDeleteDialog' diff --git a/packages/sanity/src/structure/components/structureTool/StructureToolBoundary.tsx b/packages/sanity/src/structure/components/structureTool/StructureToolBoundary.tsx index a9fbb7a3063..6eeff905e35 100644 --- a/packages/sanity/src/structure/components/structureTool/StructureToolBoundary.tsx +++ b/packages/sanity/src/structure/components/structureTool/StructureToolBoundary.tsx @@ -1,7 +1,7 @@ -import {ErrorBoundary} from '@sanity/ui' import {useEffect, useState} from 'react' import {SourceProvider, type Tool, useWorkspace} from 'sanity' +import {ErrorBoundary} from '../../../ui-components/errorBoundary' import {setActivePanes} from '../../getIntentState' import {StructureToolProvider} from '../../StructureToolProvider' import {type StructureToolOptions} from '../../types' @@ -25,6 +25,7 @@ export function StructureToolBoundary({tool: {options}}: StructureToolBoundaryPr }, []) const [{error}, setError] = useState<{error: unknown}>({error: null}) + // this re-throws if the error it catches is not a PaneResolutionError if (error) return diff --git a/packages/sanity/src/structure/panes/document/inspectors/validation/ValidationInspector.tsx b/packages/sanity/src/structure/panes/document/inspectors/validation/ValidationInspector.tsx index b3d7c94e582..535d74b3e62 100644 --- a/packages/sanity/src/structure/panes/document/inspectors/validation/ValidationInspector.tsx +++ b/packages/sanity/src/structure/panes/document/inspectors/validation/ValidationInspector.tsx @@ -11,10 +11,11 @@ import { type SchemaType, type ValidationMarker, } from '@sanity/types' -import {Box, Card, type CardTone, ErrorBoundary, Flex, Stack, Text} from '@sanity/ui' +import {Box, Card, type CardTone, Flex, Stack, Text} from '@sanity/ui' import {createElement, type ErrorInfo, Fragment, useCallback, useMemo, useState} from 'react' import {type DocumentInspectorProps, useTranslation} from 'sanity' +import {ErrorBoundary} from '../../../../../ui-components' import {DocumentInspectorHeader} from '../../documentInspector' import {useDocumentPane} from '../../useDocumentPane' import {getPathTitles} from './getPathTitles' diff --git a/packages/sanity/src/ui-components/errorBoundary/ErrorBoundary.tsx b/packages/sanity/src/ui-components/errorBoundary/ErrorBoundary.tsx new file mode 100644 index 00000000000..09c0583f66f --- /dev/null +++ b/packages/sanity/src/ui-components/errorBoundary/ErrorBoundary.tsx @@ -0,0 +1,32 @@ +import { + // eslint-disable-next-line no-restricted-imports + ErrorBoundary as UIErrorBoundary, + type ErrorBoundaryProps as UIErrorBoundaryProps, +} from '@sanity/ui' +import {useCallback, useContext} from 'react' + +import {SourceContext} from '../../_singletons' + +export type ErrorBoundaryProps = UIErrorBoundaryProps + +/** + * ErrorBoundary component that catches errors and uses onUncaughtError config property + * It also calls the onCatch prop if it exists. + */ +export function ErrorBoundary({onCatch, ...rest}: ErrorBoundaryProps): JSX.Element { + // Use context, because source could be undefined and we don't want to throw in that case + const source = useContext(SourceContext) + + const handleCatch = useCallback( + ({error: caughtError, info: caughtInfo}: {error: Error; info: React.ErrorInfo}) => { + // Send the error to the source if it has an onUncaughtError method + source?.onUncaughtError?.(caughtError, caughtInfo) + + // Call the onCatch prop if it exists + onCatch?.({error: caughtError, info: caughtInfo}) + }, + [source, onCatch], + ) + + return +} diff --git a/packages/sanity/src/ui-components/errorBoundary/__test__/ErrorBoundary.test.tsx b/packages/sanity/src/ui-components/errorBoundary/__test__/ErrorBoundary.test.tsx new file mode 100644 index 00000000000..988d0a43bb3 --- /dev/null +++ b/packages/sanity/src/ui-components/errorBoundary/__test__/ErrorBoundary.test.tsx @@ -0,0 +1,87 @@ +import {beforeAll, describe, expect, it, jest} from '@jest/globals' +import {studioTheme, ThemeProvider} from '@sanity/ui' +import {render} from '@testing-library/react' +import {type SanityClient} from 'sanity' + +import {createMockSanityClient} from '../../../../test/mocks/mockSanityClient' +import {createTestProvider} from '../../../../test/testUtils/TestProvider' +import {LocaleProviderBase, usEnglishLocale} from '../../../core/i18n' +import {prepareI18n} from '../../../core/i18n/i18nConfig' +import {ErrorBoundary} from '../ErrorBoundary' + +describe('ErrorBoundary', () => { + beforeAll(() => { + jest.clearAllMocks() + }) + + it('calls onUncaughtError when an error is caught', async () => { + const onUncaughtError = jest.fn() + const onCatch = 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( + + + + + , + ) + + expect(onUncaughtError).toHaveBeenCalledTimes(1) + }) + + it('calls onCatch prop when an error is caught when no onUncaughtError exists', () => { + const onCatch = jest.fn() + + const WrapperWithoutError = ({children}: {children: React.ReactNode}) => { + const locales = [usEnglishLocale] + const {i18next} = prepareI18n({ + projectId: 'test', + dataset: 'test', + name: 'test', + }) + + return ( + + + {children} + + + ) + } + + const ThrowErrorComponent = () => { + throw new Error('An EXPECTED, testing error occurred!') + } + + render( + + + + + , + ) + + expect(onCatch).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/sanity/src/ui-components/errorBoundary/index.ts b/packages/sanity/src/ui-components/errorBoundary/index.ts new file mode 100644 index 00000000000..2bca71da107 --- /dev/null +++ b/packages/sanity/src/ui-components/errorBoundary/index.ts @@ -0,0 +1 @@ +export * from './ErrorBoundary' diff --git a/packages/sanity/src/ui-components/index.ts b/packages/sanity/src/ui-components/index.ts index 8e1eeedabc2..a0d7cbccdd9 100644 --- a/packages/sanity/src/ui-components/index.ts +++ b/packages/sanity/src/ui-components/index.ts @@ -1,6 +1,7 @@ export * from './button' export * from './conditionalWrapper' export * from './dialog' +export * from './errorBoundary' export * from './menuButton' export * from './menuGroup' export * from './menuItem'