Skip to content
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
48 changes: 48 additions & 0 deletions lib/components/ErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ErrorBoundary fallback="Outer fallback" onError={onOuterError}>
<ErrorBoundary fallback={undefined} onError={onError}>
<MaybeThrows>Content</MaybeThrows>
</ErrorBoundary>
</ErrorBoundary>,
);
});

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(
<ErrorBoundary fallback="Outer fallback" onError={onOuterError}>
{/* @ts-expect-error Test missing fallback props from JavaScript callers. */}
<ErrorBoundary>
<MaybeThrows>Content</MaybeThrows>
</ErrorBoundary>
</ErrorBoundary>,
);
});

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"', () => {
Expand Down
19 changes: 11 additions & 8 deletions lib/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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) {
Expand All @@ -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,
);
Expand Down
19 changes: 19 additions & 0 deletions lib/components/ErrorBoundaryContextProvider.ts
Original file line number Diff line number Diff line change
@@ -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<ErrorBoundaryContextType>) {
const value = useMemo(
() => ({ didCatch, error, resetErrorBoundary }),
[didCatch, error, resetErrorBoundary],
);

return createElement(ErrorBoundaryContext.Provider, { value }, children);
}
62 changes: 61 additions & 1 deletion lib/hooks/useErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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
});
Expand Down Expand Up @@ -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(
<ErrorBoundary fallback={null}>
<Child />
</ErrorBoundary>,
);
expect(renderConsumer).toHaveBeenCalledTimes(1);

act(() =>
root.render(
<ErrorBoundary fallback={null}>
<Child />
</ErrorBoundary>,
),
);
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(
<ErrorBoundary FallbackComponent={Fallback}>
<Throws />
</ErrorBoundary>,
);
expect(renderFallback).toHaveBeenLastCalledWith(error);
const initialRenders = renderFallback.mock.calls.length;

act(() =>
root.render(
<ErrorBoundary FallbackComponent={Fallback}>
<Throws />
</ErrorBoundary>,
),
);
expect(renderFallback).toHaveBeenCalledTimes(initialRenders);
act(() => root.unmount());
});
});
25 changes: 25 additions & 0 deletions lib/utils/withErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Defaulted>();

// @ts-expect-error Props without defaults must still be required.
<Wrapped />;
// @ts-expect-error Defaulted props must retain their declared type.
<Wrapped prefix="" label={123} />;

act(() => root.render(<Wrapped prefix="Label: " ref={ref} />));
expect(container.textContent).toBe("Label: default");
expect(ref.current).toBeInstanceOf(Defaulted);

act(() => root.render(<Wrapped prefix="Label: " label="custom" />));
expect(container.textContent).toBe("Label: custom");
});

it("should forward refs to a function component that takes a ref prop", () => {
type Handle = { getFoo: () => string };

Expand Down
3 changes: 2 additions & 1 deletion lib/utils/withErrorBoundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -14,7 +15,7 @@ export function withErrorBoundary<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Type extends ComponentType<any>,
>(Component: Type, errorBoundaryProps: ErrorBoundaryProps) {
type Props = ComponentProps<Type>;
type Props = JSX.LibraryManagedAttributes<Type, ComponentProps<Type>>;

const Wrapped = forwardRef<ComponentRef<Type>, Props>((props, ref) =>
createElement(
Expand Down
Loading