Skip to content

Commit

Permalink
fix(react): new internal api SuspensiveError (toss#615)
Browse files Browse the repository at this point in the history
fix toss#598 

new internal api: `SuspensiveError` to assert each hooks position for
ErrorBoundary, ErrorBoundaryGroup
SuspensiveError shouldn't be caught by ErrorBoundary. so I updated
implementation of `<ErrorBoundary/>`

## PR Checklist

- [x] I did below actions if need

1. I read the [Contributing
Guide](https://github.com/suspensive/react/blob/main/CONTRIBUTING.md)
2. I added documents and tests.
  • Loading branch information
manudeli authored Feb 1, 2024
1 parent 0b8be45 commit 43bd74e
Show file tree
Hide file tree
Showing 16 changed files with 178 additions and 92 deletions.
5 changes: 5 additions & 0 deletions .changeset/gorgeous-beans-sniff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@suspensive/react': minor
---

feat(react): add SuspensiveError for useErrorBoundary, useErrorBoundaryFallbackProps, useErrorBoundaryGroup internally
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,3 @@ esm

# graph
graph/

15 changes: 11 additions & 4 deletions packages/react/src/Delay.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { TEXT } from '@suspensive/test-utils'
import { CustomError, TEXT } from '@suspensive/test-utils'
import { render, screen, waitFor } from '@testing-library/react'
import ms from 'ms'
import { describe, expect, it } from 'vitest'
import { Delay } from './Delay'
import { DelayMsPropShouldBeGreaterThanOrEqualTo0 } from './utils/assert'
import { Delay_ms_prop_should_be_greater_than_or_equal_to_0, SuspensiveError } from './models/SuspensiveError'

describe('<Delay/>', () => {
it('should render the children after the delay', async () => {
Expand All @@ -20,7 +20,14 @@ describe('<Delay/>', () => {
render(<Delay ms={0}>{TEXT}</Delay>)
await waitFor(() => expect(screen.queryByText(TEXT)).toBeInTheDocument(), { timeout: 1000 })
})
it('should throw error if negative number is passed as ms prop', () => {
expect(() => render(<Delay ms={-1}>{TEXT}</Delay>)).toThrow(DelayMsPropShouldBeGreaterThanOrEqualTo0)
it('should throw SuspensiveError if negative number is passed as ms prop', () => {
expect(() => render(<Delay ms={-1}>{TEXT}</Delay>)).toThrow(Delay_ms_prop_should_be_greater_than_or_equal_to_0)
try {
render(<Delay ms={-1}>{TEXT}</Delay>)
} catch (error) {
expect(error).toBeInstanceOf(SuspensiveError)
expect(error).toBeInstanceOf(Error)
expect(error).not.toBeInstanceOf(CustomError)
}
})
})
5 changes: 2 additions & 3 deletions packages/react/src/Delay.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { type PropsWithChildren, type ReactNode, useContext, useState } from 'react'
import { DelayDefaultPropsContext } from './contexts'
import { useTimeout } from './hooks'
import { assert } from './utils'
import { DelayMsPropShouldBeGreaterThanOrEqualTo0 } from './utils/assert'
import { Delay_ms_prop_should_be_greater_than_or_equal_to_0, SuspensiveError } from './models/SuspensiveError'

export interface DelayProps extends PropsWithChildren {
ms?: number
Expand All @@ -15,7 +14,7 @@ export interface DelayProps extends PropsWithChildren {
export const Delay = (props: DelayProps) => {
if (process.env.NODE_ENV === 'development') {
if (typeof props.ms === 'number') {
assert(props.ms >= 0, DelayMsPropShouldBeGreaterThanOrEqualTo0)
SuspensiveError.assert(props.ms >= 0, Delay_ms_prop_should_be_greater_than_or_equal_to_0)
}
}
const defaultProps = useContext(DelayDefaultPropsContext)
Expand Down
23 changes: 10 additions & 13 deletions packages/react/src/ErrorBoundary.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,7 @@ import {
useErrorBoundaryFallbackProps,
} from './ErrorBoundary'
import { useTimeout } from './hooks'
import {
useErrorBoundaryFallbackProps_this_hook_should_be_called_in_ErrorBoundary_props_fallback,
useErrorBoundary_this_hook_should_be_called_in_ErrorBoundary_props_children,
} from './utils/assert'
import { SuspensiveError } from './models/SuspensiveError'

describe('<ErrorBoundary/>', () => {
beforeEach(() => ThrowError.reset())
Expand Down Expand Up @@ -314,7 +311,7 @@ describe('useErrorBoundary', () => {
<ThrowError message={ERROR_MESSAGE} after={0} />
</ErrorBoundary>
)
).toThrow(useErrorBoundary_this_hook_should_be_called_in_ErrorBoundary_props_children)
).toThrow(SuspensiveError)
})
})

Expand Down Expand Up @@ -352,16 +349,16 @@ describe('useErrorBoundaryFallbackProps', () => {
})

it('should guarantee hook calling position is in fallback of ErrorBoundary', () => {
expect(
expect(() =>
render(
<ErrorBoundary fallback={(props) => <>{props.error.message}</>}>
{createElement(() => {
useErrorBoundaryFallbackProps()
return <></>
})}
</ErrorBoundary>
).getByText(useErrorBoundaryFallbackProps_this_hook_should_be_called_in_ErrorBoundary_props_fallback)
).toBeInTheDocument()
)
).toThrow(SuspensiveError)
})
it('should be prevented to be called outside fallback of ErrorBoundary', () => {
expect(() =>
Expand All @@ -371,18 +368,18 @@ describe('useErrorBoundaryFallbackProps', () => {
return <></>
})
)
).toThrow(useErrorBoundaryFallbackProps_this_hook_should_be_called_in_ErrorBoundary_props_fallback)
).toThrow(SuspensiveError)
})
it('should be prevented to be called in children of ErrorBoundary', () => {
expect(
it("should be prevented to be called in children of ErrorBoundary (ErrorBoundary shouldn't catch SuspensiveError)", () => {
expect(() =>
render(
<ErrorBoundary fallback={(props) => <>{props.error.message}</>}>
{createElement(() => {
useErrorBoundaryFallbackProps()
return <></>
})}
</ErrorBoundary>
).getByText(useErrorBoundaryFallbackProps_this_hook_should_be_called_in_ErrorBoundary_props_fallback)
).toBeInTheDocument()
)
).toThrow(SuspensiveError)
})
})
29 changes: 17 additions & 12 deletions packages/react/src/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@ import {
import { syncDevMode } from './contexts'
import { Delay } from './Delay'
import { ErrorBoundaryGroupContext } from './ErrorBoundaryGroup'
import type { ConstructorType, PropsWithDevMode } from './utility-types'
import { assert, hasResetKeysChanged } from './utils'
import {
SuspensiveError,
useErrorBoundaryFallbackProps_this_hook_should_be_called_in_ErrorBoundary_props_fallback,
useErrorBoundary_this_hook_should_be_called_in_ErrorBoundary_props_children,
} from './utils/assert'
} from './models/SuspensiveError'
import type { ConstructorType, PropsWithDevMode } from './utility-types'
import { hasResetKeysChanged } from './utils'

export interface ErrorBoundaryFallbackProps<TError extends Error = Error> {
/**
Expand Down Expand Up @@ -96,6 +97,16 @@ class BaseErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState
}

componentDidCatch(error: Error, info: ErrorInfo) {
if (error instanceof SuspensiveError) {
throw error
}
const { shouldCatch = true } = this.props
const isCatch = Array.isArray(shouldCatch)
? shouldCatch.some((shouldCatch) => checkErrorBoundary(shouldCatch, error))
: checkErrorBoundary(shouldCatch, error)
if (!isCatch) {
throw error
}
this.props.onError?.(error, info)
}

Expand All @@ -105,18 +116,12 @@ class BaseErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState
}

render() {
const { children, fallback, shouldCatch = true } = this.props
const { children, fallback } = this.props
const { isError, error } = this.state

let childrenOrFallback = children

if (isError) {
const isCatch = Array.isArray(shouldCatch)
? shouldCatch.some((shouldCatch) => checkErrorBoundary(shouldCatch, error))
: checkErrorBoundary(shouldCatch, error)
if (!isCatch) {
throw error
}
if (typeof fallback === 'undefined') {
if (process.env.NODE_ENV === 'development') {
console.error('ErrorBoundary of @suspensive/react requires a defined fallback')
Expand Down Expand Up @@ -197,7 +202,7 @@ export const useErrorBoundary = <TError extends Error = Error>() => {
}

const errorBoundary = useContext(ErrorBoundaryContext)
assert(
SuspensiveError.assert(
errorBoundary != null && !errorBoundary.isError,
useErrorBoundary_this_hook_should_be_called_in_ErrorBoundary_props_children
)
Expand All @@ -212,7 +217,7 @@ export const useErrorBoundary = <TError extends Error = Error>() => {

export const useErrorBoundaryFallbackProps = <TError extends Error = Error>(): ErrorBoundaryFallbackProps<TError> => {
const errorBoundary = useContext(ErrorBoundaryContext)
assert(
SuspensiveError.assert(
errorBoundary != null && errorBoundary.isError,
useErrorBoundaryFallbackProps_this_hook_should_be_called_in_ErrorBoundary_props_fallback
)
Expand Down
19 changes: 17 additions & 2 deletions packages/react/src/ErrorBoundaryGroup.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { ERROR_MESSAGE, TEXT, ThrowError } from '@suspensive/test-utils'
import { CustomError, ERROR_MESSAGE, TEXT, ThrowError } from '@suspensive/test-utils'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import ms from 'ms'
import { createElement } from 'react'
import { describe, expect, it } from 'vitest'
import { ErrorBoundary } from './ErrorBoundary'
import { ErrorBoundaryGroup, useErrorBoundaryGroup } from './ErrorBoundaryGroup'
import { useErrorBoundaryGroup_this_hook_should_be_called_in_ErrorBoundary_props_children } from './utils/assert'
import {
SuspensiveError,
useErrorBoundaryGroup_this_hook_should_be_called_in_ErrorBoundary_props_children,
} from './models/SuspensiveError'

const innerErrorBoundaryCount = 3
const resetButtonText = 'reset button'
Expand Down Expand Up @@ -74,5 +77,17 @@ describe('useErrorBoundaryGroup', () => {
})
)
).toThrow(useErrorBoundaryGroup_this_hook_should_be_called_in_ErrorBoundary_props_children)
try {
render(
createElement(() => {
useErrorBoundaryGroup()
return <></>
})
)
} catch (error) {
expect(error).toBeInstanceOf(SuspensiveError)
expect(error).toBeInstanceOf(Error)
expect(error).not.toBeInstanceOf(CustomError)
}
})
})
12 changes: 9 additions & 3 deletions packages/react/src/ErrorBoundaryGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ import {
useReducer,
} from 'react'
import { useIsChanged } from './hooks'
import { assert, increase } from './utils'
import { useErrorBoundaryGroup_this_hook_should_be_called_in_ErrorBoundary_props_children } from './utils/assert'
import {
SuspensiveError,
useErrorBoundaryGroup_this_hook_should_be_called_in_ErrorBoundary_props_children,
} from './models/SuspensiveError'
import { increase } from './utils'

export const ErrorBoundaryGroupContext = createContext<{ reset: () => void; resetKey: number } | undefined>(undefined)
if (process.env.NODE_ENV === 'development') {
Expand Down Expand Up @@ -73,7 +76,10 @@ export const ErrorBoundaryGroup = Object.assign(

export const useErrorBoundaryGroup = () => {
const group = useContext(ErrorBoundaryGroupContext)
assert(group != null, useErrorBoundaryGroup_this_hook_should_be_called_in_ErrorBoundary_props_children)
SuspensiveError.assert(
group != null,
useErrorBoundaryGroup_this_hook_should_be_called_in_ErrorBoundary_props_children
)
return useMemo(
() => ({
/**
Expand Down
26 changes: 22 additions & 4 deletions packages/react/src/Suspensive.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { FALLBACK, Suspend, TEXT } from '@suspensive/test-utils'
import { CustomError, FALLBACK, Suspend, TEXT } from '@suspensive/test-utils'
import { render, screen, waitFor } from '@testing-library/react'
import ms from 'ms'
import { createElement, useContext } from 'react'
import { describe, expect, it } from 'vitest'
import { DelayDefaultPropsContext, SuspenseDefaultPropsContext } from './contexts'
import { Delay, type DelayProps } from './Delay'
import {
SuspensiveError,
Suspensive_config_defaultOptions_delay_ms_should_be_greater_than_0,
} from './models/SuspensiveError'
import { Suspense, type SuspenseProps } from './Suspense'
import { Suspensive, SuspensiveProvider } from './Suspensive'
import { SuspensiveConfigDefaultOptionsDelayMsShouldBeGreaterThan0 } from './utils/assert'

const FALLBACK_GLOBAL = 'FALLBACK_GLOBAL'

Expand Down Expand Up @@ -115,11 +118,26 @@ describe('<SuspensiveProvider/>', () => {

it('should accept defaultOptions.delay.ms only positive number', () => {
expect(() => new Suspensive({ defaultOptions: { delay: { ms: 0 } } })).toThrow(
SuspensiveConfigDefaultOptionsDelayMsShouldBeGreaterThan0
Suspensive_config_defaultOptions_delay_ms_should_be_greater_than_0
)
try {
new Suspensive({ defaultOptions: { delay: { ms: 0 } } })
} catch (error) {
expect(error).toBeInstanceOf(SuspensiveError)
expect(error).toBeInstanceOf(Error)
expect(error).not.toBeInstanceOf(CustomError)
}

expect(() => new Suspensive({ defaultOptions: { delay: { ms: -1 } } })).toThrow(
SuspensiveConfigDefaultOptionsDelayMsShouldBeGreaterThan0
Suspensive_config_defaultOptions_delay_ms_should_be_greater_than_0
)
try {
new Suspensive({ defaultOptions: { delay: { ms: -1 } } })
} catch (error) {
expect(error).toBeInstanceOf(SuspensiveError)
expect(error).toBeInstanceOf(Error)
expect(error).not.toBeInstanceOf(CustomError)
}

const defaultPropsMs = 100
let ms: DelayProps['ms'] = undefined
Expand Down
15 changes: 9 additions & 6 deletions packages/react/src/Suspensive.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { type ContextType, type PropsWithChildren, useMemo } from 'react'
import { DelayDefaultPropsContext, DevModeContext, SuspenseDefaultPropsContext, SuspensiveDevMode } from './contexts'
import { assert } from './utils'
import { SuspensiveConfigDefaultOptionsDelayMsShouldBeGreaterThan0 } from './utils/assert'
import {
SuspensiveError,
Suspensive_config_defaultOptions_delay_ms_should_be_greater_than_0,
} from './models/SuspensiveError'

export class Suspensive {
public defaultOptions?: {
Expand All @@ -11,10 +13,11 @@ export class Suspensive {
public devMode = new SuspensiveDevMode()

constructor(config: { defaultOptions?: Suspensive['defaultOptions'] } = {}) {
if (process.env.NODE_ENV === 'development') {
if (typeof config.defaultOptions?.delay?.ms === 'number') {
assert(config.defaultOptions.delay.ms > 0, SuspensiveConfigDefaultOptionsDelayMsShouldBeGreaterThan0)
}
if (process.env.NODE_ENV === 'development' && typeof config.defaultOptions?.delay?.ms === 'number') {
SuspensiveError.assert(
config.defaultOptions.delay.ms > 0,
Suspensive_config_defaultOptions_delay_ms_should_be_greater_than_0
)
}
this.defaultOptions = config.defaultOptions
}
Expand Down
30 changes: 30 additions & 0 deletions packages/react/src/models/SuspensiveError.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import { SuspensiveError } from './SuspensiveError'

describe('SuspensiveError.assert', () => {
it('should make assertion if condition is boolean', () => {
const isRandomlyTrue = Math.random() > 0.5
expectTypeOf(isRandomlyTrue).toEqualTypeOf<boolean>()
try {
SuspensiveError.assert(isRandomlyTrue, 'isRandomlyTrue should be true')
expectTypeOf(isRandomlyTrue).toEqualTypeOf<true>()
expect(isRandomlyTrue).toBe(true)
} catch (error) {
expect(error).toBeInstanceOf(SuspensiveError)
}
})
it('should make assertion if condition is right', () => {
const isAlwaysTrue = Math.random() > 0
expectTypeOf(isAlwaysTrue).toEqualTypeOf<boolean>()
SuspensiveError.assert(isAlwaysTrue, 'isAlwaysTrue should be true')
expectTypeOf(isAlwaysTrue).toEqualTypeOf<true>()
expect(isAlwaysTrue).toBe(true)
})
it('should throw SuspensiveError if condition is not right', () => {
try {
SuspensiveError.assert(Math.random() > 2, 'Math.random() should be greater than 2')
} catch (error) {
expect(error).toBeInstanceOf(SuspensiveError)
}
})
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export function assert(condition: boolean, message: string): asserts condition {
if (!condition) {
throw new Error(message)
export class SuspensiveError extends Error {
static assert(condition: boolean, message: string): asserts condition {
if (!condition) {
throw new SuspensiveError(message)
}
}
}

Expand All @@ -13,9 +15,7 @@ export const useErrorBoundaryFallbackProps_this_hook_should_be_called_in_ErrorBo
export const useErrorBoundaryGroup_this_hook_should_be_called_in_ErrorBoundary_props_children =
'useErrorBoundaryGroup: this hook should be called in ErrorBoundary.props.children'

// Delay
export const DelayMsPropShouldBeGreaterThanOrEqualTo0 = 'Delay: ms prop should be greater than or equal to 0'
export const Delay_ms_prop_should_be_greater_than_or_equal_to_0 = 'Delay: ms prop should be greater than or equal to 0'

// Suspensive
export const SuspensiveConfigDefaultOptionsDelayMsShouldBeGreaterThan0 =
export const Suspensive_config_defaultOptions_delay_ms_should_be_greater_than_0 =
'Suspensive: config.defaultOptions.delay.ms should be greater than 0'
Loading

0 comments on commit 43bd74e

Please sign in to comment.