Skip to content

Commit

Permalink
[sitecore-jss-react] Introduce ErrorBoundary to serve as error handli…
Browse files Browse the repository at this point in the history
…ng per component (#1786)

* introduce error boundary component

* Added suspense and wrapped components by ErrorBoundary

* wrap components in error boundary component; error boundary component

* account for page editing state when displaying error for component

* ErrorBoundary unit tests and small fix

* add unit test for placeholder to cover error boundary component wrapping; add 'type' prop in ErrorBoundary to cover case when renderEach function is being used

* add configurable loading message for Suspense component

* update changelog

* rework showing of error component logic, adjust unit tests

* update text of error messages

* unit test fix

* specify proper types instead of any; reorder properties in props interface

---------

Co-authored-by: illiakovalenko <[email protected]>
  • Loading branch information
yavorsk and illiakovalenko authored May 2, 2024
1 parent 09561a0 commit 8ae58c1
Show file tree
Hide file tree
Showing 5 changed files with 389 additions and 1 deletion.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ Our versioning strategy is as follows:

* `[templates/nextjs]` `[templates/react]` `[templates/vue]` `[templates/angular]` Changed formatting in temp/config to prevent parse issues in Unix systems ([#1787](https://github.com/Sitecore/jss/pull/1787))

### 🎉 New Features & Improvements

* `[sitecore-jss-react]`Introduce ErrorBoundary component. All rendered components are wrapped with it and it will catch client or server side errors from any of its children, display appropriate message and prevent the rest of the application from failing. It accepts and can display custom error component and loading message if it is passed as a prop to parent Placeholder. ([#1786](https://github.com/Sitecore/jss/pull/1786))

## 22.0.0

### 🛠 Breaking Changes
Expand Down
243 changes: 243 additions & 0 deletions packages/sitecore-jss-react/src/components/ErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import React from 'react';
import { expect } from 'chai';
import { mount } from 'enzyme';
import { spy } from 'sinon';
import ErrorBoundary from './ErrorBoundary';
import { SitecoreContextReactContext } from '../components/SitecoreContext';
import { ComponentRendering } from '@sitecore-jss/sitecore-jss/layout';
import { afterEach } from 'node:test';

describe('ErrorBoundary', () => {
describe('when in page editing mode', () => {
it('Should render custom error component when custom error component is provided and error is thrown', () => {
const setContext = spy();

const testComponentProps = {
context: {
pageEditing: true,
},
setContext,
};

const testComponentName = 'Test component Name';
const rendering: ComponentRendering = { componentName: testComponentName };

const errorMessage = 'an error occured';
const TestErrorComponent: React.FC = () => {
throw Error(errorMessage);
};

const CustomErrorComponent: React.FC = () => {
return <div>This is a custom error component!</div>;
};

const rendered = mount(
<SitecoreContextReactContext.Provider value={testComponentProps}>
<ErrorBoundary rendering={rendering} customErrorComponent={CustomErrorComponent}>
<TestErrorComponent />
</ErrorBoundary>
</SitecoreContextReactContext.Provider>
);

expect(rendered.find('div').length).to.equal(1);
expect(rendered.find('div').text()).to.equal('This is a custom error component!');
});

it('Should render errors message and errored component name when error is thrown', () => {
const setContext = spy();

const testComponentProps = {
context: {
pageEditing: true,
},
setContext,
};

const testComponentName = 'Test component Name';
const rendering: ComponentRendering = { componentName: testComponentName };

const errorMessage = 'an error occured';
const TestErrorComponent: React.FC = () => {
throw Error(errorMessage);
};

const rendered = mount(
<SitecoreContextReactContext.Provider value={testComponentProps}>
<ErrorBoundary rendering={rendering}>
<TestErrorComponent />
</ErrorBoundary>
</SitecoreContextReactContext.Provider>
);

expect(rendered.html()).to.contain('class="sc-jss-placeholder-error"');
expect(rendered.html()).to.contain('A rendering error occurred in component');
expect(rendered.find('em').length).to.equal(2);
expect(
rendered
.find('em')
.at(0)
.text()
).to.equal(testComponentName);
expect(
rendered
.find('em')
.at(1)
.text()
).to.equal(errorMessage);
});
});
describe('when in development mode', () => {
before(() => {
process.env.NODE_ENV = 'development';
});

after(() => {
delete process.env.NODE_ENV;
});

it('Should render custom error component when custom error component is provided and error is thrown', () => {
const errorMessage = 'an error occured';
const TestErrorComponent: React.FC = () => {
throw Error(errorMessage);
};

const CustomErrorComponent: React.FC = () => {
return <div>This is a custom error component!</div>;
};

const rendered = mount(
<ErrorBoundary customErrorComponent={CustomErrorComponent}>
<TestErrorComponent />
</ErrorBoundary>
);
expect(rendered.find('div').length).to.equal(1);
expect(rendered.find('div').text()).to.equal('This is a custom error component!');
});

it('Should render errors message and errored component name when error is thrown and is in page editing mode', () => {
const setContext = spy();

const testComponentProps = {
context: {
pageEditing: true,
},
setContext,
};

const testComponentName = 'Test component Name';
const rendering: ComponentRendering = { componentName: testComponentName };

const errorMessage = 'an error occured';
const TestErrorComponent: React.FC = () => {
throw Error(errorMessage);
};

const rendered = mount(
<SitecoreContextReactContext.Provider value={testComponentProps}>
<ErrorBoundary rendering={rendering}>
<TestErrorComponent />
</ErrorBoundary>
</SitecoreContextReactContext.Provider>
);

expect(rendered.html()).to.contain('class="sc-jss-placeholder-error"');
expect(rendered.html()).to.contain('A rendering error occurred in component');
expect(rendered.find('em').length).to.equal(2);
expect(
rendered
.find('em')
.at(0)
.text()
).to.equal(testComponentName);
expect(
rendered
.find('em')
.at(1)
.text()
).to.equal(errorMessage);
});

it('Should render errors message and errored component name when error is thrown and is not in page editing mode', () => {
const setContext = spy();

const testComponentProps = {
context: {
pageEditing: false,
},
setContext,
};

const testComponentName = 'Test component Name';
const rendering: ComponentRendering = { componentName: testComponentName };

const errorMessage = 'an error occured';
const TestErrorComponent: React.FC = () => {
throw Error(errorMessage);
};

const rendered = mount(
<SitecoreContextReactContext.Provider value={testComponentProps}>
<ErrorBoundary rendering={rendering}>
<TestErrorComponent />
</ErrorBoundary>
</SitecoreContextReactContext.Provider>
);

expect(rendered.html()).to.contain('class="sc-jss-placeholder-error"');
expect(rendered.html()).to.contain('A rendering error occurred in component');
expect(rendered.find('em').length).to.equal(2);
expect(
rendered
.find('em')
.at(0)
.text()
).to.equal(testComponentName);
expect(
rendered
.find('em')
.at(1)
.text()
).to.equal(errorMessage);
});
});
describe('when not in page editing and not in development mode', () => {
it('Should render custom error component when custom error component is provided and error is thrown', () => {
const errorMessage = 'an error occured';
const TestErrorComponent: React.FC = () => {
throw Error(errorMessage);
};

const CustomErrorComponent: React.FC = () => {
return <div>This is a custom error component!</div>;
};

const rendered = mount(
<ErrorBoundary customErrorComponent={CustomErrorComponent}>
<TestErrorComponent />
</ErrorBoundary>
);
expect(rendered.find('div').length).to.equal(1);
expect(rendered.find('div').text()).to.equal('This is a custom error component!');
});

it('Should render default errors message when error is thrown and custom error component is not provided', () => {
const errorMessage = 'an error occured';
const TestErrorComponent: React.FC = () => {
throw Error(errorMessage);
};

const rendered = mount(
<ErrorBoundary>
<TestErrorComponent />
</ErrorBoundary>
);
console.log(rendered.html());
expect(rendered.html()).to.contain('class="sc-jss-placeholder-error"');
expect(rendered.html()).to.contain(
'There was a problem loading this section. Refresh your browser to try again.' // eslint-disable-line
);
expect(rendered.find('em').length).to.equal(0);
expect(rendered.html()).to.not.contain(errorMessage);
});
});
});
80 changes: 80 additions & 0 deletions packages/sitecore-jss-react/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import React, { ReactNode, Suspense } from 'react';
import { ComponentRendering } from '@sitecore-jss/sitecore-jss/layout';
import { withSitecoreContext } from '../enhancers/withSitecoreContext';
import { SitecoreContextValue } from './SitecoreContext';

type CustomErrorComponentProps = {
[prop: string]: unknown;
};

export type ErrorBoundaryProps = {
children: ReactNode;
sitecoreContext: SitecoreContextValue;
type: string;
customErrorComponent?:
| React.ComponentClass<CustomErrorComponentProps>
| React.FC<CustomErrorComponentProps>;
rendering?: ComponentRendering;
componentLoadingMessage?: string;
};

class ErrorBoundary extends React.Component<ErrorBoundaryProps> {
defaultErrorMessage =
'There was a problem loading this section. Refresh your browser to try again.'; // eslint-disable-line
defaultLoadingMessage = 'Loading component...';
state: { error: Error };

constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { error: null };
}

static getDerivedStateFromError(error: Error) {
return { error: error };
}

componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error({ error, errorInfo });
}

isInDevMode(): boolean {
return process.env.NODE_ENV === 'development';
}

render() {
if (this.state.error) {
if (this.props.customErrorComponent) {
return <this.props.customErrorComponent error={this.state.error} />;
} else {
if (this.isInDevMode() || this.props.sitecoreContext?.pageEditing) {
return (
<div>
<div className="sc-jss-placeholder-error">
A rendering error occurred in component{' '}
<em>{this.props.rendering?.componentName}</em>
<br />
Error: <em>{this.state.error.message}</em>
</div>
</div>
);
} else {
return (
<div>
<div className="sc-jss-placeholder-error">{this.defaultErrorMessage}</div>
</div>
);
}
}
}

return (
<Suspense
fallback={<h4>{this.props.componentLoadingMessage || this.defaultLoadingMessage}</h4>}
>
{this.props.children}
</Suspense>
);
}
}

export default withSitecoreContext()(ErrorBoundary);
40 changes: 40 additions & 0 deletions packages/sitecore-jss-react/src/components/Placeholder.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,46 @@ describe('<Placeholder />', () => {
expect(renderedComponent.find('.sc-jss-placeholder-error').length).to.equal(1);
});

it('should render error message on error, only for the errored component', () => {
const componentFactory: ComponentFactory = (componentName: string) => {
const components = new Map<string, React.FC>();

const Home: React.FC<{ rendering?: RouteData }> = ({ rendering }) => (
<div className="home-mock">
<Placeholder name="main" rendering={rendering} />
</div>
);

components.set('Home', Home);
components.set('ThrowError', () => {
throw Error('an error occured');
});
components.set('Foo', () => <div className="foo-class">foo</div>);

return components.get(componentName) || null;
};

const route = ({
placeholders: {
main: [
{
componentName: 'ThrowError',
},
{
componentName: 'Foo',
},
],
},
} as unknown) as RouteData;
const phKey = 'main';

const renderedComponent = mount(
<Placeholder name={phKey} rendering={route} componentFactory={componentFactory} />
);
expect(renderedComponent.find('.sc-jss-placeholder-error').length).to.equal(1);
expect(renderedComponent.find('div.foo-class').length).to.equal(1);
});

it('should render custom errorComponent on error, if provided', () => {
const componentFactory: ComponentFactory = (componentName: string) => {
const components = new Map<string, React.FC<{ [key: string]: unknown }>>();
Expand Down
Loading

0 comments on commit 8ae58c1

Please sign in to comment.