feat: Add error boundary and retry logic for network failures during dynamic imports (#2145)

Co-authored-by: Davide Punzo <punzodavide@hotmail.it>
This commit is contained in:
Igor Octaviano 2020-12-03 09:23:43 -03:00 committed by GitHub
parent 81fcfc7370
commit 4c079044f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 161 additions and 96 deletions

View File

@ -1,37 +0,0 @@
/**
* We use this component to leverage "Code Splitting"
*
* Link: https://serverless-stack.com/chapters/code-splitting-in-create-react-app.html
*/
import React, { Component } from 'react';
export default function asyncComponent(importComponent) {
class AsyncComponent extends Component {
constructor(props) {
super(props);
this.state = {
component: null,
};
}
async componentDidMount() {
// Add dynamically loaded component to state
const { default: component } = await importComponent();
this.setState({
component: component,
});
}
render() {
const C = this.state.component;
// Render the loaded component, or null
return C ? <C {...this.props} /> : null;
}
}
return AsyncComponent;
}

View File

@ -1,5 +1,6 @@
import React from 'react';
import asyncComponent from './asyncComponent.js';
import { asyncComponent, retryImport } from '@ohif/ui';
import commandsModule from './commandsModule.js';
import toolbarModule from './toolbarModule.js';
import withCommandsManager from './withCommandsManager.js';
@ -8,7 +9,7 @@ import { version } from '../package.json';
// import loadLocales from './loadLocales';
const OHIFVTKViewport = asyncComponent(() =>
import(/* webpackChunkName: "OHIFVTKViewport" */ './OHIFVTKViewport.js')
retryImport(() => import(/* webpackChunkName: "OHIFVTKViewport" */ './OHIFVTKViewport.js'))
);
const vtkExtension = {

View File

@ -0,0 +1,24 @@
.ErrorPage {
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
color: var(--active-color);
}
.ErrorPage .error-container {
margin: 10px;
width: 50%;
height: 25%;
overflow: scroll;
border-radius: 15px;
border-color: var(--active-color);
border: 1px solid;
padding: 5px;
}
.ErrorPage .retry-icon {
cursor: pointer;
}

View File

@ -0,0 +1,41 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Icon } from '@ohif/ui';
import './ErrorPage.css';
const ErrorPage = ({ error, title, description, onRetry }) => {
return (
<div className="ErrorPage">
{title && <h3>{title}</h3>}
<p>{description}</p>
<Icon
className="retry-icon"
name="rotate-right"
width="25px"
height="25px"
onClick={onRetry}
/>
{error && (
<div className="error-container">
<pre>{error.message}</pre>
<pre>{error.stack}</pre>
</div>
)}
</div>
);
};
ErrorPage.propTypes = {
error: PropTypes.object,
title: PropTypes.string,
description: PropTypes.string,
onRetry: PropTypes.func
};
ErrorPage.defaultProps = {
description: 'Oh snap, something went wrong, please try reloading',
onRetry: () => window.location.reload()
};
export default ErrorPage;

View File

@ -0,0 +1,2 @@
import ErrorPage from './ErrorPage';
export default ErrorPage;

View File

@ -16,6 +16,7 @@ import { SelectTree } from './selectTree';
import { SimpleDialog } from './simpleDialog';
import { OHIFModal } from './ohifModal';
import { ContextMenu } from './contextMenu';
import ErrorPage from './errorPage';
import {
PageToolbar,
StudyList,
@ -58,4 +59,5 @@ export {
Tooltip,
AboutContent,
OHIFModal,
ErrorPage
};

View File

@ -29,7 +29,8 @@ import {
Tooltip,
AboutContent,
OHIFModal,
ErrorBoundary
ErrorBoundary,
ErrorPage
} from './components';
import { useDebounce, useMedia } from './hooks';
@ -53,6 +54,7 @@ import { ScrollableArea } from './ScrollableArea/ScrollableArea.js';
import Toolbar from './viewer/Toolbar.js';
import ToolbarButton from './viewer/ToolbarButton.js';
import ViewerbaseDragDropContext from './utils/viewerbaseDragDropContext.js';
import { asyncComponent, retryImport } from './utils/asyncComponent';
import {
SnackbarProvider,
useSnackbarContext,
@ -112,7 +114,6 @@ export {
ToolbarSection,
Tooltip,
AboutContent,
ViewerbaseDragDropContext,
SnackbarProvider,
useSnackbarContext,
withSnackbar,
@ -125,7 +126,12 @@ export {
withDialog,
useDialog,
ErrorBoundary,
ErrorPage,
// Hooks
useDebounce,
useMedia,
// Utils
ViewerbaseDragDropContext,
asyncComponent,
retryImport
};

View File

@ -0,0 +1,62 @@
import React, { useState, useEffect } from 'react';
import { ErrorPage } from '@ohif/ui';
export const retryImport = (fn, retriesLeft = 5, interval = 1000) =>
new Promise((resolve, reject) => {
fn().then(resolve).catch((error) => {
setTimeout(() => {
if (retriesLeft === 1) {
/* reject('maximum retries exceeded'); */
reject(error);
return;
}
/* Passing on "reject" is the important part */
retry(fn, retriesLeft - 1, interval).then(resolve, reject);
}, interval);
});
});
const onError = (error, setState) => setState({ component: ErrorPage });
/**
* We use this function to lazy load the import of a component to leverage 'Code Splitting'
* Link: https://serverless-stack.com/chapters/code-splitting-in-create-react-app.html
*/
const asyncComponent = (importComponent, options = { onError }) => props => {
const [state, setState] = useState({ component: null });
const isFunction = item => typeof item === 'function';
const isChunkError = error => error.toString().indexOf('ChunkLoadError') > -1;
useEffect(() => {
const addDynamicallyLoadedComponentToState = async () => {
try {
const { default: component } = await importComponent();
setState({ component });
if (options.onLoaded && isFunction(options.onLoaded)) {
options.onLoaded(component);
}
} catch (error) {
console.error('[AsyncComponent] Failed to import chunk:', error);
if (options.onError && isFunction(options.onError)) {
options.onError(error, setState);
return;
}
if (isChunkError(error)) {
console.error('[AsyncComponent] Reloading due to chunk error');
window.location.reload();
}
}
};
addDynamicallyLoadedComponentToState();
}, []);
const Component = state.component;
return Component ? <Component {...props} /> : null;
};
export default asyncComponent;

View File

@ -0,0 +1,2 @@
export { default as asyncComponent } from './asyncComponent';
export { retryImport } from './asyncComponent';

View File

@ -5,9 +5,8 @@ import { Route, Switch } from 'react-router-dom';
import { NProgress } from '@tanem/react-nprogress';
import { CSSTransition } from 'react-transition-group';
import { connect } from 'react-redux';
import { ViewerbaseDragDropContext, ErrorBoundary } from '@ohif/ui';
import { ViewerbaseDragDropContext, ErrorBoundary, asyncComponent, retryImport } from '@ohif/ui';
import { SignoutCallbackComponent } from 'redux-oidc';
import asyncComponent from './components/AsyncComponent.js';
import * as RoutesUtil from './routes/routesUtil';
import NotFound from './routes/NotFound.js';
@ -18,7 +17,7 @@ import './theme-tide.css';
// Contexts
import AppContext from './context/AppContext';
const CallbackPage = asyncComponent(() =>
import(/* webpackChunkName: "CallbackPage" */ './routes/CallbackPage.js')
retryImport(() => import(/* webpackChunkName: "CallbackPage" */ './routes/CallbackPage.js'))
);
class OHIFStandaloneViewer extends Component {
@ -203,10 +202,10 @@ class OHIFStandaloneViewer extends Component {
{match === null ? (
<></>
) : (
<ErrorBoundary context={match.url}>
<Component match={match} location={this.props.location} />
</ErrorBoundary>
)}
<ErrorBoundary context={match.url}>
<Component match={match} location={this.props.location} />
</ErrorBoundary>
)}
</CSSTransition>
)}
</Route>

View File

@ -1,37 +0,0 @@
/**
* We use this component to leverage "Code Splitting"
*
* Link: https://serverless-stack.com/chapters/code-splitting-in-create-react-app.html
*/
import React, { Component } from 'react';
export default function asyncComponent(importComponent) {
class AsyncComponent extends Component {
constructor(props) {
super(props);
this.state = {
component: null,
};
}
async componentDidMount() {
// Add dynamically loaded component to state
const { default: component } = await importComponent();
this.setState({
component: component,
});
}
render() {
const C = this.state.component;
// Render the loaded component, or null
return C ? <C {...this.props} /> : null;
}
}
return AsyncComponent;
}

View File

@ -1,32 +1,32 @@
import asyncComponent from '../components/AsyncComponent.js';
import { asyncComponent, retryImport } from '@ohif/ui';
import OHIF from '@ohif/core';
const { urlUtil: UrlUtil } = OHIF.utils;
// Dynamic Import Routes (CodeSplitting)
const IHEInvokeImageDisplay = asyncComponent(() =>
import(
/* webpackChunkName: "IHEInvokeImageDisplay" */ './IHEInvokeImageDisplay.js'
retryImport(() =>
import(/* webpackChunkName: "IHEInvokeImageDisplay" */ './IHEInvokeImageDisplay.js')
)
);
const ViewerRouting = asyncComponent(() =>
import(/* webpackChunkName: "ViewerRouting" */ './ViewerRouting.js')
retryImport(() => import(/* webpackChunkName: "ViewerRouting" */ './ViewerRouting.js'))
);
const StudyListRouting = asyncComponent(() =>
import(
retryImport(() => import(
/* webpackChunkName: "StudyListRouting" */ '../studylist/StudyListRouting.js'
)
))
);
const StandaloneRouting = asyncComponent(() =>
import(
retryImport(() => import(
/* webpackChunkName: "ConnectedStandaloneRouting" */ '../connectedComponents/ConnectedStandaloneRouting.js'
)
))
);
const ViewerLocalFileData = asyncComponent(() =>
import(
retryImport(() => import(
/* webpackChunkName: "ViewerLocalFileData" */ '../connectedComponents/ViewerLocalFileData.js'
)
))
);
const reload = () => window.location.reload();