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(react): add useErrorBoundaryFallbackProps #216

Merged
merged 16 commits into from
Oct 18, 2023
Merged
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
5 changes: 5 additions & 0 deletions .changeset/two-weeks-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@suspensive/react": minor
---

feat(react): add useErrorBoundaryFallbackProps
82 changes: 64 additions & 18 deletions packages/react/src/ErrorBoundary.en.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,68 @@ const ErrorAfter4s = () => {

![Example banner](/gif/errorboundary-example.gif)

:::tip

## Define component as `<ErrorBoundary/>`'s fallback

### ErrorBoundaryFallbackProps

If you want to deliver a declared component as `<ErrorBoundary/>`'s fallback, you can use the `ErrorBoundaryFallbackProps` type to declare the component easily.

```tsx
import type { ErrorBoundaryFallbackProps } from '@suspensive/react'

const ErrorBoundaryFallback = ({ reset, error }: ErrorBoundaryFallbackProps) => (
<>
<button onClick={reset}>reset</button>
{error.message}
</>
)

const Example = () => (
<ErrorBoundary fallback={ErrorBoundaryFallback}>
<ErrorAfter4s />
</ErrorBoundary>
)
Comment on lines +60 to +73
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's for the purpose of providing typing

Wouldn't it be better for the developer experience to define not only the type of props but also the return?

Suggested change
import type { ErrorBoundaryFallbackProps } from '@suspensive/react'
const ErrorBoundaryFallback = ({ reset, error }: ErrorBoundaryFallbackProps) => (
<>
<button onClick={reset}>reset</button>
{error.message}
</>
)
const Example = () => (
<ErrorBoundary fallback={ErrorBoundaryFallback}>
<ErrorAfter4s />
</ErrorBoundary>
)
import type { ErrorBoundaryFallbackType } from '@suspensive/react'
const ErrorBoundaryFallback: ErrorBoundaryFallbackType = ({ reset, error }) => (
<> // The type of return value is also certain
<button onClick={reset}>reset</button>
{error.message}
</>
)
const Example = () => (
<ErrorBoundary fallback={ErrorBoundaryFallback}>
<ErrorAfter4s />
</ErrorBoundary>
)

I inspried by nextjs GetServerSideProps

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it be better for the developer experience to define not only the type of props but also the return?

I think that's a good idea too. However, even if we do not provide the return type, TypeScript will create a type error if it does not match the fallback type of ErrorBoundary.

I think we need to consider whether providing a return type is right choice. Because the return type may be different from the actual thing returned (this video explain it well). If it is as essential as ErrorBoundaryFallbackProps, it would be a good idea to add it as a minor version feature.

Let's consider it after merging this

```

:::

:::tip

## Use `<ErrorBoundary/>` fallback props without prop drilling

### useErrorBoundaryFallbackProps
manudeli marked this conversation as resolved.
Show resolved Hide resolved

If component using `reset` method and `error` object is nested, prop drilling cannot be avoided.
The `useErrorBoundaryFallbackProps` allows you to access the `reset` method and `error` objects without prop drilling.

```tsx
import { ErrorBoundary, useErrorBoundaryFallbackProps } from '@suspensive/react'

const Nested = () => {
const { reset, error } = useErrorBoundaryFallbackProps()

return (
<>
<button onClick={reset}>Try again</button>
{error.message}
</>
)
}

// There's no need to pass fallback Prop here!
const ErrorBoundaryFallback = () => <Nested />

const Example = () => (
<ErrorBoundary fallback={ErrorBoundaryFallback}>
<Error />
</ErrorBoundary>
)
```

:::

### props.resetKeys

If you want to reset `<ErrorBoundary/>` by component where is outside of `<ErrorBoundary/>`'s fallback. Inject any resetKey in resetKeys. resetKeys work only when at least one element of array is changed. you don't need to worry about provide new array as resetKeys like how useEffect's dependency array work.
Expand Down Expand Up @@ -123,25 +185,9 @@ useErrorBoundary is experimental feature, this interfaces could be changed

:::

We can reset `<ErrorBoundary/>` without props using useErrorBoundary.reset in ErrorBoundaryFallback.

```tsx
import { ErrorBoundary, useErrorBoundary } from '@suspensive/react'

const ErrorBoundaryFallback = () => {
const errorBoundary = useErrorBoundary()

return <button onClick={errorBoundary.reset} />
}

const Example = () => (
<ErrorBoundary fallback={ErrorBoundaryFallback}>
<Error />
</ErrorBoundary>
)
```
### useErrorBoundary().setError

In children of `<ErrorBoundary/>`, we can use useErrorBoundary.setError to make `<ErrorBoundary/>` aware of the Error without throw.
In children of `<ErrorBoundary/>`, we can use useErrorBoundary().setError to make `<ErrorBoundary/>` aware of the Error without throw.

```tsx
import { ErrorBoundary, useErrorBoundary } from '@suspensive/react'
Expand Down
84 changes: 65 additions & 19 deletions packages/react/src/ErrorBoundary.ko.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,71 @@ const ErrorAfter4s = () => {

![Example banner](/gif/errorboundary-example.gif)

:::tip

## `<ErrorBoundary/>`'의 fallback로 전달할 컴포넌트 정의하기

### ErrorBoundaryFallbackProps

`<ErrorBoundary/>`'의 fallback으로 컴포넌트를 전달하고 싶다면 `ErrorBoundaryFallbackProps` 타입을 활용해 쉽게 컴포넌트를 선언 할 수 있습니다.

```tsx
import type { ErrorBoundaryFallbackProps } from '@suspensive/react'

const ErrorBoundaryFallback = ({ reset, error }: ErrorBoundaryFallbackProps) => (
<>
<button onClick={reset}>reset</button>
{error.message}
</>
)

const Example = () => (
<ErrorBoundary fallback={ErrorBoundaryFallback}>
<ErrorAfter4s />
</ErrorBoundary>
)
```

:::

:::tip

## `<ErrorBoundary/>` fallback props을 prop drilling 없이 사용하기
manudeli marked this conversation as resolved.
Show resolved Hide resolved

### useErrorBoundaryFallbackProps

`error` 객체와 `reset` 메소드을 사용하려는 컴포넌트가 중첩되면 prop drilling을 피할 수 없습니다.
이 때, `useErrorBoundaryFallbackProps`을 통해, prop drilling 없이 `reset` 메소드와 `error` 객체에 접근할 수 있습니다.

```tsx
import { ErrorBoundary, useErrorBoundaryFallbackProps } from '@suspensive/react'

const Nested = () => {
const { reset, error } = useErrorBoundaryFallbackProps()

return (
<>
<button onClick={reset}>Try again</button>
{error.message}
</>
)
}

// 여기서 fallbackProp 을 전달할 필요가 없어집니다!
const ErrorBoundaryFallback = () => <Nested />

const Example = () => (
<ErrorBoundary fallback={ErrorBoundaryFallback}>
<Error />
</ErrorBoundary>
)
```

:::

### props.resetKeys

`<ErrorBoundary/>`의 fallback 외부에 있는 컴포넌트가 `<ErrorBoundary/>`를 reset하려면 resetKeys배열에 resetKey를 할당하면 됩니다. resetKeys는 배열의 하나 이상의 요소가 변경된 경우에만 작동합니다. useEffect의 종속성 배열이 작동하는 방식과 같이 resetKeys로 매 렌더링마다 새 배열을 주입하는 것을 걱정할 필요도 없습니다.
`<ErrorBoundary/>`의 fallback 외부에 있는 컴포넌트가 `<ErrorBoundary/>`를 reset하려면 resetKeys 배열에 resetKey를 할당하면 됩니다. resetKeys는 배열의 하나 이상의 요소가 변경된 경우에만 작동합니다. useEffect의 종속성 배열이 작동하는 방식과 같이 resetKeys로 매 렌더링마다 새 배열을 주입하는 것을 걱정할 필요도 없습니다.

```tsx
import { ErrorBoundary } from '@suspensive/react'
Expand Down Expand Up @@ -123,25 +185,9 @@ useErrorBoundary는 실험 기능이므로 이 인터페이스는 변경될 수

:::

ErrorBoundaryFallback에서 useErrorBoundary.reset을 사용해 props 없이도 `<ErrorBoundary/>`을 reset할 수 있습니다.

```tsx
import { ErrorBoundary, useErrorBoundary } from '@suspensive/react'

const ErrorBoundaryFallback = () => {
const errorBoundary = useErrorBoundary()

return <button onClick={errorBoundary.reset} />
}

const Example = () => (
<ErrorBoundary fallback={ErrorBoundaryFallback}>
<Error />
</ErrorBoundary>
)
```
### useErrorBoundary().setError

`<ErrorBoundary/>`의 children에서 useErrorBoundary.setError을 사용해 throw 없이도 `<ErrorBoundary/>`에서 Error를 알도록 할 수 있습니다.
`<ErrorBoundary/>`의 children에서 useErrorBoundary().setError을 사용해 throw 없이도 `<ErrorBoundary/>`에서 Error를 알게 할 수 있습니다.

```tsx
import { ErrorBoundary, useErrorBoundary } from '@suspensive/react'
Expand Down
146 changes: 97 additions & 49 deletions packages/react/src/ErrorBoundary.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { act, render } from '@testing-library/react'
import type { ComponentProps, ComponentRef } from 'react'
import { createElement, createRef, useEffect } from 'react'
import { createElement, createRef } from 'react'
import { createRoot } from 'react-dom/client'
import { vi } from 'vitest'
import { useSetTimeout } from './hooks'
import { assert } from './utils'
import { ERROR_MESSAGE, FALLBACK, MS_100, TEXT, ThrowError, ThrowNull } from './utils/toTest'
import { ErrorBoundary, useErrorBoundary, withErrorBoundary } from '.'
import { ErrorBoundary, useErrorBoundary, useErrorBoundaryFallbackProps, withErrorBoundary } from '.'

let container = document.createElement('div')
let root = createRoot(container)
Expand Down Expand Up @@ -250,22 +250,79 @@ describe('useErrorBoundary', () => {
)
)

it('should supply reset function to reset in fallback of ErrorBoundary', () => {
const onReset = vi.fn()
it('should supply setError to set Error of ErrorBoundary manually', () => {
const onError = vi.fn()
vi.useFakeTimers()
renderErrorBoundary({
onReset,
fallback: function ErrorBoundaryFallback({ error }) {
onError,
fallback: function ErrorBoundaryFallback() {
const props = useErrorBoundaryFallbackProps()
useSetTimeout(props.reset, MS_100)
return <>{props.error.message}</>
},
children: createElement(() => {
const errorBoundary = useErrorBoundary()
useSetTimeout(() => errorBoundary.setError(new Error(ERROR_MESSAGE)), MS_100)
return <>{TEXT}</>
}),
})
expect(container.textContent).toBe(TEXT)
expect(container.textContent).not.toBe(ERROR_MESSAGE)
expect(onError).toHaveBeenCalledTimes(0)
act(() => vi.advanceTimersByTime(MS_100))
expect(container.textContent).toBe(ERROR_MESSAGE)
expect(container.textContent).not.toBe(TEXT)
expect(onError).toHaveBeenCalledTimes(1)
})

useEffect(() => {
const timeoutId = setTimeout(() => {
errorBoundary.reset()
}, MS_100)
return () => clearTimeout(timeoutId)
})
it('should guarantee hook calling position is in children of ErrorBoundary', () => {
expect(
render(
<ErrorBoundary fallback={ERROR_MESSAGE}>
{createElement(() => {
useErrorBoundary()
return <>{TEXT}</>
})}
</ErrorBoundary>
).getByText(TEXT)
).toBeInTheDocument()

return <>{error.message}</>
expect(() =>
render(
<ErrorBoundary
fallback={() => {
useErrorBoundary()
return <></>
}}
>
<ThrowError message={ERROR_MESSAGE} after={0} />
</ErrorBoundary>
)
).toThrow(assert.message.useErrorBoundary.onlyInChildrenOfErrorBoundary)
})
})

describe('useErrorBoundaryFallbackProps', () => {
beforeEach(() => {
container = document.createElement('div')
root = createRoot(container)
ThrowError.reset()
})
const renderErrorBoundary = (props: Partial<ComponentProps<typeof ErrorBoundary>>) =>
act(() =>
root.render(<ErrorBoundary ref={errorBoundaryRef} fallback={(props) => <>{props.error.message}</>} {...props} />)
)

it('should supply reset function and error to reset in fallback of ErrorBoundary', () => {
const onReset = vi.fn()
vi.useFakeTimers()
renderErrorBoundary({
onReset,
fallback: function ErrorBoundaryFallback() {
const props = useErrorBoundaryFallbackProps()
useSetTimeout(props.reset, MS_100)

return <>{props.error.message}</>
},
children: (
<ThrowError message={ERROR_MESSAGE} after={MS_100}>
Expand All @@ -286,49 +343,40 @@ describe('useErrorBoundary', () => {
expect(onReset).toHaveBeenCalledTimes(1)
})

it('should supply setError to set Error of ErrorBoundary manually', () => {
const onError = vi.fn()
vi.useFakeTimers()
renderErrorBoundary({
onError,
fallback: function ErrorBoundaryFallback({ error }) {
const errorBoundary = useErrorBoundary()
useSetTimeout(errorBoundary.reset, MS_100)
return <>{error.message}</>
},
children: createElement(() => {
const errorBoundary = useErrorBoundary()
useSetTimeout(() => errorBoundary.setError(new Error(ERROR_MESSAGE)), MS_100)
return <>{TEXT}</>
}),
})
expect(container.textContent).toBe(TEXT)
expect(container.textContent).not.toBe(ERROR_MESSAGE)
expect(onError).toHaveBeenCalledTimes(0)
act(() => vi.advanceTimersByTime(MS_100))
expect(container.textContent).toBe(ERROR_MESSAGE)
expect(container.textContent).not.toBe(TEXT)
expect(onError).toHaveBeenCalledTimes(1)
it('should guarantee hook calling position is in fallback of ErrorBoundary', () => {
expect(
render(
<ErrorBoundary fallback={(props) => <>{props.error.message}</>}>
{createElement(() => {
useErrorBoundaryFallbackProps()
return <>{TEXT}</>
})}
</ErrorBoundary>
).getByText(assert.message.useErrorBoundaryFallbackProps.onlyInFallbackOfErrorBoundary)
).toBeInTheDocument()
})

it('should guarantee ErrorBoundary in parent', () => {
const rendered = render(
<ErrorBoundary fallback={ERROR_MESSAGE}>
{createElement(() => {
useErrorBoundary()
return <>{TEXT}</>
})}
</ErrorBoundary>
)
expect(rendered.getByText(TEXT)).toBeInTheDocument()

it('should be prevented to be called outside fallback of ErrorBoundary', () => {
expect(() =>
render(
createElement(() => {
useErrorBoundary()
useErrorBoundaryFallbackProps()
return <>{TEXT}</>
})
)
).toThrow(assert.message.useErrorBoundary.onlyInChildrenOfErrorBoundary)
).toThrow(assert.message.useErrorBoundaryFallbackProps.onlyInFallbackOfErrorBoundary)
})

it('should be prevented to be called in children of ErrorBoundary', () => {
expect(
render(
<ErrorBoundary fallback={(props) => <>{props.error.message}</>}>
{createElement(() => {
useErrorBoundaryFallbackProps()
return <>{TEXT}</>
})}
</ErrorBoundary>
).getByText(assert.message.useErrorBoundaryFallbackProps.onlyInFallbackOfErrorBoundary)
).toBeInTheDocument()
})
})
Loading
Loading