144 lines
4.8 KiB
TypeScript
144 lines
4.8 KiB
TypeScript
import { Component, Fragment, type ReactNode } from 'react';
|
||
import * as Sentry from '@sentry/react';
|
||
|
||
import i18n from '../../i18n';
|
||
import { MapFallback } from './map-page/Fallbacks';
|
||
|
||
/**
|
||
* Scoped error boundary for the WebGL map.
|
||
*
|
||
* deck.gl runs in interleaved mode, sharing MapLibre's WebGL context. When that
|
||
* shared context gets into a bad state (lost context, a buffer/texture finalized
|
||
* while still referenced — the "bufferSubData: no buffer" / "bindTexture: deleted
|
||
* object" storm), the next interleaved draw can throw. deck.gl's own animation
|
||
* loop swallows render errors via its `onError` prop, but interleaved draws fire
|
||
* from MapLibre's synchronous `render` event, which can run inside a React commit
|
||
* — so the throw bubbles to the nearest React error boundary.
|
||
*
|
||
* Without this, that throw reaches the single top-level boundary and blanks the
|
||
* entire app ("Something went wrong / Refresh the page"). This boundary contains
|
||
* the blast radius to the map and auto-recovers by remounting it, which builds a
|
||
* fresh MapLibre + deck.gl context. Filters, selection and viewport live above
|
||
* this boundary (in MapPage / the URL), so a remount preserves them.
|
||
*/
|
||
|
||
const MAX_AUTO_RECOVERIES = 2;
|
||
// Remount after a short delay so a transient GL state can settle first.
|
||
const RECOVERY_DELAY_MS = 400;
|
||
// Crashes spaced further apart than this are treated as independent, so the
|
||
// auto-recovery budget resets — only a tight crash loop escalates to manual retry.
|
||
const STABILITY_WINDOW_MS = 30_000;
|
||
|
||
interface MapErrorBoundaryProps {
|
||
children: ReactNode;
|
||
}
|
||
|
||
interface MapErrorBoundaryState {
|
||
errored: boolean;
|
||
failedPermanently: boolean;
|
||
renderKey: number;
|
||
recoveries: number;
|
||
}
|
||
|
||
export class MapErrorBoundary extends Component<MapErrorBoundaryProps, MapErrorBoundaryState> {
|
||
state: MapErrorBoundaryState = {
|
||
errored: false,
|
||
failedPermanently: false,
|
||
renderKey: 0,
|
||
recoveries: 0,
|
||
};
|
||
|
||
private recoveryTimer: ReturnType<typeof setTimeout> | null = null;
|
||
private lastErrorAt = 0;
|
||
|
||
static getDerivedStateFromError(): Partial<MapErrorBoundaryState> {
|
||
return { errored: true };
|
||
}
|
||
|
||
componentDidCatch(error: Error, info: { componentStack?: string | null }) {
|
||
const now = Date.now();
|
||
const withinWindow = now - this.lastErrorAt < STABILITY_WINDOW_MS;
|
||
this.lastErrorAt = now;
|
||
const recoveries = withinWindow ? this.state.recoveries + 1 : 1;
|
||
|
||
Sentry.captureException(error, {
|
||
tags: { scope: 'map' },
|
||
extra: { componentStack: info.componentStack, recoveries },
|
||
});
|
||
|
||
if (recoveries <= MAX_AUTO_RECOVERIES) {
|
||
this.scheduleRecovery(recoveries);
|
||
} else {
|
||
this.setState({ failedPermanently: true, recoveries });
|
||
}
|
||
}
|
||
|
||
componentWillUnmount() {
|
||
this.clearRecoveryTimer();
|
||
}
|
||
|
||
private clearRecoveryTimer() {
|
||
if (this.recoveryTimer) {
|
||
clearTimeout(this.recoveryTimer);
|
||
this.recoveryTimer = null;
|
||
}
|
||
}
|
||
|
||
private scheduleRecovery(recoveries: number) {
|
||
this.clearRecoveryTimer();
|
||
this.recoveryTimer = setTimeout(() => {
|
||
this.recoveryTimer = null;
|
||
this.setState((prev) => ({
|
||
errored: false,
|
||
failedPermanently: false,
|
||
renderKey: prev.renderKey + 1,
|
||
recoveries,
|
||
}));
|
||
}, RECOVERY_DELAY_MS);
|
||
}
|
||
|
||
private handleManualRetry = () => {
|
||
this.clearRecoveryTimer();
|
||
this.lastErrorAt = 0;
|
||
this.setState((prev) => ({
|
||
errored: false,
|
||
failedPermanently: false,
|
||
renderKey: prev.renderKey + 1,
|
||
recoveries: 0,
|
||
}));
|
||
};
|
||
|
||
render() {
|
||
if (this.state.failedPermanently) {
|
||
return (
|
||
<div className="flex h-full w-full items-center justify-center bg-warm-100 px-6 text-center dark:bg-navy-950">
|
||
<div>
|
||
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
|
||
{i18n.t('map.error.heading', { defaultValue: 'The map ran into a problem' })}
|
||
</h2>
|
||
<p className="mt-2 text-sm text-warm-600 dark:text-warm-300">
|
||
{i18n.t('map.error.body', {
|
||
defaultValue: 'This can happen when your browser’s graphics context is interrupted.',
|
||
})}
|
||
</p>
|
||
<button
|
||
type="button"
|
||
onClick={this.handleManualRetry}
|
||
className="mt-4 rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-400"
|
||
>
|
||
{i18n.t('map.error.reload', { defaultValue: 'Reload the map' })}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// While auto-recovering, show the same spinner used for the lazy-load
|
||
// fallback so the remount reads as a brief reload rather than a crash.
|
||
if (this.state.errored) {
|
||
return <MapFallback />;
|
||
}
|
||
|
||
return <Fragment key={this.state.renderKey}>{this.props.children}</Fragment>;
|
||
}
|
||
}
|