Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[sitecore-jss-react] Introduce ErrorBoundary to serve as error handling per component #1786

Merged
merged 14 commits into from
May 2, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ Our versioning strategy is as follows:

## Unreleased

### 🎉 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;
customErrorComponent?:
| React.ComponentClass<CustomErrorComponentProps>
| React.FC<CustomErrorComponentProps>;
rendering?: ComponentRendering;
sitecoreContext: SitecoreContextValue;
componentLoadingMessage?: string;
type: string;
addy-pathania marked this conversation as resolved.
Show resolved Hide resolved
};

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: any) {
addy-pathania marked this conversation as resolved.
Show resolved Hide resolved
super(props);
this.state = { error: null };
}

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

componentDidCatch(error: Error, errorInfo: any) {
addy-pathania marked this conversation as resolved.
Show resolved Hide resolved
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