new demo mode & tenure
This commit is contained in:
parent
7656f24544
commit
4a0f00f2a4
64 changed files with 2875 additions and 338 deletions
141
frontend/src/components/map/MapErrorBoundary.tsx
Normal file
141
frontend/src/components/map/MapErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { Component, Fragment, type ReactNode } from 'react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
|
||||
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">
|
||||
The map ran into a problem
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-warm-600 dark:text-warm-300">
|
||||
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"
|
||||
>
|
||||
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>;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue