-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
Showing
5 changed files
with
418 additions
and
59 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
Oops, something went wrong.