perfect-postcode/frontend/src/components/map/MapErrorBoundary.tsx
2026-06-25 22:29:52 +01:00

144 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 browsers 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>;
}
}