MM-61734 Add withErrorBoundary HOC and use for PostComponent (#29801)

* MM-61734 Add withErrorBoundary HOC and use for PostComponent

I originally planned to add error boundaries to some individual parts of the
post, but it seems sufficient to add them to just the outer PostComponent.

* Update button label
Этот коммит содержится в:
Harrison Healey
2025-01-20 10:02:11 -05:00
коммит произвёл GitHub
родитель ce29be0f38
Коммит c7eb908e38
6 изменённых файлов: 244 добавлений и 5 удалений

Просмотреть файл

@@ -49,6 +49,7 @@ import {getDateForUnixTicks, makeIsEligibleForClick} from 'utils/utils';
import type {PostActionComponent, PostPluginComponent} from 'types/store/plugins';
import {withPostErrorBoundary} from './post_error_boundary';
import PostOptions from './post_options';
import PostUserProfile from './user_profile';
@@ -119,7 +120,7 @@ export type Props = {
pluginActions: PostActionComponent[];
};
const PostComponent = (props: Props): JSX.Element => {
function PostComponent(props: Props) {
const {post, shouldHighlight, togglePostMenu} = props;
const isSearchResultItem = (props.matches && props.matches.length > 0) || props.isMentionSearch || (props.term && props.term.length > 0);
@@ -670,6 +671,6 @@ const PostComponent = (props: Props): JSX.Element => {
</PostAriaLabelDiv>
</>
);
};
}
export default PostComponent;
export default withPostErrorBoundary(PostComponent);

Просмотреть файл

@@ -0,0 +1,43 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import type {FallbackProps} from 'components/with_error_boundary';
import withErrorBoundary from 'components/with_error_boundary';
export function withPostErrorBoundary<P>(component: React.ComponentType<P>) {
return withErrorBoundary<P>(component, {
renderFallback: ({clearError}) => {
return (
<div className='a11y__section post'>
<FormattedMessage
id='post.renderError.message'
defaultMessage='An error occurred while rendering this post.'
tagName='p'
/>
<br/>
<RetryButton clearError={clearError}/>
</div>
);
},
});
}
function RetryButton({clearError}: FallbackProps) {
const intl = useIntl();
return (
<button
className='btn btn-tertiary'
aria-label={intl.formatMessage({id: 'post.renderError.retryLabel', defaultMessage: 'Retry rendering this post'})}
onClick={clearError}
>
<FormattedMessage
id='post.renderError.retry'
defaultMessage='Retry'
/>
</button>
);
}

Просмотреть файл

@@ -43,7 +43,7 @@ exports[`components/post_view/post_list_row should have class hideAnimation for
exports[`components/post_view/post_list_row should render channel intro message 1`] = `<Connect(ChannelIntroMessage) />`;
exports[`components/post_view/post_list_row should render combined post 1`] = `
<Connect(Connect(PostComponent))
<Connect(Connect(ErrorBoundary(PostComponent)))
combinedId="user-activity-1234-5678"
isLastPost={false}
location="CENTER"
@@ -101,7 +101,7 @@ exports[`components/post_view/post_list_row should render new messages line 1`]
`;
exports[`components/post_view/post_list_row should render post 1`] = `
<Connect(PostComponent)
<Connect(ErrorBoundary(PostComponent))
isLastPost={false}
location="CENTER"
post={

Просмотреть файл

@@ -0,0 +1,138 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useSelector} from 'react-redux';
import {getChannelsInCategoryOrder} from 'selectors/views/channel_sidebar';
import {render, screen} from 'tests/react_testing_utils';
import type {FallbackProps} from '.';
import withErrorBoundary from '.';
function renderFallbackWithRetry({clearError}: FallbackProps) {
return (
<div>
<p>{'A rendering error occurred'}</p>
<button onClick={clearError}>{'Try again?'}</button>
</div>
);
}
describe('withErrorBoundary', () => {
const origError = console.error;
beforeAll(() => {
console.error = jest.fn();
});
afterAll(() => {
console.error = origError;
});
test('should render the component normally', () => {
function TestComponent() {
return <span>{'TestComponent'}</span>;
}
const WrappedTestComponent = withErrorBoundary(TestComponent, {
renderFallback: renderFallbackWithRetry,
});
render(
<WrappedTestComponent/>,
);
expect(screen.getByText('TestComponent')).toBeVisible();
});
test('should render fallback when an error occurs during rendering', () => {
function TestComponent(): JSX.Element {
const obj = {} as any;
return <span>{'TestComponent' + obj.someField.thatDoesnt.exist.toString()}</span>;
}
const WrappedTestComponent = withErrorBoundary(TestComponent, {
renderFallback: renderFallbackWithRetry,
});
render(
<WrappedTestComponent/>,
);
expect(screen.getByText('A rendering error occurred')).toBeVisible();
});
test('should render fallback when an error occurs in a hook', () => {
function useAnErrorForSomeReason(): string {
throw new Error('hook error');
}
function TestComponent(): JSX.Element {
const extraText = useAnErrorForSomeReason();
return <span>{'TestComponent' + extraText}</span>;
}
const WrappedTestComponent = withErrorBoundary(TestComponent, {
renderFallback: renderFallbackWithRetry,
});
render(
<WrappedTestComponent/>,
);
expect(screen.getByText('A rendering error occurred')).toBeVisible();
});
test('should render fallback when an error occurs in a selector', () => {
function TestComponent(): JSX.Element {
const extraText = useSelector(getChannelsInCategoryOrder);
return <span>{'TestComponent' + extraText}</span>;
}
const WrappedTestComponent = withErrorBoundary(TestComponent, {
renderFallback: renderFallbackWithRetry,
});
render(
<WrappedTestComponent/>,
);
expect(screen.getByText('A rendering error occurred')).toBeVisible();
});
test('the user should be able to retry rendering the component', () => {
let throwError = true;
function TestComponent(): JSX.Element {
let obj: any;
if (throwError) {
obj = {};
} else {
obj = {
someField: {
thatDoesnt: {
exist: [1, 2, 3],
},
},
};
}
return <span>{'TestComponent ' + obj.someField.thatDoesnt.exist.toString()}</span>;
}
const WrappedTestComponent = withErrorBoundary(TestComponent, {
renderFallback: renderFallbackWithRetry,
});
render(
<WrappedTestComponent/>,
);
expect(screen.queryByText('A rendering error occurred')).toBeVisible();
expect(screen.queryByText('TestComponent 1,2,3')).toBeNull();
throwError = false;
screen.getByText('Try again?').click();
expect(screen.queryByText('A rendering error occurred')).toBeNull();
expect(screen.queryByText('TestComponent 1,2,3')).toBeVisible();
});
});

Просмотреть файл

@@ -0,0 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
type ErrorBoundaryState = {
hasError: boolean;
}
export type FallbackProps = {
clearError: (e: React.MouseEvent) => void;
};
type ErrorBoundaryOptions = {
renderFallback: (props: FallbackProps) => React.ReactNode;
};
export default function withErrorBoundary<P>(component: React.ComponentType<P>, options: ErrorBoundaryOptions) {
const Component = component;
const displayName = component.displayName ?? component.name ?? 'Component';
const WrappedComponent = class WrappedComponent extends React.PureComponent<P, ErrorBoundaryState> {
static displayName = `ErrorBoundary(${displayName})`;
state = {
hasError: false,
};
static getDerivedStateFromError() {
return {
hasError: true,
};
}
clearError = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
this.setState({hasError: false});
};
render() {
if (this.state.hasError) {
return options.renderFallback({
clearError: this.clearError,
});
}
return <Component {...this.props}/>;
}
};
return WrappedComponent;
}

Просмотреть файл

@@ -4731,6 +4731,9 @@
"post.ariaLabel.replyMessage": "At {time} {date}, {authorName} replied, {message}",
"post.reminder.acknowledgement": "You will be reminded at {reminderTime}, {reminderDate} about this message from {username}: {permaLink}",
"post.reminder.systemBot": "Hi there, here's your reminder about this message from {username}: {permaLink}",
"post.renderError.message": "An error occurred while rendering this post.",
"post.renderError.retry": "Retry",
"post.renderError.retryLabel": "Retry rendering this post",
"postlist.toast.history": "Viewing message history",
"postlist.toast.newMessages": "{count, number} new {count, plural, one {message} other {messages}}",
"postlist.toast.newMessagesSince": "{count, number} new {count, plural, one {message} other {messages}} {isToday, select, true {} other {since}} {date}",