Skip to content

Commit

Permalink
Merge branch 'main' into fix/use-sync-external-store-correctly
Browse files Browse the repository at this point in the history
  • Loading branch information
manudeli committed Jan 15, 2024
2 parents 46107b6 + 74a43f5 commit c3d2938
Show file tree
Hide file tree
Showing 19 changed files with 417 additions and 141 deletions.
6 changes: 6 additions & 0 deletions configs/test-utils/src/CustomError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class CustomError extends Error {
constructor(...args: ConstructorParameters<ErrorConstructor>) {
super(...args)
console.error(...args)
}
}
5 changes: 5 additions & 0 deletions configs/test-utils/src/CustomNotError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class CustomNotError {
constructor(public message?: string) {
console.log(message)
}
}
2 changes: 2 additions & 0 deletions configs/test-utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export { CustomError } from './CustomError'
export { CustomNotError } from './CustomNotError'
export { Suspend } from './Suspend'
export { ThrowError, ThrowNull } from './ThrowError'
export { sleep } from './sleep'
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,10 @@
"@testing-library/dom": "^9.3.4",
"@testing-library/jest-dom": "^6.2.0",
"@testing-library/react": "^14.1.2",
"@types/node": "^18.19.6",
"@types/node": "^18.19.7",
"@vitest/browser": "^1.1.3",
"@vitest/coverage-istanbul": "^1.1.3",
"@vitest/ui": "^1.1.3",
"@vitest/ui": "^1.2.0",
"commitizen": "^4.3.0",
"cz-conventional-changelog": "^3.3.0",
"eslint": "^8.56.0",
Expand All @@ -71,7 +71,7 @@
"ms": "^3.0.0-canary.1",
"packlint": "^0.2.4",
"playwright": "^1.40.1",
"prettier": "^3.1.1",
"prettier": "^3.2.2",
"prettier-plugin-tailwindcss": "^0.5.11",
"publint": "^0.2.7",
"rimraf": "^5.0.5",
Expand All @@ -80,6 +80,6 @@
"turbo": "latest",
"typescript": "^5.3.3",
"vite": "^5.0.11",
"vitest": "^1.1.3"
"vitest": "^1.2.0"
}
}
7 changes: 7 additions & 0 deletions packages/react-query/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# @suspensive/react-query

## 1.25.0

### Patch Changes

- Updated dependencies [caed129]
- @suspensive/react@1.25.0

## 1.24.0

### Patch Changes
Expand Down
4 changes: 2 additions & 2 deletions packages/react-query/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@suspensive/react-query",
"version": "1.24.0",
"version": "1.25.0",
"description": "Useful helpers for @tanstack/react-query with suspense",
"keywords": [
"suspensive",
Expand Down Expand Up @@ -69,7 +69,7 @@
"react-dom": "^18.2.0"
},
"peerDependencies": {
"@suspensive/react": "workspace:^1.24.0",
"@suspensive/react": "workspace:^1.25.0",
"@tanstack/react-query": "^4",
"react": "^16.8 || ^17 || ^18"
},
Expand Down
6 changes: 6 additions & 0 deletions packages/react/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# @suspensive/react

## 1.25.0

### Minor Changes

- caed129: feat(react): add `shouldCatch` prop of ErrorBoundary

## 1.24.0

### Minor Changes
Expand Down
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@suspensive/react",
"version": "1.24.0",
"version": "1.25.0",
"description": "Useful interfaces for React Suspense",
"keywords": [
"suspensive",
Expand Down
94 changes: 93 additions & 1 deletion packages/react/src/ErrorBoundary.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ERROR_MESSAGE, FALLBACK, TEXT, ThrowError, ThrowNull } from '@suspensive/test-utils'
import { CustomError, ERROR_MESSAGE, FALLBACK, TEXT, ThrowError, ThrowNull } from '@suspensive/test-utils'
import { act, render, screen, waitFor } from '@testing-library/react'
import ms from 'ms'
import { type ComponentRef, createElement, createRef } from 'react'
Expand Down Expand Up @@ -167,6 +167,98 @@ describe('<ErrorBoundary/>', () => {
expect(screen.queryByText(ERROR_MESSAGE)).not.toBeInTheDocument()
expect(onReset).toHaveBeenCalledTimes(1)
})

it('should catch Error by many criteria', async () => {
const onErrorParent = vi.fn()
const onErrorChild = vi.fn()

render(
<ErrorBoundary fallback={({ error }) => <>{error.message} of Parent</>} onError={onErrorParent}>
<ErrorBoundary
shouldCatch={[CustomError, (error) => error instanceof CustomError]}
fallback={({ error }) => <>{error.message} of Child</>}
onError={onErrorChild}
>
{createElement(() => {
throw new Error(ERROR_MESSAGE)
})}
</ErrorBoundary>
</ErrorBoundary>
)

expect(onErrorChild).toBeCalledTimes(0)
expect(onErrorParent).toBeCalledTimes(1)
await waitFor(() => expect(screen.queryByText(`${ERROR_MESSAGE} of Parent`)).toBeInTheDocument())
})

it('should catch Error by one criteria(ErrorConstructor)', async () => {
const onErrorParent = vi.fn()
const onErrorChild = vi.fn()

render(
<ErrorBoundary fallback={({ error }) => <>{error.message} of Parent</>} onError={onErrorParent}>
<ErrorBoundary
shouldCatch={CustomError}
fallback={({ error }) => <>{error.message} of Child</>}
onError={onErrorChild}
>
{createElement(() => {
throw new Error(ERROR_MESSAGE)
})}
</ErrorBoundary>
</ErrorBoundary>
)

expect(onErrorChild).toBeCalledTimes(0)
expect(onErrorParent).toBeCalledTimes(1)
await waitFor(() => expect(screen.queryByText(`${ERROR_MESSAGE} of Parent`)).toBeInTheDocument())
})

it('should catch Error by one criteria(ShouldCatchCallback)', async () => {
const onErrorParent = vi.fn()
const onErrorChild = vi.fn()

render(
<ErrorBoundary fallback={({ error }) => <>{error.message} of Parent</>} onError={onErrorParent}>
<ErrorBoundary
shouldCatch={(error) => error instanceof CustomError}
fallback={({ error }) => <>{error.message} of Child</>}
onError={onErrorChild}
>
{createElement(() => {
throw new Error(ERROR_MESSAGE)
})}
</ErrorBoundary>
</ErrorBoundary>
)

expect(onErrorChild).toBeCalledTimes(0)
expect(onErrorParent).toBeCalledTimes(1)
await waitFor(() => expect(screen.queryByText(`${ERROR_MESSAGE} of Parent`)).toBeInTheDocument())
})

it('should catch Error by one criteria(boolean)', async () => {
const onErrorParent = vi.fn()
const onErrorChild = vi.fn()

render(
<ErrorBoundary fallback={({ error }) => <>{error.message} of Parent</>} onError={onErrorParent}>
<ErrorBoundary
shouldCatch={false}
fallback={({ error }) => <>{error.message} of Child</>}
onError={onErrorChild}
>
{createElement(() => {
throw new Error(ERROR_MESSAGE)
})}
</ErrorBoundary>
</ErrorBoundary>
)

expect(onErrorChild).toBeCalledTimes(0)
expect(onErrorParent).toBeCalledTimes(1)
await waitFor(() => expect(screen.queryByText(`${ERROR_MESSAGE} of Parent`)).toBeInTheDocument())
})
})

describe('useErrorBoundary', () => {
Expand Down
38 changes: 38 additions & 0 deletions packages/react/src/ErrorBoundary.test-d.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { CustomError, CustomNotError } from '@suspensive/test-utils'
import type { ComponentProps } from 'react'
import { describe, expectTypeOf, it } from 'vitest'
import { ErrorBoundary } from './ErrorBoundary'
import type { ConstructorType } from './utility-types'

describe('<ErrorBoundary/>', () => {
it('should pass only boolean or ErrorConstructor or ShouldCatchCallback or ShouldCatch[]', () => {
type ShouldCatchCallback = (error: Error) => boolean
type ShouldCatch = boolean | ConstructorType<Error> | ShouldCatchCallback
expectTypeOf<ComponentProps<typeof ErrorBoundary>['shouldCatch']>().toEqualTypeOf<
undefined | ShouldCatch | [ShouldCatch, ...ShouldCatch[]]
>()

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const ShouldCatchByMany = () => (
<ErrorBoundary
shouldCatch={[
// @ts-expect-error CustomNotError should be new (...args) => Error
CustomNotError,
CustomError,
(error) => error instanceof CustomError,
Math.random() > 0.5,
]}
fallback={({ error }) => <>{error.message} of Child</>}
></ErrorBoundary>
)

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const ShouldCatchByOne = () => (
<ErrorBoundary
// @ts-expect-error CustomNotError should be new (...args) => Error
shouldCatch={CustomNotError}
fallback={({ error }) => <>{error.message} of Child</>}
></ErrorBoundary>
)
})
})
96 changes: 60 additions & 36 deletions packages/react/src/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
import { syncDevMode } from './contexts'
import { Delay } from './Delay'
import { ErrorBoundaryGroupContext } from './ErrorBoundaryGroup'
import type { PropsWithDevMode } from './utility-types'
import type { ConstructorType, PropsWithDevMode } from './utility-types'
import { assert, hasResetKeysChanged } from './utils'
import {
useErrorBoundaryFallbackProps_this_hook_should_be_called_in_ErrorBoundary_props_fallback,
Expand All @@ -33,34 +33,48 @@ export interface ErrorBoundaryFallbackProps<TError extends Error = Error> {
reset: () => void
}

export interface ErrorBoundaryProps extends PropsWithDevMode<PropsWithChildren, ErrorBoundaryDevModeProp> {
/**
* an array of elements for the ErrorBoundary to check each render. If any of those elements change between renders, then the ErrorBoundary will reset the state which will re-render the children
*/
resetKeys?: unknown[]
/**
* when ErrorBoundary is reset by resetKeys or fallback's props.reset, onReset will be triggered
*/
onReset?(): void
/**
* when ErrorBoundary catch error, onError will be triggered
*/
onError?(error: Error, info: ErrorInfo): void
/**
* when ErrorBoundary catch error, fallback will be render instead of children
*/
fallback: ReactNode | FunctionComponent<ErrorBoundaryFallbackProps>
type ShouldCatchCallback = (error: Error) => boolean
type ShouldCatch = ConstructorType<Error> | ShouldCatchCallback | boolean
const checkErrorBoundary = (shouldCatch: ShouldCatch, error: Error) => {
if (typeof shouldCatch === 'boolean') {
return shouldCatch
}
if (shouldCatch.prototype instanceof Error) {
return error instanceof shouldCatch
}
return (shouldCatch as ShouldCatchCallback)(error)
}

export type ErrorBoundaryProps = PropsWithDevMode<
PropsWithChildren<{
/**
* an array of elements for the ErrorBoundary to check each render. If any of those elements change between renders, then the ErrorBoundary will reset the state which will re-render the children
*/
resetKeys?: unknown[]
/**
* when ErrorBoundary is reset by resetKeys or fallback's props.reset, onReset will be triggered
*/
onReset?(): void
/**
* when ErrorBoundary catch error, onError will be triggered
*/
onError?(error: Error, info: ErrorInfo): void
/**
* when ErrorBoundary catch error, fallback will be render instead of children
*/
fallback: ReactNode | FunctionComponent<ErrorBoundaryFallbackProps>
/**
* @experimental This is experimental feature.

Check warning on line 67 in packages/react/src/ErrorBoundary.tsx

View workflow job for this annotation

GitHub Actions / Check quality (lint)

Invalid JSDoc tag name "experimental"
* @default true
*/
shouldCatch?: ShouldCatch | [ShouldCatch, ...ShouldCatch[]]
}>,
ErrorBoundaryDevModeProp
>

type ErrorBoundaryState<TError extends Error = Error> =
| {
isError: true
error: TError
}
| {
isError: false
error: null
}
| { isError: true; error: TError }
| { isError: false; error: null }

const initialErrorBoundaryState: ErrorBoundaryState = {
isError: false,
Expand Down Expand Up @@ -91,24 +105,33 @@ class BaseErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState
}

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

let childrenOrFallback = children

if (this.state.isError && typeof fallback === 'undefined') {
if (process.env.NODE_ENV !== 'production') {
console.error('ErrorBoundary of @suspensive/react requires a defined fallback')
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 !== 'production') {
console.error('ErrorBoundary of @suspensive/react requires a defined fallback')
}
throw error
}
throw this.state.error
}

let childrenOrFallback = children
if (this.state.isError) {
if (typeof fallback === 'function') {
const FallbackComponent = fallback
childrenOrFallback = <FallbackComponent error={this.state.error} reset={this.reset} />
childrenOrFallback = <FallbackComponent error={error} reset={this.reset} />
} else {
childrenOrFallback = fallback
}
}

return (
<ErrorBoundaryContext.Provider value={{ ...this.state, reset: this.reset }}>
{childrenOrFallback}
Expand All @@ -122,7 +145,7 @@ class BaseErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState
* @see {@link https://suspensive.org/docs/react/ErrorBoundary}
*/
export const ErrorBoundary = forwardRef<{ reset(): void }, ErrorBoundaryProps>(
({ devMode, fallback, children, onError, onReset, resetKeys }, ref) => {
({ devMode, fallback, children, onError, onReset, resetKeys, ...props }, ref) => {
const group = useContext(ErrorBoundaryGroupContext) ?? { resetKey: 0 }
const baseErrorBoundaryRef = useRef<BaseErrorBoundary>(null)
useImperativeHandle(ref, () => ({
Expand All @@ -131,6 +154,7 @@ export const ErrorBoundary = forwardRef<{ reset(): void }, ErrorBoundaryProps>(

return (
<BaseErrorBoundary
{...props}
fallback={fallback}
onError={onError}
onReset={onReset}
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/utility-types/ConstructorType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type ConstructorType<TClass> = new (...args: any[]) => TClass
1 change: 1 addition & 0 deletions packages/react/src/utility-types/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export type { PropsWithDevMode } from './PropsWithDevMode'
export type { OmitKeyOf } from './OmitKeyOf'
export type { ConstructorType } from './ConstructorType'
Loading

0 comments on commit c3d2938

Please sign in to comment.