From 4c079044f6ae2381c6054d8d77414100152d1d19 Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Thu, 3 Dec 2020 09:23:43 -0300 Subject: [PATCH] feat: Add error boundary and retry logic for network failures during dynamic imports (#2145) Co-authored-by: Davide Punzo --- extensions/vtk/src/asyncComponent.js | 37 ----------- extensions/vtk/src/index.js | 5 +- .../ui/src/components/errorPage/ErrorPage.css | 24 +++++++ .../ui/src/components/errorPage/ErrorPage.js | 41 ++++++++++++ platform/ui/src/components/errorPage/index.js | 2 + platform/ui/src/components/index.js | 2 + platform/ui/src/index.js | 10 ++- .../utils/asyncComponent/asyncComponent.js | 62 +++++++++++++++++++ platform/ui/src/utils/asyncComponent/index.js | 2 + platform/viewer/src/OHIFStandaloneViewer.js | 13 ++-- .../viewer/src/components/AsyncComponent.js | 37 ----------- platform/viewer/src/routes/routesUtil.js | 22 +++---- 12 files changed, 161 insertions(+), 96 deletions(-) delete mode 100644 extensions/vtk/src/asyncComponent.js create mode 100644 platform/ui/src/components/errorPage/ErrorPage.css create mode 100644 platform/ui/src/components/errorPage/ErrorPage.js create mode 100644 platform/ui/src/components/errorPage/index.js create mode 100644 platform/ui/src/utils/asyncComponent/asyncComponent.js create mode 100644 platform/ui/src/utils/asyncComponent/index.js delete mode 100644 platform/viewer/src/components/AsyncComponent.js diff --git a/extensions/vtk/src/asyncComponent.js b/extensions/vtk/src/asyncComponent.js deleted file mode 100644 index e8f344a35..000000000 --- a/extensions/vtk/src/asyncComponent.js +++ /dev/null @@ -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 ? : null; - } - } - - return AsyncComponent; -} diff --git a/extensions/vtk/src/index.js b/extensions/vtk/src/index.js index e68552069..dde0d6ce0 100644 --- a/extensions/vtk/src/index.js +++ b/extensions/vtk/src/index.js @@ -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 = { diff --git a/platform/ui/src/components/errorPage/ErrorPage.css b/platform/ui/src/components/errorPage/ErrorPage.css new file mode 100644 index 000000000..98c938bcc --- /dev/null +++ b/platform/ui/src/components/errorPage/ErrorPage.css @@ -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; +} diff --git a/platform/ui/src/components/errorPage/ErrorPage.js b/platform/ui/src/components/errorPage/ErrorPage.js new file mode 100644 index 000000000..ce15f1274 --- /dev/null +++ b/platform/ui/src/components/errorPage/ErrorPage.js @@ -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 ( +
+ {title &&

{title}

} +

{description}

+ + {error && ( +
+
{error.message}
+
{error.stack}
+
+ )} +
+ ); +}; + +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; diff --git a/platform/ui/src/components/errorPage/index.js b/platform/ui/src/components/errorPage/index.js new file mode 100644 index 000000000..0ad40318a --- /dev/null +++ b/platform/ui/src/components/errorPage/index.js @@ -0,0 +1,2 @@ +import ErrorPage from './ErrorPage'; +export default ErrorPage; diff --git a/platform/ui/src/components/index.js b/platform/ui/src/components/index.js index c3853be93..bc62197cb 100644 --- a/platform/ui/src/components/index.js +++ b/platform/ui/src/components/index.js @@ -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 }; diff --git a/platform/ui/src/index.js b/platform/ui/src/index.js index 36237ac06..e95547357 100644 --- a/platform/ui/src/index.js +++ b/platform/ui/src/index.js @@ -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 }; diff --git a/platform/ui/src/utils/asyncComponent/asyncComponent.js b/platform/ui/src/utils/asyncComponent/asyncComponent.js new file mode 100644 index 000000000..7e544883e --- /dev/null +++ b/platform/ui/src/utils/asyncComponent/asyncComponent.js @@ -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 ? : null; +}; + +export default asyncComponent; diff --git a/platform/ui/src/utils/asyncComponent/index.js b/platform/ui/src/utils/asyncComponent/index.js new file mode 100644 index 000000000..abb28f96e --- /dev/null +++ b/platform/ui/src/utils/asyncComponent/index.js @@ -0,0 +1,2 @@ +export { default as asyncComponent } from './asyncComponent'; +export { retryImport } from './asyncComponent'; diff --git a/platform/viewer/src/OHIFStandaloneViewer.js b/platform/viewer/src/OHIFStandaloneViewer.js index 7bddfb9d6..ae3af51ad 100644 --- a/platform/viewer/src/OHIFStandaloneViewer.js +++ b/platform/viewer/src/OHIFStandaloneViewer.js @@ -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 ? ( <> ) : ( - - - - )} + + + + )} )} diff --git a/platform/viewer/src/components/AsyncComponent.js b/platform/viewer/src/components/AsyncComponent.js deleted file mode 100644 index e8f344a35..000000000 --- a/platform/viewer/src/components/AsyncComponent.js +++ /dev/null @@ -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 ? : null; - } - } - - return AsyncComponent; -} diff --git a/platform/viewer/src/routes/routesUtil.js b/platform/viewer/src/routes/routesUtil.js index bea1c110d..8601edf10 100644 --- a/platform/viewer/src/routes/routesUtil.js +++ b/platform/viewer/src/routes/routesUtil.js @@ -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();