Add error boundary around routes in web UI (#19412)
* Add error boundary around routes in web UI * Update app/javascript/mastodon/features/ui/util/react_router_helpers.js Co-authored-by: Yamagishi Kazutoshi <ykzts@desire.sh> * Update app/javascript/mastodon/features/ui/util/react_router_helpers.js Co-authored-by: Yamagishi Kazutoshi <ykzts@desire.sh> * Update app/javascript/mastodon/features/ui/components/bundle_column_error.js Co-authored-by: Yamagishi Kazutoshi <ykzts@desire.sh> Co-authored-by: Yamagishi Kazutoshi <ykzts@desire.sh>
This commit is contained in:
		
					parent
					
						
							
								56efa8d22f
							
						
					
				
			
			
				commit
				
					
						a43a823768
					
				
			
		
					 4 changed files with 222 additions and 34 deletions
				
			
		|  | @ -1,44 +1,155 @@ | ||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import { defineMessages, injectIntl } from 'react-intl'; | import { injectIntl, FormattedMessage } from 'react-intl'; | ||||||
| import Column from 'mastodon/components/column'; | import Column from 'mastodon/components/column'; | ||||||
| import ColumnHeader from 'mastodon/components/column_header'; | import Button from 'mastodon/components/button'; | ||||||
| import IconButton from 'mastodon/components/icon_button'; |  | ||||||
| import { Helmet } from 'react-helmet'; | import { Helmet } from 'react-helmet'; | ||||||
|  | import { Link } from 'react-router-dom'; | ||||||
|  | import classNames from 'classnames'; | ||||||
|  | import { autoPlayGif } from 'mastodon/initial_state'; | ||||||
| 
 | 
 | ||||||
| const messages = defineMessages({ | class GIF extends React.PureComponent { | ||||||
|   title: { id: 'bundle_column_error.title', defaultMessage: 'Network error' }, |  | ||||||
|   body: { id: 'bundle_column_error.body', defaultMessage: 'Something went wrong while loading this component.' }, |  | ||||||
|   retry: { id: 'bundle_column_error.retry', defaultMessage: 'Try again' }, |  | ||||||
| }); |  | ||||||
| 
 |  | ||||||
| class BundleColumnError extends React.PureComponent { |  | ||||||
| 
 | 
 | ||||||
|   static propTypes = { |   static propTypes = { | ||||||
|     onRetry: PropTypes.func.isRequired, |     src: PropTypes.string.isRequired, | ||||||
|     intl: PropTypes.object.isRequired, |     staticSrc: PropTypes.string.isRequired, | ||||||
|     multiColumn: PropTypes.bool, |     className: PropTypes.string, | ||||||
|  |     animate: PropTypes.bool, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   static defaultProps = { | ||||||
|  |     animate: autoPlayGif, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   state = { | ||||||
|  |     hovering: false, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   handleMouseEnter = () => { | ||||||
|  |     const { animate } = this.props; | ||||||
|  | 
 | ||||||
|  |     if (!animate) { | ||||||
|  |       this.setState({ hovering: true }); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   handleRetry = () => { |   handleMouseLeave = () => { | ||||||
|     this.props.onRetry(); |     const { animate } = this.props; | ||||||
|  | 
 | ||||||
|  |     if (!animate) { | ||||||
|  |       this.setState({ hovering: false }); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   render () { |   render () { | ||||||
|     const { multiColumn, intl: { formatMessage } } = this.props; |     const { src, staticSrc, className, animate } = this.props; | ||||||
|  |     const { hovering } = this.state; | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|       <Column bindToDocument={!multiColumn} label={formatMessage(messages.title)}> |       <img | ||||||
|         <ColumnHeader |         className={className} | ||||||
|           icon='exclamation-circle' |         src={(hovering || animate) ? src : staticSrc} | ||||||
|           title={formatMessage(messages.title)} |         alt='' | ||||||
|           showBackButton |         role='presentation' | ||||||
|           multiColumn={multiColumn} |         onMouseEnter={this.handleMouseEnter} | ||||||
|         /> |         onMouseLeave={this.handleMouseLeave} | ||||||
|  |       /> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
| 
 | 
 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | class CopyButton extends React.PureComponent { | ||||||
|  | 
 | ||||||
|  |   static propTypes = { | ||||||
|  |     children: PropTypes.node.isRequired, | ||||||
|  |     value: PropTypes.string.isRequired, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   state = { | ||||||
|  |     copied: false, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   handleClick = () => { | ||||||
|  |     const { value } = this.props; | ||||||
|  |     navigator.clipboard.writeText(value); | ||||||
|  |     this.setState({ copied: true }); | ||||||
|  |     this.timeout = setTimeout(() => this.setState({ copied: false }), 700); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   componentWillUnmount () { | ||||||
|  |     if (this.timeout) clearTimeout(this.timeout); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   render () { | ||||||
|  |     const { children } = this.props; | ||||||
|  |     const { copied } = this.state; | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |       <Button onClick={this.handleClick} className={copied ? 'copied' : 'copyable'}>{copied ? <FormattedMessage id='copypaste.copied' defaultMessage='Copied' /> : children}</Button> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default @injectIntl | ||||||
|  | class BundleColumnError extends React.PureComponent { | ||||||
|  | 
 | ||||||
|  |   static propTypes = { | ||||||
|  |     errorType: PropTypes.oneOf(['routing', 'network', 'error']), | ||||||
|  |     onRetry: PropTypes.func, | ||||||
|  |     intl: PropTypes.object.isRequired, | ||||||
|  |     multiColumn: PropTypes.bool, | ||||||
|  |     stacktrace: PropTypes.string, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   static defaultProps = { | ||||||
|  |     errorType: 'routing', | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   handleRetry = () => { | ||||||
|  |     const { onRetry } = this.props; | ||||||
|  | 
 | ||||||
|  |     if (onRetry) { | ||||||
|  |       onRetry(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   render () { | ||||||
|  |     const { errorType, multiColumn, stacktrace } = this.props; | ||||||
|  | 
 | ||||||
|  |     let title, body; | ||||||
|  | 
 | ||||||
|  |     switch(errorType) { | ||||||
|  |     case 'routing': | ||||||
|  |       title = <FormattedMessage id='bundle_column_error.routing.title' defaultMessage='404' />; | ||||||
|  |       body = <FormattedMessage id='bundle_column_error.routing.body' defaultMessage='The requested page could not be found. Are you sure the URL in the address bar is correct?' />; | ||||||
|  |       break; | ||||||
|  |     case 'network': | ||||||
|  |       title = <FormattedMessage id='bundle_column_error.network.title' defaultMessage='Network error' />; | ||||||
|  |       body = <FormattedMessage id='bundle_column_error.network.body' defaultMessage='There was an error when trying to load this page. This could be due to a temporary problem with your internet connection or this server.' />; | ||||||
|  |       break; | ||||||
|  |     case 'error': | ||||||
|  |       title = <FormattedMessage id='bundle_column_error.error.title' defaultMessage='Oh, no!' />; | ||||||
|  |       body = <FormattedMessage id='bundle_column_error.error.body' defaultMessage='The requested page could not be rendered. It could be due to a bug in our code, or a browser compatibility issue.' />; | ||||||
|  |       break; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |       <Column bindToDocument={!multiColumn}> | ||||||
|         <div className='error-column'> |         <div className='error-column'> | ||||||
|           <IconButton title={formatMessage(messages.retry)} icon='refresh' onClick={this.handleRetry} size={64} /> |           <GIF src='/oops.gif' staticSrc='/oops.png' className='error-column__image' /> | ||||||
|           {formatMessage(messages.body)} | 
 | ||||||
|  |           <div className='error-column__message'> | ||||||
|  |             <h1>{title}</h1> | ||||||
|  |             <p>{body}</p> | ||||||
|  | 
 | ||||||
|  |             <div className='error-column__message__actions'> | ||||||
|  |               {errorType === 'network' && <Button onClick={this.handleRetry}><FormattedMessage id='bundle_column_error.retry' defaultMessage='Try again' /></Button>} | ||||||
|  |               {errorType === 'error' && <CopyButton value={stacktrace}><FormattedMessage id='bundle_column_error.copy_stacktrace' defaultMessage='Copy error report' /></CopyButton>} | ||||||
|  |               <Link to='/' className={classNames('button', { 'button-tertiary': errorType !== 'routing' })}><FormattedMessage id='bundle_column_error.return' defaultMessage='Go back home' /></Link> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|         </div> |         </div> | ||||||
| 
 | 
 | ||||||
|         <Helmet> |         <Helmet> | ||||||
|  | @ -49,5 +160,3 @@ class BundleColumnError extends React.PureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| } | } | ||||||
| 
 |  | ||||||
| export default injectIntl(BundleColumnError); |  | ||||||
|  |  | ||||||
|  | @ -3,7 +3,7 @@ import React from 'react'; | ||||||
| import { HotKeys } from 'react-hotkeys'; | import { HotKeys } from 'react-hotkeys'; | ||||||
| import { defineMessages, injectIntl } from 'react-intl'; | import { defineMessages, injectIntl } from 'react-intl'; | ||||||
| import { connect } from 'react-redux'; | import { connect } from 'react-redux'; | ||||||
| import { Redirect, withRouter } from 'react-router-dom'; | import { Redirect, Route, withRouter } from 'react-router-dom'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import NotificationsContainer from './containers/notifications_container'; | import NotificationsContainer from './containers/notifications_container'; | ||||||
| import LoadingBarContainer from './containers/loading_bar_container'; | import LoadingBarContainer from './containers/loading_bar_container'; | ||||||
|  | @ -18,6 +18,7 @@ import { clearHeight } from '../../actions/height_cache'; | ||||||
| import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app'; | import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app'; | ||||||
| import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers'; | import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers'; | ||||||
| import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; | import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; | ||||||
|  | import BundleColumnError from './components/bundle_column_error'; | ||||||
| import UploadArea from './components/upload_area'; | import UploadArea from './components/upload_area'; | ||||||
| import ColumnsAreaContainer from './containers/columns_area_container'; | import ColumnsAreaContainer from './containers/columns_area_container'; | ||||||
| import PictureInPicture from 'mastodon/features/picture_in_picture'; | import PictureInPicture from 'mastodon/features/picture_in_picture'; | ||||||
|  | @ -39,7 +40,6 @@ import { | ||||||
|   HashtagTimeline, |   HashtagTimeline, | ||||||
|   Notifications, |   Notifications, | ||||||
|   FollowRequests, |   FollowRequests, | ||||||
|   GenericNotFound, |  | ||||||
|   FavouritedStatuses, |   FavouritedStatuses, | ||||||
|   BookmarkedStatuses, |   BookmarkedStatuses, | ||||||
|   ListTimeline, |   ListTimeline, | ||||||
|  | @ -219,7 +219,7 @@ class SwitchingColumnsArea extends React.PureComponent { | ||||||
|           <WrappedRoute path='/mutes' component={Mutes} content={children} /> |           <WrappedRoute path='/mutes' component={Mutes} content={children} /> | ||||||
|           <WrappedRoute path='/lists' component={Lists} content={children} /> |           <WrappedRoute path='/lists' component={Lists} content={children} /> | ||||||
| 
 | 
 | ||||||
|           <WrappedRoute component={GenericNotFound} content={children} /> |           <Route component={BundleColumnError} /> | ||||||
|         </WrappedSwitch> |         </WrappedSwitch> | ||||||
|       </ColumnsAreaContainer> |       </ColumnsAreaContainer> | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|  | @ -1,7 +1,7 @@ | ||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import { Switch, Route } from 'react-router-dom'; | import { Switch, Route } from 'react-router-dom'; | ||||||
| 
 | import StackTrace from 'stacktrace-js'; | ||||||
| import ColumnLoading from '../components/column_loading'; | import ColumnLoading from '../components/column_loading'; | ||||||
| import BundleColumnError from '../components/bundle_column_error'; | import BundleColumnError from '../components/bundle_column_error'; | ||||||
| import BundleContainer from '../containers/bundle_container'; | import BundleContainer from '../containers/bundle_container'; | ||||||
|  | @ -42,8 +42,38 @@ export class WrappedRoute extends React.Component { | ||||||
|     componentParams: {}, |     componentParams: {}, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|  |   static getDerivedStateFromError () { | ||||||
|  |     return { | ||||||
|  |       hasError: true, | ||||||
|  |     }; | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   state = { | ||||||
|  |     hasError: false, | ||||||
|  |     stacktrace: '', | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   componentDidCatch (error) { | ||||||
|  |     StackTrace.fromError(error).then(stackframes => { | ||||||
|  |       this.setState({ stacktrace: error.toString() + '\n' + stackframes.map(frame => frame.toString()).join('\n') }); | ||||||
|  |     }).catch(err => { | ||||||
|  |       console.error(err); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   renderComponent = ({ match }) => { |   renderComponent = ({ match }) => { | ||||||
|     const { component, content, multiColumn, componentParams } = this.props; |     const { component, content, multiColumn, componentParams } = this.props; | ||||||
|  |     const { hasError, stacktrace } = this.state; | ||||||
|  | 
 | ||||||
|  |     if (hasError) { | ||||||
|  |       return ( | ||||||
|  |         <BundleColumnError | ||||||
|  |           stacktrace={stacktrace} | ||||||
|  |           multiColumn={multiColumn} | ||||||
|  |           errorType='error' | ||||||
|  |         /> | ||||||
|  |       ); | ||||||
|  |     } | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|       <BundleContainer fetchComponent={component} loading={this.renderLoading} error={this.renderError}> |       <BundleContainer fetchComponent={component} loading={this.renderLoading} error={this.renderError}> | ||||||
|  | @ -59,7 +89,7 @@ export class WrappedRoute extends React.Component { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   renderError = (props) => { |   renderError = (props) => { | ||||||
|     return <BundleColumnError {...props} />; |     return <BundleColumnError {...props} errorType='network' />; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   render () { |   render () { | ||||||
|  |  | ||||||
|  | @ -89,6 +89,15 @@ | ||||||
|     cursor: default; |     cursor: default; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   &.copyable { | ||||||
|  |     transition: background 300ms linear; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   &.copied { | ||||||
|  |     background: $valid-value-color; | ||||||
|  |     transition: none; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   &::-moz-focus-inner { |   &::-moz-focus-inner { | ||||||
|     border: 0; |     border: 0; | ||||||
|   } |   } | ||||||
|  | @ -2656,7 +2665,8 @@ $ui-header-height: 55px; | ||||||
| 
 | 
 | ||||||
|   .column-header, |   .column-header, | ||||||
|   .column-back-button, |   .column-back-button, | ||||||
|   .scrollable { |   .scrollable, | ||||||
|  |   .error-column { | ||||||
|     border-radius: 0 !important; |     border-radius: 0 !important; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | @ -4292,7 +4302,6 @@ a.status-card.compact:hover { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .empty-column-indicator, | .empty-column-indicator, | ||||||
| .error-column, |  | ||||||
| .follow_requests-unlocked_explanation { | .follow_requests-unlocked_explanation { | ||||||
|   color: $dark-text-color; |   color: $dark-text-color; | ||||||
|   background: $ui-base-color; |   background: $ui-base-color; | ||||||
|  | @ -4330,7 +4339,47 @@ a.status-card.compact:hover { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .error-column { | .error-column { | ||||||
|  |   padding: 20px; | ||||||
|  |   background: $ui-base-color; | ||||||
|  |   border-radius: 4px; | ||||||
|  |   display: flex; | ||||||
|  |   flex: 1 1 auto; | ||||||
|  |   align-items: center; | ||||||
|  |   justify-content: center; | ||||||
|   flex-direction: column; |   flex-direction: column; | ||||||
|  |   cursor: default; | ||||||
|  | 
 | ||||||
|  |   &__image { | ||||||
|  |     max-width: 350px; | ||||||
|  |     margin-top: -50px; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   &__message { | ||||||
|  |     text-align: center; | ||||||
|  |     color: $darker-text-color; | ||||||
|  |     font-size: 15px; | ||||||
|  |     line-height: 22px; | ||||||
|  | 
 | ||||||
|  |     h1 { | ||||||
|  |       font-size: 28px; | ||||||
|  |       line-height: 33px; | ||||||
|  |       font-weight: 700; | ||||||
|  |       margin-bottom: 15px; | ||||||
|  |       color: $primary-text-color; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     p { | ||||||
|  |       max-width: 48ch; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     &__actions { | ||||||
|  |       margin-top: 30px; | ||||||
|  |       display: flex; | ||||||
|  |       gap: 10px; | ||||||
|  |       align-items: center; | ||||||
|  |       justify-content: center; | ||||||
|  |     } | ||||||
|  |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| @keyframes heartbeat { | @keyframes heartbeat { | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue