Skip to content

Commit

Permalink
#51 Split view component into View and View.ErrorBoundary (#52)
Browse files Browse the repository at this point in the history
* #51 Split view component into View and View.ErrorBoundary

* #51 Exclude ErrorBoundary to own independent  component

* #51 Make View.ErrorBoundary functional component

* #51 Setup for DOM testing and add test for ErrorBoundary

* #51 Downgrade JEST back to original version

* #51 Refactor based on review comments

* #51 Fix tests and change fallback from ReactChildren to ReactNode

* #51 Render children inside View component when VM is not presented

* #51 Make resetErrorBoundary readonly

Co-authored-by: Michal Kopecký <[email protected]>
  • Loading branch information
kopecmi8 and Michal Kopecký authored Oct 7, 2021
1 parent b395e8b commit 6aa967a
Show file tree
Hide file tree
Showing 5 changed files with 418 additions and 59 deletions.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
},
"devDependencies": {
"@emanprague/eslint-config": "^1.0.2",
"@testing-library/react": "^12.1.1",
"@types/jest": "^24.9.1",
"@types/prettier": "^2.0.1",
"@types/rimraf": "^3.0.0",
Expand All @@ -57,6 +58,7 @@
},
"peerDependencies": {
"mobx": "^4.15.4",
"react": "^16.13.0"
"react": "^16.13.0",
"react-dom": "^16.13.0"
}
}
183 changes: 183 additions & 0 deletions packages/views/__tests__/errorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/**
* @jest-environment jsdom
*/
import * as React from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { bound } from "@frui.ts/helpers";
import ErrorBoundary, { ErrorBoundaryProps } from "../src/errorBoundary";

function SimulateError({ title }: { title: string }): JSX.Element {
throw new Error(title);
}

function Test(): JSX.Element {
return <SimulateError title={"errorMessage"} />;
}

class TestApp extends React.Component<ErrorBoundaryProps, { error: boolean }> {
state = {
error: false,
};

@bound
protected setError(error: boolean) {
this.setState({ error });
}

render() {
return (
<ErrorBoundary
{...this.props}
onReset={(...args) => {
this.setError(false);
if (this.props.onReset) {
this.props.onReset(...args);
}
}}>
{this.state.error ? <Test /> : this.props.children}
<button
data-testid="raiseErrorBtn"
onClick={() => {
this.setError(true);
}}>
Raise error
</button>
</ErrorBoundary>
);
}
}

describe("ErrorBoundary", () => {
it("renders children correctly when there is no error", () => {
const { container } = render(
<ErrorBoundary fallback={<h1>Error Component</h1>}>
<h1>children</h1>
</ErrorBoundary>
);

expect(container.innerHTML).toBe("<h1>children</h1>");
});

it("renders 'Something went wrong :-(' if not given `fallback` prop", () => {
const renderResult = render(
<ErrorBoundary>
<Test />
</ErrorBoundary>
);

expect(renderResult.container.innerHTML).toBe("<p>Something went wrong :-(</p>");
});

it("renders a fallback string when error occur", () => {
const renderResult = render(
<ErrorBoundary fallback={"Error occur"}>
<Test />
</ErrorBoundary>
);

expect(renderResult.container.innerHTML).toBe("Error occur");
});

it("renders a fallback component on error", () => {
const { container } = render(
<ErrorBoundary fallback={<h1>Error Component</h1>}>
<Test />
</ErrorBoundary>
);
expect(container.innerHTML).toBe("<h1>Error Component</h1>");
});

it("renders a fallback component when error occur", async () => {
const { container } = render(
<TestApp fallback={<p>You have hit an error</p>}>
<h1>children</h1>
</TestApp>
);

expect(container.innerHTML).toContain("<h1>children</h1>");

const btn = screen.getByTestId("raiseErrorBtn");
fireEvent.click(btn);

expect(container.innerHTML).not.toContain("<h1>children</h1>");
expect(container.innerHTML).toBe("<p>You have hit an error</p>");
});

it("renders fallback as function", async () => {
let errorString = "";
let componentStackString = "";
const { container } = render(
<TestApp
fallback={({ error, errorInfo }) => {
errorString = error.toString();
componentStackString = errorInfo?.componentStack || "";
return <div>Fallback here</div>;
}}>
<h1>children</h1>
</TestApp>
);

expect(container.innerHTML).toContain("<h1>children</h1>");

const btn = screen.getByTestId("raiseErrorBtn");
fireEvent.click(btn);

expect(container.innerHTML).not.toContain("<h1>children</h1>");
expect(container.innerHTML).toBe("<div>Fallback here</div>");
expect(errorString).toBe("Error: errorMessage");
/*
in SimulateError
in Test
in ErrorBoundary
in TestApp
*/
expect(componentStackString).toMatch(
/\s*(in SimulateError)([a-z]|[A-Z]|\(|\)| )*\s*(in Test)([a-z]|[A-Z]|\(|\)| )*\s*(in ErrorBoundary)([a-z]|[A-Z]|\(|\)| )*\s*(in TestApp)([a-z]|[A-Z]|\(|\)| )*/g
);
});

it("renders children component after reset from error", async () => {
const { container } = render(
<TestApp
fallback={({ resetError }) => {
return (
<button data-testid="resetErrorBtn" onClick={resetError}>
Reset error
</button>
);
}}>
<h1>children</h1>
</TestApp>
);

expect(container.innerHTML).toContain("<h1>children</h1>");

const btn = screen.getByTestId("raiseErrorBtn");
fireEvent.click(btn);

expect(container.innerHTML).not.toContain("<h1>children</h1>");
expect(container.innerHTML).toContain('<button data-testid="resetErrorBtn">Reset error</button>');

const resetErrorBtn = screen.getByTestId("resetErrorBtn");
fireEvent.click(resetErrorBtn);

expect(container.innerHTML).toContain("<h1>children</h1>");
});

it("calls `componentDidCatch() when an error occurs`", () => {
const mockOnError = jest.fn();
render(
<TestApp fallback={<p>You have hit an error</p>} onError={mockOnError}>
<h1>children</h1>
</TestApp>
);

expect(mockOnError).toHaveBeenCalledTimes(0);

const btn = screen.getByTestId("raiseErrorBtn");
fireEvent.click(btn);

expect(mockOnError).toHaveBeenCalledTimes(1);
expect(mockOnError).toHaveBeenCalledWith(expect.any(Error), expect.any(Object));
});
});
62 changes: 62 additions & 0 deletions packages/views/src/errorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import * as React from "react";

export type FallbackRender = (errorData: {
error: Error;
errorInfo: React.ErrorInfo | null;
resetError(): void;
}) => React.ReactNode;

export interface ErrorBoundaryProps {
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
/** Called if resetError() is called from the fallback render props function */
onReset?(error: Error | null, errorInfo: React.ErrorInfo | null): void;
fallback?: React.ReactNode | FallbackRender;
}

interface ErrorBoundaryState {
error: Error | null;
errorInfo: React.ErrorInfo | null;
}

const INITIAL_STATE = Object.freeze({
errorInfo: null,
error: null,
});

export default class ErrorBoundary extends React.PureComponent<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = INITIAL_STATE;

public readonly resetErrorBoundary = () => {
const { onReset } = this.props;

if (onReset) {
const { error, errorInfo } = this.state;
onReset(error, errorInfo);
}

this.setState(INITIAL_STATE);
};

componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
this.props.onError?.(error, errorInfo);

this.setState({ error, errorInfo });
}

render() {
if (this.state.error) {
const { error, errorInfo } = this.state;
const { fallback } = this.props;
const element =
typeof fallback === "function" ? fallback({ error, errorInfo, resetError: this.resetErrorBoundary }) : fallback;

if (element !== undefined) {
return element;
}

return <p>Something went wrong :-(</p>;
}

return <React.Fragment>{this.props.children}</React.Fragment>;
}
}
84 changes: 26 additions & 58 deletions packages/views/src/view.tsx
Original file line number Diff line number Diff line change
@@ -1,81 +1,49 @@
import { isActivatable, isDeactivatable } from "@frui.ts/screens";
import * as React from "react";
import ErrorBoundary, { ErrorBoundaryProps } from "./errorBoundary";
import useScreenLifecycle from "./useScreenLifecycle";
import { getView, tryGetView } from "./viewLocator";

interface ViewProps {
vm: any;
context?: string;
useLifecycle?: boolean;
fallbackMode?: "message" | "children";
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}

interface ViewState {
hasError: boolean;
}

export default class View extends React.PureComponent<ViewProps, ViewState> {
static defaultProps: Partial<ViewProps> = {
fallbackMode: "children",
};

static getDerivedStateFromError(error: any) {
return { hasError: true };
}
const PureView: React.FunctionComponent<ViewProps> = props => {
const { vm, children, context, useLifecycle } = props;

componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
this.props.onError?.(error, errorInfo);
if (!vm) {
return <React.Fragment>{children}</React.Fragment>;
}

componentDidMount() {
this.tryActivateViewModel();
}
const FoundView = children === undefined ? getView(vm.constructor, context) : tryGetView(vm.constructor, context);

componentDidUpdate(prevProps: ViewProps) {
if (prevProps.vm !== this.props.vm) {
this.tryActivateViewModel();
}
if (!FoundView) {
return <React.Fragment>{children}</React.Fragment>;
}

componentWillUnmount() {
if (this.props.useLifecycle) {
const { vm } = this.props;
if (vm && isDeactivatable(vm)) {
vm.deactivate(true);
}
}
if (!!useLifecycle) {
useScreenLifecycle(vm);
}

render() {
if (this.state?.hasError) {
return <p>Something went wrong :-(</p>;
}
return <FoundView vm={vm} />;
};

const { vm, context, fallbackMode, children } = this.props;
PureView.displayName = "View";

if (!vm) {
return <React.Fragment>{children}</React.Fragment>;
}
const ViewWithErrorBoundary: React.FunctionComponent<ViewProps & ErrorBoundaryProps> = props => {
const { onError, onReset, fallback, ...rest } = props;

const FoundView = fallbackMode ? tryGetView(vm.constructor, context) : getView(vm.constructor, context);
return (
<ErrorBoundary onError={onError} onReset={onReset} fallback={fallback}>
<PureView {...rest} />
</ErrorBoundary>
);
};

if (!FoundView) {
return fallbackMode === "message" ? (
<p>Could not find a view for {vm.constructor.name}</p>
) : (
<React.Fragment>{children}</React.Fragment>
);
}
ViewWithErrorBoundary.displayName = "View.ErrorBoundary";

return <FoundView vm={vm} />;
}
const View = PureView as React.FunctionComponent<ViewProps> & { ErrorBoundary: typeof ViewWithErrorBoundary };
View.ErrorBoundary = ViewWithErrorBoundary;

protected tryActivateViewModel() {
if (this.props.useLifecycle) {
const { vm } = this.props;
if (vm && isActivatable(vm)) {
vm.activate();
}
}
}
}
export default View;
Loading

0 comments on commit 6aa967a

Please sign in to comment.