Back

91.66% Statements 11/12
83.33% Branches 5/6
75% Functions 3/4
91.66% Lines 11/12

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134                                                                                                      187x                 10x                               5x   5x 5x 5x 5x                             203x 203x 193x     10x                                                                
import jetpackAnalytics from '@automattic/jetpack-analytics';
import { Button, Card } from '@wordpress/components';
import { Component } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { Stack, Text } from '@wordpress/ui';
import { identifyReader } from '../../hooks/use-analytics';
import './style.scss';
import type { ErrorInfo, ReactNode } from 'react';
 
type Props = {
	children: ReactNode;
	screen?: 'overview' | 'download' | 'restore';
};
 
type State = {
	error: Error | null;
};
 
/**
 * Reload the current page.
 *
 * Hoisted out of the render so the button's handler is a stable
 * reference rather than a new closure on every render.
 */
function reloadPage() {
	window.location.reload();
}
 
/**
 * Route-level error boundary for the modernized Backup dashboard.
 *
 * Without one, a throw anywhere on the render path unmounts the whole
 * React tree and leaves an empty `<div>` inside the page chrome — a
 * blank panel with no explanation and nothing to click.
 *
 * This is defence in depth rather than a fix for one known hole. The
 * hazard it was written for — `toFileNode` calling `toISOString()` on a
 * `period` parsed straight out of an unvalidated WPCOM manifest — is
 * guarded at the source, and that guard is the right place for it,
 * because dropping one timestamp beats blanking the file browser. What
 * the boundary buys is that the next such field, in the next component,
 * costs a legible error screen instead of a white rectangle.
 *
 * A class component because that is still the only way to implement
 * `getDerivedStateFromError` — React exposes no hook equivalent.
 *
 * Recovery is a reload rather than a state reset: by the time this
 * renders, the failed subtree's state is gone, so offering "try again"
 * without remounting would re-throw immediately.
 */
export default class ErrorBoundary extends Component< Props, State > {
	state: State = { error: null };
 
	/**
	 * Record the error so the next render shows the fallback.
	 *
	 * @param error - The thrown error.
	 * @return The next state.
	 */
	static getDerivedStateFromError( error: Error ): State {
		return { error };
	}
 
	/**
	 * Log the component stack, which the fallback deliberately does not
	 * show — it is the only part of the report that identifies where the
	 * throw came from, and support needs it more than the reader does.
	 *
	 * It also records a Tracks event with the screen and the error name
	 * only — never the message or stack, which can carry site data.
	 *
	 * @param error     - The thrown error.
	 * @param errorInfo - React's component stack.
	 */
	componentDidCatch( error: Error, errorInfo: ErrorInfo ) {
		// eslint-disable-next-line no-console
		console.error( '[Jetpack Backup] Unhandled error:', error, errorInfo.componentStack );
 
		try {
			const { screen } = this.props;
			identifyReader();
			jetpackAnalytics.tracks.recordEvent( 'jetpack_backup_dashboard_crash', {
				...( screen && { screen } ),
				error_type: error?.name || error?.constructor?.name,
			} );
		} catch {
			// Tracking must never stop the fallback from rendering.
		}
	}
 
	/**
	 * Render the children, or the fallback once an error has been caught.
	 *
	 * @return The rendered tree.
	 */
	render() {
		const { error } = this.state;
		if ( ! error ) {
			return this.props.children;
		}
 
		return (
			<Card className="jpb-error-boundary">
				<Stack direction="column" gap="md" align="center">
					{ /*
					 * An `h1`, not an `h2`: the page's own `h1` comes from
					 * `<Page title>` inside `<DashboardLayout>`, and this
					 * fallback renders *instead of* that whole subtree. When
					 * it is on screen there is no other heading on the page,
					 * so this is the document's first one. (Every other first
					 * in-body heading is an `h2`, because they all render
					 * inside the layout with that `h1` still above them.)
					 */ }
					<Text variant="heading-md" render={ <h1 /> }>
						{ __( 'Something went wrong', 'jetpack-backup-pkg' ) }
					</Text>
					<Text>
						{ __(
							'The page ran into an unexpected problem. Your backups are unaffected.',
							'jetpack-backup-pkg'
						) }
					</Text>
					<Text variant="body-sm" className="jpb-text-muted">
						{ error.message }
					</Text>
					<Button variant="primary" onClick={ reloadPage }>
						{ __( 'Reload the page', 'jetpack-backup-pkg' ) }
					</Button>
				</Stack>
			</Card>
		);
	}
}