Skip to content

Commit

Permalink
Add error boundary package
Browse files Browse the repository at this point in the history
  • Loading branch information
marshallku committed Feb 12, 2024
1 parent f5d7ae6 commit 4916128
Show file tree
Hide file tree
Showing 13 changed files with 332 additions and 1 deletion.
4 changes: 3 additions & 1 deletion apps/blog/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
},
"dependencies": {
"@marshallku/icon": "workspace:^",
"@marshallku/react-error-boundary": "workspace:^",
"@marshallku/toast": "workspace:^",
"@marshallku/ui": "workspace:^",
"@marshallku/utils": "workspace:^",
Expand All @@ -31,7 +32,8 @@
"remark-slug": "^7.0.1",
"remark-toc": "^9.0.0",
"remark-unwrap-images": "^4.0.0",
"smooth-zoom": "^1.4.1"
"smooth-zoom": "^1.4.1",
"zod": "^3.22.4"
},
"devDependencies": {
"@marshallku/eslint-config": "workspace:*",
Expand Down
3 changes: 3 additions & 0 deletions packages/react-error-boundary/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": ["@marshallku/eslint-config/react.js"]
}
6 changes: 6 additions & 0 deletions packages/react-error-boundary/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# react-error-boundary

Original source from [react-error-boundary](https://github.com/bvaughn/react-error-boundary).\
Last commit hash: [23a4d77](https://github.com/bvaughn/react-error-boundary/commit/23a4d779744f3079dfe0960acaa4bdfd5dede87b)

I've forked this to test integration with a [self-hosted sentry](https://github.com/getsentry/self-hosted)
30 changes: 30 additions & 0 deletions packages/react-error-boundary/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "@marshallku/react-error-boundary",
"version": "0.0.0",
"sideEffects": false,
"type": "module",
"license": "MIT",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist/**"
],
"scripts": {
"build": "tsup src/index.ts --format esm --dts --external react --minify --clean"
},
"devDependencies": {
"@marshallku/eslint-config": "workspace:*",
"@marshallku/typescript-config": "workspace:*",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"react-dom": "^18.2.0",
"tsup": "^8.0.1"
},
"peerDependencies": {
"react": "^18.2.0"
},
"publishConfig": {
"access": "public"
}
}
9 changes: 9 additions & 0 deletions packages/react-error-boundary/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"use client";

export * from "./lib/ErrorBoundary";
export * from "./lib/ErrorBoundaryContext";
export * from "./lib/useErrorBoundary";
export * from "./lib/withErrorBoundary";

// TypeScript types
export * from "./lib/types";
108 changes: 108 additions & 0 deletions packages/react-error-boundary/src/lib/ErrorBoundary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { Component, createElement, ErrorInfo, isValidElement } from "react";
import { ErrorBoundaryContext } from "./ErrorBoundaryContext";
import { ErrorBoundaryProps, FallbackProps } from "./types";

type ErrorBoundaryState =
| {
didCatch: true;
error: any;
}
| {
didCatch: false;
error: null;
};

const initialState: ErrorBoundaryState = {
didCatch: false,
error: null,
};

export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);

this.resetErrorBoundary = this.resetErrorBoundary.bind(this);
this.state = initialState;
}

static getDerivedStateFromError(error: Error) {
return { didCatch: true, error };
}

resetErrorBoundary(...args: any[]) {
const { error } = this.state;

if (error !== null) {
this.props.onReset?.({
args,
reason: "imperative-api",
});

this.setState(initialState);
}
}

componentDidCatch(error: Error, info: ErrorInfo) {
this.props.onError?.(error, info);
}

componentDidUpdate(prevProps: ErrorBoundaryProps, prevState: ErrorBoundaryState) {
const { didCatch } = this.state;
const { resetKeys } = this.props;

// There's an edge case where if the thing that triggered the error happens to *also* be in the resetKeys array,
// we'd end up resetting the error boundary immediately.
// This would likely trigger a second error to be thrown.
// So we make sure that we don't check the resetKeys on the first call of cDU after the error is set.

if (didCatch && prevState.error !== null && hasArrayChanged(prevProps.resetKeys, resetKeys)) {
this.props.onReset?.({
next: resetKeys,
prev: prevProps.resetKeys,
reason: "keys",
});

this.setState(initialState);
}
}

render() {
const { children, fallbackRender, FallbackComponent, fallback } = this.props;
const { didCatch, error } = this.state;

let childToRender = children;

if (didCatch) {
const props: FallbackProps = {
error,
resetErrorBoundary: this.resetErrorBoundary,
};

if (isValidElement(fallback)) {
childToRender = fallback;
} else if (typeof fallbackRender === "function") {
childToRender = fallbackRender(props);
} else if (FallbackComponent) {
childToRender = createElement(FallbackComponent, props);
} else {
throw error;
}
}

return createElement(
ErrorBoundaryContext.Provider,
{
value: {
didCatch,
error,
resetErrorBoundary: this.resetErrorBoundary,
},
},
childToRender,
);
}
}

function hasArrayChanged(a: unknown[] = [], b: unknown[] = []) {
return a.length !== b.length || a.some((item, index) => !Object.is(item, b[index]));
}
9 changes: 9 additions & 0 deletions packages/react-error-boundary/src/lib/ErrorBoundaryContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createContext } from "react";

export type ErrorBoundaryContextType = {
didCatch: boolean;
error: any;
resetErrorBoundary: (...args: any[]) => void;
};

export const ErrorBoundaryContext = createContext<ErrorBoundaryContextType | null>(null);
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ErrorBoundaryContextType } from "./ErrorBoundaryContext";

export function assertErrorBoundaryContext(value: any): asserts value is ErrorBoundaryContextType {
if (value == null || typeof value.didCatch !== "boolean" || typeof value.resetErrorBoundary !== "function") {
throw new Error("ErrorBoundaryContext not found");
}
}
49 changes: 49 additions & 0 deletions packages/react-error-boundary/src/lib/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
Component,
ComponentType,
ErrorInfo,
FunctionComponent,
PropsWithChildren,
ReactElement,
ReactNode,
} from "react";

declare function FallbackRender(props: FallbackProps): ReactNode;

export type FallbackProps = {
error: any;
resetErrorBoundary: (...args: any[]) => void;
};

type ErrorBoundarySharedProps = PropsWithChildren<{
onError?: (error: Error, info: ErrorInfo) => void;
onReset?: (
details:
| { reason: "imperative-api"; args: any[] }
| { reason: "keys"; prev: any[] | undefined; next: any[] | undefined },
) => void;
resetKeys?: any[];
}>;

export type ErrorBoundaryPropsWithComponent = ErrorBoundarySharedProps & {
fallback?: never;
FallbackComponent: ComponentType<FallbackProps>;
fallbackRender?: never;
};

export type ErrorBoundaryPropsWithRender = ErrorBoundarySharedProps & {
fallback?: never;
FallbackComponent?: never;
fallbackRender: typeof FallbackRender;
};

export type ErrorBoundaryPropsWithFallback = ErrorBoundarySharedProps & {
fallback: ReactElement<unknown, string | FunctionComponent | typeof Component> | null;
FallbackComponent?: never;
fallbackRender?: never;
};

export type ErrorBoundaryProps =
| ErrorBoundaryPropsWithFallback
| ErrorBoundaryPropsWithComponent
| ErrorBoundaryPropsWithRender;
42 changes: 42 additions & 0 deletions packages/react-error-boundary/src/lib/useErrorBoundary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useContext, useMemo, useState } from "react";
import { assertErrorBoundaryContext } from "./assertErrorBoundaryContext";
import { ErrorBoundaryContext } from "./ErrorBoundaryContext";

type UseErrorBoundaryState<TError> = { error: TError; hasError: true } | { error: null; hasError: false };

export type UseErrorBoundaryApi<TError> = {
resetBoundary: () => void;
showBoundary: (error: TError) => void;
};

export function useErrorBoundary<TError = any>(): UseErrorBoundaryApi<TError> {
const context = useContext(ErrorBoundaryContext);

assertErrorBoundaryContext(context);

const [state, setState] = useState<UseErrorBoundaryState<TError>>({
error: null,
hasError: false,
});

const memoized = useMemo(
() => ({
resetBoundary: () => {
context.resetErrorBoundary();
setState({ error: null, hasError: false });
},
showBoundary: (error: TError) =>
setState({
error,
hasError: true,
}),
}),
[context.resetErrorBoundary],
);

if (state.hasError) {
throw state.error;
}

return memoized;
}
26 changes: 26 additions & 0 deletions packages/react-error-boundary/src/lib/withErrorBoundary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {
createElement,
forwardRef,
ForwardedRef,
RefAttributes,
ForwardRefExoticComponent,
PropsWithoutRef,
ComponentType,
} from "react";
import { ErrorBoundary } from "./ErrorBoundary";
import { ErrorBoundaryProps } from "./types";

export function withErrorBoundary<Props extends object>(
component: ComponentType<Props>,
errorBoundaryProps: ErrorBoundaryProps,
): ForwardRefExoticComponent<PropsWithoutRef<Props> & RefAttributes<any>> {
const Wrapped = forwardRef<ComponentType<Props>, Props>((props: Props, ref: ForwardedRef<ComponentType<Props>>) =>
createElement(ErrorBoundary, errorBoundaryProps, createElement(component, { ...props, ref })),
);

// Format for display in DevTools
const name = component.displayName || component.name || "Unknown";
Wrapped.displayName = `withErrorBoundary(${name})`;

return Wrapped;
}
5 changes: 5 additions & 0 deletions packages/react-error-boundary/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "@marshallku/typescript-config/react-library.json",
"include": ["."],
"exclude": ["dist", "build", "node_modules"]
}
35 changes: 35 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 4916128

Please sign in to comment.