Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(sanity): add config for onUncaughtError #7553

Merged
merged 22 commits into from
Oct 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5b8d48e
feat(sanity): add config for onStudioError
RitaDias Sep 27, 2024
717804d
feat(sanity): add call to config from WorkspaceRouterProvider
RitaDias Sep 27, 2024
16dcca9
feat(sanity): add call on FormBuilderInput, add ability to have confi…
RitaDias Sep 27, 2024
bf266dd
feat(sanity): add call to config from Structure
RitaDias Sep 30, 2024
ec741a5
refactor(sanity): remove throw errors
RitaDias Sep 30, 2024
f36035c
chore(sanity): update import on formBuilderInputErrorBoundary
RitaDias Sep 30, 2024
7a5b89c
chore(sanity): update import on WorkspaceRouterProvider
RitaDias Sep 30, 2024
9eefd07
test(sanity): add test to formBuilderInputErrorBoundary
RitaDias Oct 1, 2024
9fd24f4
test(sanity): add test to WorkspaceRouterProvider
RitaDias Oct 1, 2024
c385b90
refactor(router): add ErrorBoundary component that should be used ins…
RitaDias Oct 3, 2024
7900480
refactor(ui-componets): replace useSource with useContext(SourceContext)
RitaDias Oct 3, 2024
0773660
test(sanity): update tests after refactor
RitaDias Oct 3, 2024
7e5d861
refactor(sanity): remove unneeded export
RitaDias Oct 3, 2024
6545c65
test(ui-components): add test provider
RitaDias Oct 3, 2024
7926fd3
test(ui-components): remove unused code
RitaDias Oct 3, 2024
f3459d7
refactor(sanity): update ErrorBoundary to use ui-components
RitaDias Oct 3, 2024
77aebff
refactor(sanity): onStudioErrorResolver to allow for error handling f…
RitaDias Oct 3, 2024
a4669c5
chore(sanity): add ErrorBoundary to linting overrides
RitaDias Oct 4, 2024
ef0a1ce
refactor(sanity): update ErrorBoundary calls and update test to not b…
RitaDias Oct 4, 2024
c186d7c
fix(sanity): update the throw to not remove the stack trace
RitaDias Oct 7, 2024
f498039
refactor(sanity): rename onStudioError to onUncaughtError
RitaDias Oct 7, 2024
3b3dbfe
chore(sanity): fix linting issue
RitaDias Oct 7, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading