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(