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>pull/19421/head
parent
56efa8d22f
commit
a43a823768
|
@ -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…
Reference in New Issue