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>
lolsob-rspec
Eugen Rochko 2022-10-22 23:18:32 +02:00 committed by GitHub
parent 01980c975b
commit daaa66a2c2
4 changed files with 222 additions and 34 deletions

View File

@ -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);

View File

@ -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>
); );

View File

@ -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 () {

View File

@ -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 {