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 { state: MapErrorBoundaryState = { errored: false, failedPermanently: false, renderKey: 0, recoveries: 0, }; private recoveryTimer: ReturnType | null = null; private lastErrorAt = 0; static getDerivedStateFromError(): Partial { 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 (

{i18n.t('map.error.heading', { defaultValue: 'The map ran into a problem' })}

{i18n.t('map.error.body', { defaultValue: 'This can happen when your browser’s graphics context is interrupted.', })}

); } // 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 ; } return {this.props.children}; } }