diff --git a/lib/components/ErrorBoundary.test.tsx b/lib/components/ErrorBoundary.test.tsx index eb19595..69c8a8d 100644 --- a/lib/components/ErrorBoundary.test.tsx +++ b/lib/components/ErrorBoundary.test.tsx @@ -175,6 +175,54 @@ describe("ErrorBoundary", () => { }); expect(container.textContent).toBe(""); }); + + it("should accept an explicit undefined fallback and log an error", () => { + shouldThrow = true; + const onError = vi.fn(); + const onOuterError = vi.fn(); + + act(() => { + root.render( + + + Content + + , + ); + }); + + expect(container.textContent).toBe(""); + expect(onError).toHaveBeenCalledWith(valueToThrow, expect.anything()); + expect(onOuterError).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + "react-error-boundary received an undefined fallback. Pass null to explicitly render nothing.", + ); + }); + + it("should still propagate errors when no fallback prop is provided", () => { + shouldThrow = true; + const onOuterError = vi.fn(); + + act(() => { + root.render( + + {/* @ts-expect-error Test missing fallback props from JavaScript callers. */} + + Content + + , + ); + }); + + expect(container.textContent).toBe("Outer fallback"); + expect(onOuterError).toHaveBeenCalledWith( + valueToThrow, + expect.anything(), + ); + expect(console.error).toHaveBeenCalledWith( + "react-error-boundary requires either a fallback, fallbackRender, or FallbackComponent prop", + ); + }); }); describe('"FallbackComponent"', () => { diff --git a/lib/components/ErrorBoundary.tsx b/lib/components/ErrorBoundary.tsx index 5468fd3..a81be03 100644 --- a/lib/components/ErrorBoundary.tsx +++ b/lib/components/ErrorBoundary.tsx @@ -1,6 +1,6 @@ import { Component, createElement, type ErrorInfo } from "react"; -import { ErrorBoundaryContext } from "../context/ErrorBoundaryContext"; import type { ErrorBoundaryProps, FallbackProps } from "../types"; +import { ErrorBoundaryContextProvider } from "./ErrorBoundaryContextProvider"; const isDevelopment = import.meta.env.DEV; @@ -118,7 +118,12 @@ export class ErrorBoundary extends Component< childToRender = fallbackRender(props); } else if (FallbackComponent) { childToRender = createElement(FallbackComponent, props); - } else if (fallback !== undefined) { + } else if ("fallback" in this.props) { + if (isDevelopment && fallback === undefined) { + console.error( + "react-error-boundary received an undefined fallback. Pass null to explicitly render nothing.", + ); + } childToRender = fallback; } else { if (isDevelopment) { @@ -132,13 +137,11 @@ export class ErrorBoundary extends Component< } return createElement( - ErrorBoundaryContext.Provider, + ErrorBoundaryContextProvider, { - value: { - didCatch, - error, - resetErrorBoundary: this.resetErrorBoundary, - }, + didCatch, + error, + resetErrorBoundary: this.resetErrorBoundary, }, childToRender, ); diff --git a/lib/components/ErrorBoundaryContextProvider.ts b/lib/components/ErrorBoundaryContextProvider.ts new file mode 100644 index 0000000..7c8f176 --- /dev/null +++ b/lib/components/ErrorBoundaryContextProvider.ts @@ -0,0 +1,19 @@ +import { createElement, useMemo, type PropsWithChildren } from "react"; +import { + ErrorBoundaryContext, + type ErrorBoundaryContextType, +} from "../context/ErrorBoundaryContext"; + +export function ErrorBoundaryContextProvider({ + children, + didCatch, + error, + resetErrorBoundary, +}: PropsWithChildren) { + const value = useMemo( + () => ({ didCatch, error, resetErrorBoundary }), + [didCatch, error, resetErrorBoundary], + ); + + return createElement(ErrorBoundaryContext.Provider, { value }, children); +} diff --git a/lib/hooks/useErrorBoundary.test.tsx b/lib/hooks/useErrorBoundary.test.tsx index ffb0ad9..10c84c6 100644 --- a/lib/hooks/useErrorBoundary.test.tsx +++ b/lib/hooks/useErrorBoundary.test.tsx @@ -1,4 +1,4 @@ -import { act, useLayoutEffect, type ReactNode } from "react"; +import { act, memo, useLayoutEffect, type ReactNode } from "react"; import { createRoot } from "react-dom/client"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { ErrorBoundary } from "../components/ErrorBoundary"; @@ -10,6 +10,9 @@ describe("useErrorBoundary", () => { let container: HTMLDivElement; beforeEach(() => { + // @ts-expect-error This is a React internal + global.IS_REACT_ACT_ENVIRONMENT = true; + vi.spyOn(console, "error").mockImplementation(() => { // Don't clutter the console with expected error text }); @@ -91,4 +94,61 @@ describe("useErrorBoundary", () => { expect(container.textContent).toBe("Fallback"); }); + + it("should not rerender memoized consumers when the boundary context is unchanged", () => { + const renderConsumer = vi.fn(); + const Child = memo(function Child() { + const { error } = useErrorBoundary(); + renderConsumer(error); + return "Child"; + }); + + const root = render( + + + , + ); + expect(renderConsumer).toHaveBeenCalledTimes(1); + + act(() => + root.render( + + + , + ), + ); + expect(renderConsumer).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); + + it("should not rerender memoized fallback consumers when the error is unchanged", () => { + const error = new Error("Thrown"); + const renderFallback = vi.fn(); + const Fallback = memo(function Fallback() { + const { error } = useErrorBoundary(); + renderFallback(error); + return "Fallback"; + }); + function Throws(): never { + throw error; + } + + const root = render( + + + , + ); + expect(renderFallback).toHaveBeenLastCalledWith(error); + const initialRenders = renderFallback.mock.calls.length; + + act(() => + root.render( + + + , + ), + ); + expect(renderFallback).toHaveBeenCalledTimes(initialRenders); + act(() => root.unmount()); + }); }); diff --git a/lib/utils/withErrorBoundary.test.tsx b/lib/utils/withErrorBoundary.test.tsx index a4f585e..b796ea3 100644 --- a/lib/utils/withErrorBoundary.test.tsx +++ b/lib/utils/withErrorBoundary.test.tsx @@ -87,6 +87,31 @@ describe("withErrorBoundary", () => { expect(ref.current.getFoo()).toBe("abc"); }); + it("should preserve defaulted and required class props", () => { + class Defaulted extends Component<{ label: string; prefix: string }> { + static defaultProps = { label: "default" }; + + render() { + return this.props.prefix + this.props.label; + } + } + + const Wrapped = withErrorBoundary(Defaulted, { fallback: null }); + const ref = createRef(); + + // @ts-expect-error Props without defaults must still be required. + ; + // @ts-expect-error Defaulted props must retain their declared type. + ; + + act(() => root.render()); + expect(container.textContent).toBe("Label: default"); + expect(ref.current).toBeInstanceOf(Defaulted); + + act(() => root.render()); + expect(container.textContent).toBe("Label: custom"); + }); + it("should forward refs to a function component that takes a ref prop", () => { type Handle = { getFoo: () => string }; diff --git a/lib/utils/withErrorBoundary.ts b/lib/utils/withErrorBoundary.ts index c991133..09ef179 100644 --- a/lib/utils/withErrorBoundary.ts +++ b/lib/utils/withErrorBoundary.ts @@ -4,6 +4,7 @@ import { type ComponentProps, type ComponentRef, type ComponentType, + type JSX, } from "react"; import { ErrorBoundary } from "../components/ErrorBoundary"; import type { ErrorBoundaryProps } from "../types"; @@ -14,7 +15,7 @@ export function withErrorBoundary< // eslint-disable-next-line @typescript-eslint/no-explicit-any Type extends ComponentType, >(Component: Type, errorBoundaryProps: ErrorBoundaryProps) { - type Props = ComponentProps; + type Props = JSX.LibraryManagedAttributes>; const Wrapped = forwardRef, Props>((props, ref) => createElement(