Skip to content

Commit

Permalink
remove error wrapping for thrown primitives (#14)
Browse files Browse the repository at this point in the history
  • Loading branch information
tatethurston committed Jun 21, 2022
1 parent 663bbf8 commit 53002ba
Show file tree
Hide file tree
Showing 4 changed files with 50 additions and 102 deletions.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## v3.0.0

```js
const [error] = useErrorBoundary();
```

- The `error` wrapping that was introduced in v2 has been removed. `error` will now be the error that was caught without any wrapping for thrown primitives. The types have been updated to `unknown` to reflect that thrown JavaScript errors may be any type not just instances of `Error`.

- `withErrorBoundary` now propagates the wrapped component display name for improved debugging with React dev tools. It will display as `WithErrorBoundary(${Component.displayName})`.

## v2.0.1

Publish CommonJS and ESM.
Expand Down
3 changes: 2 additions & 1 deletion example/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ const App: FC = withErrorBoundary(() => {
});

if (error) {
const message = error instanceof Error ? error.message : (error as string);
return (
<>
<div>Error: {error.message}</div>
<div>Error: {message}</div>
<button
onClick={() => {
setShouldThrow(false);
Expand Down
81 changes: 24 additions & 57 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import React, {
Component,
useState,
useCallback,
ComponentLifecycle,
createContext,
useContext,
MutableRefObject,
Expand All @@ -12,58 +11,22 @@ import React, {
ReactNode,
PropsWithChildren,
ReactElement,
ErrorInfo,
} from "react";

// eslint-disable-next-line @typescript-eslint/ban-types
type ComponentDidCatch = ComponentLifecycle<{}, {}>["componentDidCatch"];
type ComponentDidCatch = (error: unknown, errorInfo: ErrorInfo) => void;

interface ErrorBoundaryProps {
error: Error | undefined;
onError: NonNullable<ComponentDidCatch>;
}

/**
* Wrapper that is instantiated for thrown primitives so that consumers always work with the `Error` interface.
* The thrown primitive can be accessed via the `originalError` property.
*/
class ReactUseErrorBoundaryWrappedError extends Error {
/**
* The thrown error.
*/
originalError: unknown;

constructor(error: unknown) {
console.warn(
"react-use-error-boundary: A value was thrown that is not an instance of Error. Thrown values should be instantiated with JavaScript's Error constructor."
);
/*
Some values cannot be converted into a string, such as Symbols
or certain Object instances (e.g., `Object.create(null)`).
This try/catch ensures that our silent error wrapper doesn't
cause an unexpected error for the user, bricking the React app
when we're meant to be preventing errors doing so.
*/
try {
super(error as string);
} catch {
super(
"react-use-error-boundary: Could not instantiate an Error with the thrown value. The thrown value can be accessed via the 'originalError' property"
);
}
this.name = "ReactUseErrorBoundaryWrappedError";
// Maintains proper stack trace for where our error was thrown (only available on V8)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
Error.captureStackTrace?.(this, ReactUseErrorBoundaryWrappedError);
// Save a copy of the original non-stringified data
this.originalError = error;
}
error: unknown | undefined;
onError: ComponentDidCatch;
}

class ErrorBoundary extends Component<PropsWithChildren<ErrorBoundaryProps>> {
displayName = "ReactUseErrorBoundary";

componentDidCatch(...args: Parameters<NonNullable<ComponentDidCatch>>) {
componentDidCatch(
...args: Parameters<NonNullable<Component["componentDidCatch"]>>
) {
// silence React warning:
// ErrorBoundary: Error boundaries should implement getDerivedStateFromError(). In that method, return a state update to display an error message or fallback UI
this.setState({});
Expand All @@ -78,8 +41,8 @@ class ErrorBoundary extends Component<PropsWithChildren<ErrorBoundaryProps>> {
const noop = () => false;

interface ErrorBoundaryCtx {
componentDidCatch: MutableRefObject<ComponentDidCatch>;
error: Error | undefined;
componentDidCatch: MutableRefObject<ComponentDidCatch | undefined>;
error: unknown | undefined;
setError: (error: Error | undefined) => void;
}

Expand All @@ -95,7 +58,7 @@ export function ErrorBoundaryContext({
}: {
children?: ReactNode | undefined;
}) {
const [error, setError] = useState<Error>();
const [error, setError] = useState<unknown>();
const componentDidCatch = useRef<ComponentDidCatch>();
const ctx = useMemo(
() => ({
Expand All @@ -110,10 +73,6 @@ export function ErrorBoundaryContext({
<ErrorBoundary
error={error}
onError={(error, errorInfo) => {
if (!(error instanceof Error)) {
error = new ReactUseErrorBoundaryWrappedError(error);
}

setError(error);
componentDidCatch.current?.(error, errorInfo);
}}
Expand All @@ -129,15 +88,23 @@ export function withErrorBoundary<Props = Record<string, unknown>>(
WrappedComponent: ComponentType<Props>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): (props: PropsWithChildren<Props>) => ReactElement<any, any> {
return (props: Props) => (
<ErrorBoundaryContext>
<WrappedComponent key="WrappedComponent" {...props} />
</ErrorBoundaryContext>
);
function WithErrorBoundary(props: Props) {
return (
<ErrorBoundaryContext>
<WrappedComponent key="WrappedComponent" {...props} />
</ErrorBoundaryContext>
);
}
WithErrorBoundary.displayName = `WithErrorBoundary(${
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
WrappedComponent.displayName ?? WrappedComponent.name ?? "Component"
})`;

return WithErrorBoundary;
}

type UseErrorBoundaryReturn = [
error: Error | undefined,
error: unknown | undefined,
resetError: () => void
];

Expand Down
58 changes: 14 additions & 44 deletions src/test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,60 +31,30 @@ describe(useErrorBoundary, () => {
expect(componentDidCatch).toHaveBeenCalledTimes(1);
});

describe("ReactUseErrorBoundaryWrappedError", () => {
it("wraps thrown values that are not Error instances with ReactUseErrorBoundaryWrappedError", () => {
let error;
it("thrown primitives", () => {
let error;

const ThrowNonError: FC = () => {
throw "Bombs away 💣";
};
const ThrowNonError: FC = () => {
throw "Bombs away 💣";
};

const Example: FC = withErrorBoundary(() => {
[error] = useErrorBoundary();
if (error) {
return <p>Error: {error.message}</p>;
}
const Example: FC = withErrorBoundary(() => {
[error] = useErrorBoundary();
if (error) {
return <p>Error: {error as string}</p>;
}

return <ThrowNonError />;
});
return <ThrowNonError />;
});

render(<Example />);
render(<Example />);

expect(screen.queryByText(/Error:/)).toMatchInlineSnapshot(`
expect(screen.queryByText(/Error:/)).toMatchInlineSnapshot(`
<p>
Error:
Bombs away 💣
</p>
`);
});

it("handles thrown values that can not be passed to Error's constructor", () => {
let error: Error | undefined;

const thrownError = Symbol("Foo");

const ThrowNonError: FC = () => {
throw thrownError;
};

const Example: FC = withErrorBoundary(() => {
[error] = useErrorBoundary();
if (error) {
return null;
}

return <ThrowNonError />;
});

render(<Example />);

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unsafe-member-access
expect(error!.message).toMatchInlineSnapshot(
`"react-use-error-boundary: Could not instantiate an Error with the thrown value. The thrown value can be accessed via the 'originalError' property"`
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
expect((error as any).originalError).toEqual(thrownError);
});
});

it("does not invoke the componentDidCatch handler when there is not an error", () => {
Expand Down

0 comments on commit 53002ba

Please sign in to comment.