Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/reference-guides/filters/editor-filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ Opaque animated GIFs are converted client-side to a companion MP4/WebM video, an

A JavaScript error in a part of the UI shouldn't break the whole app. To solve this problem for users, React library uses the concept of an ["error boundary"](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary). Error boundaries are React components that catch JavaScript errors anywhere in their child component tree and display a fallback UI instead of the component tree that crashed.

The `editor.ErrorBoundary.errorLogged` action allows you to hook into the [Error Boundaries](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary) and gives you access to the error object.
The `editor.ErrorBoundary.errorLogged` action allows you to hook into the [Error Boundaries](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary) and gives you access to the error object, along with React's error info object, whose `componentStack` property describes where in the component tree the error was thrown.

You can use this action to get hold of the error object handled by the boundaries. For example, you may want to send them to an external error-tracking tool. Here's an example:

Expand All @@ -322,10 +322,10 @@ import { addAction } from '@wordpress/hooks';
addAction(
'editor.ErrorBoundary.errorLogged',
'mu-plugin/error-capture-setup',
( error ) => {
// Error is the exception's error object.
( error, errorInfo ) => {
// Error is the exception's error object.
// You can console.log it or send it to an external error-tracking tool.
console.log ( error );
console.log ( error, errorInfo?.componentStack );
}
);
```
164 changes: 141 additions & 23 deletions packages/editor/src/components/error-boundary/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Component } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { Button } from '@wordpress/components';
import { Stack, Text } from '@wordpress/ui';
// eslint-disable-next-line @wordpress/use-recommended-components -- The fallback UI renders outside the editor's notice system.
import { Card, CollapsibleCard, Notice, Stack, Text } from '@wordpress/ui';
import { select } from '@wordpress/data';
import { useCopyToClipboard } from '@wordpress/compose';
import { doAction } from '@wordpress/hooks';
Expand All @@ -19,12 +19,112 @@ function getContent() {
} catch {}
}

function CopyButton( { text, children, variant = 'secondary' } ) {
// A boundary catches whatever was thrown, which is not always an `Error`.
function getErrorName( error ) {
return ( error instanceof Error && error.name ) || 'Error';
}

function getErrorMessage( error ) {
if ( typeof error === 'string' && error ) {
return error;
}

if ( typeof error?.message === 'string' && error.message ) {
return error.message;
}

return 'An unknown error occurred.';
}

// The sections of the report, shared by the copied Markdown and the details
// panel so that the two cannot drift apart. Deliberately untranslated: both
// are developer-facing, and the report is pasted into a bug report.
function getErrorSections( error, componentStack ) {
const sections = [
{ label: getErrorName( error ), content: getErrorMessage( error ) },
];

if ( componentStack ) {
sections.push( {
label: 'Component stack',
content: componentStack.trim(),
preformatted: true,
} );
}

if ( error?.stack ) {
sections.push( {
label: 'Stack',
content: error.stack.trim(),
preformatted: true,
} );
}

sections.push( {
label: 'Environment',
content: `User agent: ${ window.navigator.userAgent }`,
preformatted: true,
} );

return sections;
}

// Markdown, so the report stays readable as plain text and renders when pasted
// into a bug report.
function getErrorReport( error, componentStack ) {
const sections = getErrorSections( error, componentStack ).map(
( { label, content, preformatted } ) =>
`**${ label }**\n\n${
preformatted ? `\`\`\`\n${ content }\n\`\`\`` : content
}`
);

return [ '### Error report', ...sections ].join( '\n\n' );
}

function CopyButton( { text, children, variant = 'outline' } ) {
const ref = useCopyToClipboard( text );
return (
<Button __next40pxDefaultSize variant={ variant } ref={ ref }>
<Notice.ActionButton variant={ variant } ref={ ref }>
{ children }
</Button>
</Notice.ActionButton>
);
}

function ErrorReport( { error, componentStack } ) {
return (
<Stack
className="editor-error-boundary__report"
direction="column"
gap="md"
>
{ getErrorSections( error, componentStack ).map(
( { label, content } ) => (
<Stack key={ label } direction="column" gap="xs">
<Text variant="heading-md">{ label }</Text>
<pre className="editor-error-boundary__report-section">
{ content }
</pre>
</Stack>
)
) }
</Stack>
);
}

function ErrorDetails( { error, componentStack } ) {
return (
<CollapsibleCard.Root className="editor-error-boundary__details">
<CollapsibleCard.Header>
<Card.Title>{ __( 'Error details' ) }</Card.Title>
</CollapsibleCard.Header>
<CollapsibleCard.Content>
<ErrorReport
error={ error }
componentStack={ componentStack }
/>
</CollapsibleCard.Content>
</CollapsibleCard.Root>
);
}

Expand All @@ -34,19 +134,21 @@ class ErrorBoundary extends Component {

this.state = {
error: null,
componentStack: null,
};
}

componentDidCatch( error ) {
doAction( 'editor.ErrorBoundary.errorLogged', error );
componentDidCatch( error, errorInfo ) {
this.setState( { componentStack: errorInfo?.componentStack } );
Comment thread
Mamaduka marked this conversation as resolved.
Resolved
doAction( 'editor.ErrorBoundary.errorLogged', error, errorInfo );
}

static getDerivedStateFromError( error ) {
return { error };
}

render() {
const { error } = this.state;
const { error, componentStack } = this.state;
const { canCopyContent = false } = this.props;
if ( ! error ) {
return this.props.children;
Expand All @@ -55,24 +157,40 @@ class ErrorBoundary extends Component {
return (
<Stack
className="editor-error-boundary"
align="baseline"
direction="column"
gap="lg"
justify="space-between"
wrap="wrap"
>
<Text render={ <p /> }>
{ __( 'The editor has encountered an unexpected error.' ) }
</Text>
<Stack align="center" gap="sm">
{ canCopyContent && (
<CopyButton text={ getContent }>
{ __( 'Copy contents' ) }
<Notice.Root intent="error">
<Notice.Title>
{ __( 'The editor has crashed' ) }
</Notice.Title>
<Notice.Description>
{ __(
'An unknown error occurred. Reload your browser to try again, or copy the error to report the problem or search.'
) }
</Notice.Description>
<Notice.Actions>
{ canCopyContent && (
<CopyButton text={ getContent }>
{ __( 'Copy contents' ) }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering if this could be confused with "contents" of the error. Maybe it could be explicit, e.g., "Copy post content" or "editor content".

Or maybe we could tweak the description to explain the option. 🤷🏻

</CopyButton>
) }
<CopyButton
variant="solid"
text={ () =>
getErrorReport( error, componentStack )
}
>
{ __( 'Copy error' ) }
</CopyButton>
) }
<CopyButton variant="primary" text={ error?.stack }>
{ __( 'Copy error' ) }
</CopyButton>
</Stack>
</Notice.Actions>
</Notice.Root>
{ globalThis.SCRIPT_DEBUG ? (
<ErrorDetails
error={ error }
componentStack={ componentStack }
/>
) : null }
</Stack>
);
}
Expand Down
28 changes: 20 additions & 8 deletions packages/editor/src/components/error-boundary/style.scss
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
@use "@wordpress/base-styles/colors" as *;
@use "@wordpress/base-styles/variables" as *;

.editor-error-boundary {
font-family: $default-font;
font-family: var(--wpds-typography-font-family-body);
margin: auto;
max-width: 780px;
padding: 1em;
width: 100%;
max-width: var(--wpds-dimension-surface-width-lg);
padding: var(--wpds-dimension-padding-xl);
margin-top: $header-height;
box-shadow: $elevation-large;
border: $border-width solid $gray-900;
border-radius: $radius-small;
background-color: $white;
}

.editor-error-boundary__report {
max-height: 420px;
overflow: auto;
overflow-anchor: none;
color: var(--wpds-color-foreground-content-neutral-weak);
}

.editor-error-boundary__report-section {
margin: 0;
font-family: var(--wpds-typography-font-family-mono);
font-size: var(--wpds-typography-font-size-sm);
line-height: var(--wpds-typography-line-height-sm);
white-space: pre-wrap;
overflow-wrap: break-word;
}
Loading