Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
114 changes: 112 additions & 2 deletions packages/react/src/DefaultProps.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { act, render, screen } from '@testing-library/react'
import { createElement, useContext } from 'react'
import { DelayDefaultPropsContext, SuspenseDefaultPropsContext } from './contexts/DefaultPropsContexts'
import {
DelayDefaultPropsContext,
SuspenseDefaultPropsContext,
ErrorBoundaryDefaultPropsContext,
} from './contexts/DefaultPropsContexts'
import { DefaultProps, DefaultPropsProvider } from './DefaultProps'
import { Delay, type DelayProps } from './Delay'
import { ErrorBoundary, type ErrorBoundaryProps } from './ErrorBoundary'
import { Message_DefaultProp_delay_ms_should_be_greater_than_0, SuspensiveError } from './models/SuspensiveError'
import { Suspense, type SuspenseProps } from './Suspense'
import { CustomError, FALLBACK, Suspend, TEXT } from './test-utils'
import { CustomError, FALLBACK, Suspend, TEXT, Throw } from './test-utils'

const FALLBACK_GLOBAL = 'FALLBACK_GLOBAL'

Expand Down Expand Up @@ -178,4 +183,109 @@ describe('<DefaultPropsProvider/>', () => {

expect(ms).toBe(defaultPropsMs)
})

it('should accept defaultProps.ErrorBoundary.onError to setup default onError callback of ErrorBoundary', async () => {
const globalOnError = vi.fn()
const ERROR_MESSAGE = 'test error'

render(
<DefaultPropsProvider defaultProps={new DefaultProps({ ErrorBoundary: { onError: globalOnError } })}>
<ErrorBoundary fallback={<div>Error fallback</div>}>
<Throw.Error message={ERROR_MESSAGE} after={100}>
{TEXT}
</Throw.Error>
</ErrorBoundary>
</DefaultPropsProvider>
)

expect(screen.queryByText(TEXT)).toBeInTheDocument()
expect(globalOnError).toHaveBeenCalledTimes(0)

await act(() => vi.advanceTimersByTime(100))

expect(screen.queryByText('Error fallback')).toBeInTheDocument()
expect(screen.queryByText(TEXT)).not.toBeInTheDocument()
expect(globalOnError).toHaveBeenCalledTimes(1)
expect(globalOnError).toHaveBeenCalledWith(expect.any(Error), expect.any(Object))
})

it('should use local onError over default onError when both are provided', async () => {
const globalOnError = vi.fn()
const localOnError = vi.fn()
const ERROR_MESSAGE = 'test error'

render(
<DefaultPropsProvider defaultProps={new DefaultProps({ ErrorBoundary: { onError: globalOnError } })}>
<ErrorBoundary fallback={<div>Error fallback</div>} onError={localOnError}>
<Throw.Error message={ERROR_MESSAGE} after={100}>
{TEXT}
</Throw.Error>
</ErrorBoundary>
</DefaultPropsProvider>
)

expect(screen.queryByText(TEXT)).toBeInTheDocument()
expect(globalOnError).toHaveBeenCalledTimes(0)
expect(localOnError).toHaveBeenCalledTimes(0)

await act(() => vi.advanceTimersByTime(100))

expect(screen.queryByText('Error fallback')).toBeInTheDocument()
expect(screen.queryByText(TEXT)).not.toBeInTheDocument()
expect(localOnError).toHaveBeenCalledTimes(1)
expect(globalOnError).toHaveBeenCalledTimes(0)
})

it('should accept defaultProps.ErrorBoundary.onReset to setup default onReset callback of ErrorBoundary', async () => {
const globalOnReset = vi.fn()
const ERROR_MESSAGE = 'test error'

const { rerender } = render(
<DefaultPropsProvider defaultProps={new DefaultProps({ ErrorBoundary: { onReset: globalOnReset } })}>
<ErrorBoundary resetKeys={[0]} fallback={(props) => <div>Error: {props.error.message}</div>}>
<Throw.Error message={ERROR_MESSAGE} after={100}>
{TEXT}
</Throw.Error>
</ErrorBoundary>
</DefaultPropsProvider>
)

expect(globalOnReset).toHaveBeenCalledTimes(0)
expect(screen.queryByText(TEXT)).toBeInTheDocument()

await act(() => vi.advanceTimersByTime(100))

expect(screen.queryByText(`Error: ${ERROR_MESSAGE}`)).toBeInTheDocument()
expect(screen.queryByText(TEXT)).not.toBeInTheDocument()
expect(globalOnReset).toHaveBeenCalledTimes(0)

rerender(
<DefaultPropsProvider defaultProps={new DefaultProps({ ErrorBoundary: { onReset: globalOnReset } })}>
<ErrorBoundary resetKeys={[1]} fallback={(props) => <div>Error: {props.error.message}</div>}>
<Throw.Error message={ERROR_MESSAGE} after={100}>
{TEXT}
</Throw.Error>
</ErrorBoundary>
</DefaultPropsProvider>
)

expect(globalOnReset).toHaveBeenCalledTimes(1)
expect(screen.queryByText(TEXT)).toBeInTheDocument()
})

it('should provide access to ErrorBoundaryDefaultPropsContext', () => {
const testOnError = vi.fn()
let contextValue: ErrorBoundaryProps

render(
<DefaultPropsProvider defaultProps={new DefaultProps({ ErrorBoundary: { onError: testOnError } })}>
{createElement(() => {
contextValue = useContext(ErrorBoundaryDefaultPropsContext)
return <></>
})}
</DefaultPropsProvider>
)

expect(contextValue!.onError).toBe(testOnError)
})
})
20 changes: 17 additions & 3 deletions packages/react/src/DefaultProps.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { type ContextType, type PropsWithChildren } from 'react'
import { DelayDefaultPropsContext, SuspenseDefaultPropsContext } from './contexts/DefaultPropsContexts'
import {
DelayDefaultPropsContext,
SuspenseDefaultPropsContext,
ErrorBoundaryDefaultPropsContext,
} from './contexts/DefaultPropsContexts'
import { Message_DefaultProp_delay_ms_should_be_greater_than_0, SuspensiveError } from './models/SuspensiveError'

/**
Expand All @@ -16,19 +20,24 @@ import { Message_DefaultProp_delay_ms_should_be_greater_than_0, SuspensiveError
* fallback: <Spinner>Fetching data...</Spinner>,
* clientOnly: false,
* },
* ErrorBoundary: {
* onError: (error, info) => console.error('Global error handler:', error),
* },
* })
* ```
*/
export class DefaultProps {
Suspense?: ContextType<typeof SuspenseDefaultPropsContext>
Delay?: ContextType<typeof DelayDefaultPropsContext>
ErrorBoundary?: ContextType<typeof ErrorBoundaryDefaultPropsContext>

constructor(defaultProps: DefaultProps = {}) {
if (process.env.NODE_ENV === 'development' && typeof defaultProps.Delay?.ms === 'number') {
SuspensiveError.assert(defaultProps.Delay.ms > 0, Message_DefaultProp_delay_ms_should_be_greater_than_0)
}
this.Suspense = defaultProps.Suspense
this.Delay = defaultProps.Delay
this.ErrorBoundary = defaultProps.ErrorBoundary
}
}

Expand All @@ -38,7 +47,7 @@ interface DefaultPropsProviderProps extends PropsWithChildren {

/**
* A provider component that controls the default settings of Suspensive components.
* Use this to configure default props for Suspense, Delay, and other Suspensive components globally.
* Use this to configure default props for Suspense, Delay, and ErrorBoundary components globally.
*
* @example
* ```tsx
Expand All @@ -51,6 +60,9 @@ interface DefaultPropsProviderProps extends PropsWithChildren {
* fallback: <Skeleton />,
* clientOnly: false,
* },
* ErrorBoundary: {
* onError: (error, info) => console.error('Global error:', error),
* },
* })
*
* function App() {
Expand All @@ -67,7 +79,9 @@ interface DefaultPropsProviderProps extends PropsWithChildren {
export const DefaultPropsProvider = ({ defaultProps, children }: DefaultPropsProviderProps) => (
<DelayDefaultPropsContext.Provider value={defaultProps.Delay ?? {}}>
<SuspenseDefaultPropsContext.Provider value={defaultProps.Suspense ?? {}}>
{children}
<ErrorBoundaryDefaultPropsContext.Provider value={defaultProps.ErrorBoundary ?? {}}>
{children}
</ErrorBoundaryDefaultPropsContext.Provider>
</SuspenseDefaultPropsContext.Provider>
</DelayDefaultPropsContext.Provider>
)
12 changes: 7 additions & 5 deletions packages/react/src/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
useRef,
useState,
} from 'react'
import { ErrorBoundaryDefaultPropsContext } from './contexts/DefaultPropsContexts'
import { ErrorBoundaryGroupContext } from './ErrorBoundaryGroup'
import {
Message_useErrorBoundaryFallbackProps_this_hook_should_be_called_in_ErrorBoundary_props_fallback,
Expand Down Expand Up @@ -169,19 +170,20 @@ class FallbackBoundary extends Component<{ children: ReactNode }> {
export const ErrorBoundary = Object.assign(
forwardRef<{ reset: () => void }, ErrorBoundaryProps>(
({ fallback, children, onError, onReset, resetKeys, shouldCatch }, ref) => {
const group = useContext(ErrorBoundaryGroupContext) ?? { resetKey: 0 }
const group = useContext(ErrorBoundaryGroupContext)
const defaultProps = useContext(ErrorBoundaryDefaultPropsContext)
const baseErrorBoundaryRef = useRef<BaseErrorBoundary>(null)
useImperativeHandle(ref, () => ({
reset: () => baseErrorBoundaryRef.current?.reset(),
}))

return (
<BaseErrorBoundary
shouldCatch={shouldCatch}
shouldCatch={shouldCatch ?? defaultProps.shouldCatch}
fallback={fallback}
onError={onError}
onReset={onReset}
resetKeys={[group.resetKey, ...(resetKeys || [])]}
onError={onError ?? defaultProps.onError}
onReset={onReset ?? defaultProps.onReset}
resetKeys={[group?.resetKey ?? 0, ...(resetKeys || defaultProps.resetKeys || [])]}
ref={baseErrorBoundaryRef}
>
{children}
Expand Down
10 changes: 9 additions & 1 deletion packages/react/src/contexts/DefaultPropsContexts.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createContext } from 'react'
import type { DelayProps, SuspenseProps } from '..'
import type { DelayProps, SuspenseProps, ErrorBoundaryProps } from '..'
import type { OmitKeyof } from '../utility-types/OmitKeyof'

export const DelayDefaultPropsContext = createContext<OmitKeyof<DelayProps, 'children'>>({
Expand All @@ -13,3 +13,11 @@ export const SuspenseDefaultPropsContext = createContext<OmitKeyof<SuspenseProps
clientOnly: undefined,
})
SuspenseDefaultPropsContext.displayName = 'SuspenseDefaultPropsContext'

export const ErrorBoundaryDefaultPropsContext = createContext<OmitKeyof<ErrorBoundaryProps, 'children' | 'fallback'>>({
resetKeys: undefined,
onReset: undefined,
onError: undefined,
shouldCatch: undefined,
})
ErrorBoundaryDefaultPropsContext.displayName = 'ErrorBoundaryDefaultPropsContext'
Loading