diff --git a/client/src/App.tsx b/client/src/App.tsx index 4e01bb20c..e415eef28 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -30,6 +30,7 @@ import SaveToCollectionModal from './components/Collections/SaveToCollectionModa import MSaveToCollectionSheet from './components/Collections/MSaveToCollectionSheet' import BackgroundTasksWidget from './components/BackgroundTasks/BackgroundTasksWidget' import MobileShell from './mobile/MobileShell' +import ErrorBoundary from './components/shared/ErrorBoundary' import { useIsPhone } from './mobile/useIsPhone' import { TranslationProvider, useTranslation } from './i18n' import { authApi } from './api/client' @@ -94,7 +95,39 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR // sheets, toasts); from 768px up the legacy wrapper stays untouched. The // shell branches internally so pages keep their state when the viewport // crosses the breakpoint. - return {children} + // The boundary sits inside the shell so a broken page keeps the navigation the + // user needs to leave it — outside, the route would be a dead end, and the + // mobile --m-* tokens live on the shell's .m-root anyway. + // + // key, not resetKeys: ProtectedRoute is the same component at the same position + // for all 16 protected routes, so without it React could keep the failed + // instance across a navigation and the error would follow the user around. + return ( + + + {children} + + + ) +} + +/** + * The public routes render outside ProtectedRoute, so the route boundary above + * never sees them — including /login, where someone lands when everything else + * failed, and the two anonymous share pages. + */ +function PublicRoute({ children }: { children: React.ReactNode }) { + const location = useLocation() + return ( + + {children} + + ) } function RootRedirect() { @@ -210,21 +243,21 @@ export default function App() { return ( - {!isAuthPage && } - - {!isAuthPage && } + {!isAuthPage && } + + {!isAuthPage && } {!isAuthPage && (isPhone ? : )} - + } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> {/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */} - } /> + } /> (function JourneyM if (useGL) { return ( - - {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - - + // See MapViewAuto: the boundary has to sit outside the Suspense to catch a + // chunk that never arrives. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }> + + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + + + ) } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/client/src/components/Map/MapViewAuto.tsx b/client/src/components/Map/MapViewAuto.tsx index e6fdc05c4..fba1d69c2 100644 --- a/client/src/components/Map/MapViewAuto.tsx +++ b/client/src/components/Map/MapViewAuto.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense } from 'react' import { useSettingsStore } from '../../store/settingsStore' import { MapView } from './MapView' +import ErrorBoundary from '../shared/ErrorBoundary' // MapLibre/Mapbox pull in a ~230 KB (gzip) GL engine. Lazy-load the GL renderer so // Leaflet-only installs never download it — it ships only once a GL provider is picked. @@ -27,9 +28,14 @@ export function MapViewAuto(props: any) { // Render the previous Leaflet map as the fallback so there's no blank flash // while the GL chunk loads on first use. return ( - }> - - + // Outside the Suspense on purpose: Suspense handles the pending promise, + // a rejected one (chunk gone after a deploy) throws past it. Falling back + // to Leaflet keeps a usable map instead of an error card. + }> + }> + + + ) } return diff --git a/client/src/components/Plugins/PluginFrame.tsx b/client/src/components/Plugins/PluginFrame.tsx index 56a62a5e0..45df856ed 100644 --- a/client/src/components/Plugins/PluginFrame.tsx +++ b/client/src/components/Plugins/PluginFrame.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router' import { useTranslation } from '../../i18n' +import ErrorBoundary from '../shared/ErrorBoundary' import { useAuthStore } from '../../store/authStore' import { useSettingsStore } from '../../store/settingsStore' import { useTripStore } from '../../store/tripStore' @@ -582,32 +583,35 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d const confirmTitle = confirmReq?.title ? `${pluginLabel} — ${confirmReq.title}` : pluginLabel return ( - <> -