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 (
- <>
-
}>
+
+ ,
+ );
+ expect(screen.getByText('quiet fallback')).toBeInTheDocument();
+ expect(screen.queryByRole('alert')).toBeNull();
+ });
+
+ it('FE-COMP-ERRBOUND-005: fallback={null} hides a broken widget without a trace', () => {
+ const { container } = render(
+
+
+ ,
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('FE-COMP-ERRBOUND-006: a function fallback receives the error and the reset handle', () => {
+ render(
+
}
+
+ );
+ }
+ const { rerender } = render();
+ expect(screen.getByRole('alert')).toBeInTheDocument();
+
+ // Switching to another trip/tab must not carry the previous failure over.
+ rerender();
+ expect(screen.getByText('panel 2')).toBeInTheDocument();
+ });
+
+ it('FE-COMP-ERRBOUND-009: an unchanged resetKey leaves the error standing', () => {
+ function Harness({ id }: { id: number }) {
+ return (
+
+
+
+ );
+ }
+ const { rerender } = render();
+ rerender();
+ expect(screen.getByRole('alert')).toBeInTheDocument();
+ });
+
+ it('FE-COMP-ERRBOUND-010: a dead chunk offers a reload instead of a retry', () => {
+ render(
+
+
+ ,
+ );
+ // React.lazy caches the rejection, so a retry button would be a button that
+ // does nothing — only a reload can pick up the new index.
+ expect(screen.queryByText('Try again')).toBeNull();
+ expect(screen.getByText('Reload page')).toBeInTheDocument();
+ expect(screen.getByText('A new version is available')).toBeInTheDocument();
+ });
+
+ it('FE-COMP-ERRBOUND-011: a chunk failure reloads once, then stops', () => {
+ const reload = vi.fn();
+ Object.defineProperty(window, 'location', {
+ value: { ...window.location, reload },
+ writable: true,
+ });
+
+ render(
+
+
+ ,
+ );
+ expect(reload).toHaveBeenCalledTimes(1);
+
+ // Second failure in the same session: the marker is set, so no reload loop.
+ render(
+
+
+ ,
+ );
+ expect(reload).toHaveBeenCalledTimes(1);
+ });
+
+ it('FE-COMP-ERRBOUND-012: a labelled boundary names what failed', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('Koffi & Friends')).toBeInTheDocument();
+ expect(screen.getByText('This plugin could not be shown')).toBeInTheDocument();
+ });
+
+ it('FE-COMP-ERRBOUND-013: a panel fallback says the rest of the page still works', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('This section could not be shown')).toBeInTheDocument();
+ expect(screen.getByText('The rest of the page still works.')).toBeInTheDocument();
+ });
+
+ it('FE-COMP-ERRBOUND-014: the mobile variant uses the mobile tokens', () => {
+ render(
+
+
+ ,
+ );
+ // --m-* only resolves inside MobileShell's .m-root; the desktop classes would
+ // render an invisible card there.
+ expect(screen.getByRole('alert').className).toContain('bg-m-card');
+ });
+});
+
+describe('RootErrorFallback', () => {
+ it('FE-COMP-ERRBOUND-015: stays untranslated because it renders above the provider', () => {
+ render( {}} isChunkError={false} />);
+ // If it called t() and the provider was the thing that broke, the user would
+ // be reading "common.errorTitle".
+ expect(screen.getByText('TREK could not start')).toBeInTheDocument();
+ expect(screen.getByText('boot failed')).toBeInTheDocument();
+ });
+
+ it('FE-COMP-ERRBOUND-016: names a stale chunk as an update rather than a crash', () => {
+ render( {}} isChunkError />);
+ expect(screen.getByText('A new version is available')).toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/shared/ErrorBoundary.tsx b/client/src/components/shared/ErrorBoundary.tsx
new file mode 100644
index 000000000..de75be4e0
--- /dev/null
+++ b/client/src/components/shared/ErrorBoundary.tsx
@@ -0,0 +1,212 @@
+import React from 'react'
+import { AlertTriangle, RefreshCw, RotateCcw } from 'lucide-react'
+import { useTranslation } from '../../i18n'
+import { isChunkLoadError, reloadOnceForChunk } from '../../utils/chunkReload'
+
+/**
+ * The app had none of these, so a single throw during render unmounted the whole
+ * tree and left a blank page. This catches the render path and swaps in a panel
+ * the user can act on.
+ *
+ * What it does NOT catch, and no boundary does: event handlers, anything async,
+ * and errors during SSR. Those go to the global handlers in main.tsx.
+ *
+ * A class is required — getDerivedStateFromError has no hook equivalent. It holds
+ * state only; the visible part is a function component underneath, so it can use
+ * useTranslation() like everything else. TranslationContext isn't exported, so
+ * contextType wouldn't have worked anyway.
+ */
+
+type Level = 'root' | 'route' | 'panel'
+
+interface FallbackState {
+ error: unknown
+ reset: () => void
+ isChunkError: boolean
+}
+
+export interface ErrorBoundaryProps {
+ children: React.ReactNode
+ /** Stable name in the console line, e.g. 'route', 'plugin-frame', 'planner-tabs'. */
+ boundaryId: string
+ level?: Level
+ /** Mobile uses its own tokens; they only resolve inside MobileShell's .m-root. */
+ variant?: 'desktop' | 'mobile'
+ /** Shown instead of the generic wording — a plugin name, a panel title. */
+ label?: string
+ fallback?: React.ReactNode | ((state: FallbackState) => React.ReactNode)
+ /** Any change here clears the error, e.g. [tripId] or [activeTab]. */
+ resetKeys?: readonly unknown[]
+ onReset?: () => void
+ onError?: (error: unknown, info: React.ErrorInfo) => void
+}
+
+interface ErrorBoundaryState {
+ error: unknown
+ hasError: boolean
+}
+
+function keysChanged(a: readonly unknown[] = [], b: readonly unknown[] = []): boolean {
+ return a.length !== b.length || a.some((v, i) => !Object.is(v, b[i]))
+}
+
+export default class ErrorBoundary extends React.Component {
+ state: ErrorBoundaryState = { error: null, hasError: false }
+
+ static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
+ return { error, hasError: true }
+ }
+
+ componentDidCatch(error: unknown, info: React.ErrorInfo) {
+ console.error(`[ErrorBoundary:${this.props.boundaryId}]`, error, info.componentStack)
+ this.props.onError?.(error, info)
+
+ // A chunk that 404s can't be retried — React.lazy keeps the rejected promise,
+ // so every later render throws the same thing. One reload picks up the new
+ // index; after that the fallback stays put rather than looping.
+ if (isChunkLoadError(error)) reloadOnceForChunk()
+ }
+
+ componentDidUpdate(prev: ErrorBoundaryProps) {
+ if (this.state.hasError && keysChanged(prev.resetKeys, this.props.resetKeys)) {
+ this.reset()
+ }
+ }
+
+ reset = () => {
+ this.props.onReset?.()
+ this.setState({ error: null, hasError: false })
+ }
+
+ render() {
+ if (!this.state.hasError) return this.props.children
+
+ const { fallback, level = 'panel', variant = 'desktop', label } = this.props
+ const state: FallbackState = {
+ error: this.state.error,
+ reset: this.reset,
+ isChunkError: isChunkLoadError(this.state.error),
+ }
+
+ if (typeof fallback === 'function') return fallback(state)
+ if (fallback !== undefined) return fallback
+
+ return
+ }
+}
+
+interface FallbackProps extends FallbackState {
+ level: Level
+ variant: 'desktop' | 'mobile'
+ label?: string
+}
+
+/**
+ * The visible half. A function component so it can translate; anything mounted
+ * above TranslationProvider gets RootErrorFallback instead, which has no keys to
+ * look up in the first place.
+ */
+export function ErrorFallback({ error, reset, isChunkError, level, variant, label }: FallbackProps) {
+ const { t } = useTranslation()
+ const isPanel = level === 'panel'
+ const mobile = variant === 'mobile'
+
+ const title = isChunkError
+ ? t('common.errorUpdateTitle')
+ : label
+ ? t('common.errorPluginTitle')
+ : isPanel ? t('common.errorPanelTitle') : t('common.errorTitle')
+ const body = isChunkError
+ ? t('common.errorUpdateBody')
+ : isPanel ? t('common.errorPanelBody') : t('common.errorBody')
+
+ const message = error instanceof Error ? error.message : null
+
+ return (
+
+
+
+
{title}
+ {label &&
{label}
}
+
{body}
+
+
+ {/* Worth showing: TREK is self-hosted, so the person seeing this is often the
+ same person who will paste it into an issue. */}
+ {message && (
+
+ {message}
+
+ )}
+
+
+ {/* No retry for a dead chunk — React.lazy would throw again immediately. */}
+ {!isChunkError && (
+
+ )}
+ {(!isPanel || isChunkError) && (
+
+ )}
+
+
+ )
+}
+
+/**
+ * For the boundary outside TranslationProvider. Deliberately untranslated: if the
+ * provider is what crashed, useTranslation() would hand back the default context
+ * whose `t` echoes the key, and the user would read "common.errorTitle".
+ */
+export function RootErrorFallback({ error, isChunkError }: FallbackState) {
+ const message = error instanceof Error ? error.message : null
+ return (
+
+
+
+ {isChunkError ? 'A new version is available' : 'TREK could not start'}
+
+
+ {isChunkError
+ ? 'TREK was updated while this tab was open. Reload to get the new version.'
+ : 'Reloading usually fixes this. Your data is safe.'}
+
+ {message && {message}}
+
+
+ )
+}
diff --git a/client/src/i18n/TranslationContext.tsx b/client/src/i18n/TranslationContext.tsx
index 51bc043f0..fb72b15c1 100644
--- a/client/src/i18n/TranslationContext.tsx
+++ b/client/src/i18n/TranslationContext.tsx
@@ -120,6 +120,10 @@ export function TranslationProvider({ children }: TranslationProviderProps) {
let cancelled = false
loader().then(mod => {
if (!cancelled) setStrings(mod.default)
+ }).catch(err => {
+ // The locale chunk can be gone after a deploy. Keep the strings we have —
+ // an untranslated UI beats an unhandled rejection and a blank screen.
+ console.error('[i18n] locale chunk failed to load', err)
})
return () => { cancelled = true }
}, [language])
diff --git a/client/src/main.tsx b/client/src/main.tsx
index d5dca1a7d..9a2c326c6 100644
--- a/client/src/main.tsx
+++ b/client/src/main.tsx
@@ -34,16 +34,29 @@ import './index.css'
import { maybeInstallTouchDragPolyfill } from './utils/touchDragPolyfill'
import { startConnectivityProbe } from './sync/connectivity'
import { requestPersistentStorage } from './sync/persistentStorage'
+import ErrorBoundary, { RootErrorFallback } from './components/shared/ErrorBoundary'
+import { installGlobalErrorHandlers } from './utils/globalErrorHandlers'
+import { clearChunkReloadMarker } from './utils/chunkReload'
maybeInstallTouchDragPolyfill()
startConnectivityProbe()
// Keep offline data (map tiles, file blobs, IndexedDB) exempt from eviction.
requestPersistentStorage()
+// Event handlers and async code never reach a boundary; this is where they land.
+installGlobalErrorHandlers()
ReactDOM.createRoot(document.getElementById('root')!).render(
-
+ {/* Outermost net: catches App itself and TranslationProvider, which every
+ boundary further in sits below. Its fallback is untranslated on purpose
+ — if the provider is the thing that broke, t() would echo raw keys. */}
+ }>
+
+ ,
)
+
+// The app rendered, so a later deploy is allowed to trigger its own reload.
+clearChunkReloadMarker()
diff --git a/client/src/mobile/MobileShell.tsx b/client/src/mobile/MobileShell.tsx
index fc1bfd7e3..fcd8953ef 100644
--- a/client/src/mobile/MobileShell.tsx
+++ b/client/src/mobile/MobileShell.tsx
@@ -1,6 +1,7 @@
import { ReactNode } from 'react'
import { useMatch } from 'react-router'
import BottomNav from '../components/Layout/BottomNav'
+import ErrorBoundary from '../components/shared/ErrorBoundary'
import MBottomNav from './components/MBottomNav'
import MToastHost from './components/MToastHost'
import './mobile.css'
@@ -32,7 +33,7 @@ export default function MobileShell({ isPhone, children }: MobileShellProps) {
return (
{children}
-
+
)
}
@@ -40,8 +41,8 @@ export default function MobileShell({ isPhone, children }: MobileShellProps) {
return (
{children}
- {!inTripPlanner && }
-
+ {!inTripPlanner && }
+
)
diff --git a/client/src/utils/chunkReload.test.ts b/client/src/utils/chunkReload.test.ts
new file mode 100644
index 000000000..5465a0cd9
--- /dev/null
+++ b/client/src/utils/chunkReload.test.ts
@@ -0,0 +1,73 @@
+import { isChunkLoadError, reloadOnceForChunk, clearChunkReloadMarker } from './chunkReload';
+
+beforeEach(() => {
+ sessionStorage.clear();
+ vi.restoreAllMocks();
+});
+
+describe('isChunkLoadError', () => {
+ // One message per engine — the wording differs and none of them carry a code.
+ it.each([
+ ['Vite', 'Failed to fetch dynamically imported module: /assets/Page-a1b2.js'],
+ ['Firefox', 'error loading dynamically imported module'],
+ ['Safari', 'Importing a module script failed.'],
+ ['Vite CSS', 'Unable to preload CSS for /assets/Page-a1b2.css'],
+ ])('FE-UTIL-CHUNK-001: recognises the %s wording', (_engine, message) => {
+ expect(isChunkLoadError(new Error(message))).toBe(true);
+ });
+
+ it('FE-UTIL-CHUNK-002: leaves ordinary errors alone', () => {
+ expect(isChunkLoadError(new Error('Cannot read properties of undefined'))).toBe(false);
+ expect(isChunkLoadError(null)).toBe(false);
+ expect(isChunkLoadError(undefined)).toBe(false);
+ });
+
+ it('FE-UTIL-CHUNK-003: also reads a plain string, since a rejection need not be an Error', () => {
+ expect(isChunkLoadError('Failed to fetch dynamically imported module')).toBe(true);
+ });
+});
+
+describe('reloadOnceForChunk', () => {
+ function stubReload() {
+ const reload = vi.fn();
+ Object.defineProperty(window, 'location', { value: { ...window.location, reload }, writable: true });
+ return reload;
+ }
+
+ it('FE-UTIL-CHUNK-004: reloads the first time and refuses afterwards', () => {
+ const reload = stubReload();
+ expect(reloadOnceForChunk()).toBe(true);
+ expect(reload).toHaveBeenCalledTimes(1);
+
+ // A chunk that is genuinely missing — bad deploy, stale proxy — would otherwise
+ // put the tab in a reload loop.
+ expect(reloadOnceForChunk()).toBe(false);
+ expect(reload).toHaveBeenCalledTimes(1);
+ });
+
+ it('FE-UTIL-CHUNK-005: clearing the marker lets the next deploy heal too', () => {
+ const reload = stubReload();
+ reloadOnceForChunk();
+ clearChunkReloadMarker();
+ expect(reloadOnceForChunk()).toBe(true);
+ expect(reload).toHaveBeenCalledTimes(2);
+ });
+
+ it('FE-UTIL-CHUNK-006: does not reload blind when storage is unavailable', () => {
+ const reload = stubReload();
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
+ throw new Error('storage blocked');
+ });
+ // Without the marker there is no loop protection, so showing the fallback with
+ // its manual reload button is the safer answer.
+ expect(reloadOnceForChunk()).toBe(false);
+ expect(reload).not.toHaveBeenCalled();
+ });
+
+ it('FE-UTIL-CHUNK-007: clearing survives unavailable storage', () => {
+ vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
+ throw new Error('storage blocked');
+ });
+ expect(() => clearChunkReloadMarker()).not.toThrow();
+ });
+});
diff --git a/client/src/utils/chunkReload.ts b/client/src/utils/chunkReload.ts
new file mode 100644
index 000000000..6eda06900
--- /dev/null
+++ b/client/src/utils/chunkReload.ts
@@ -0,0 +1,56 @@
+/**
+ * Recognising — and surviving — a lazy chunk that no longer exists.
+ *
+ * After a deploy the old index file still references hashed chunks that are gone
+ * from the server, so a dynamic import fails for anyone with the tab still open.
+ * React.lazy caches the rejected promise on the lazy object, which means retrying
+ * the same render fails instantly and forever: the only cure is a reload that
+ * fetches the new index.
+ *
+ * Kept out of the component module so main.tsx can use it without pulling React in.
+ */
+
+const RELOAD_MARKER = 'trek:chunk-reload';
+
+/** Vite's own message, plus what Chrome/Firefox/Safari say for a failed module fetch. */
+const CHUNK_ERROR_PATTERNS = [
+ 'failed to fetch dynamically imported module',
+ 'error loading dynamically imported module',
+ 'importing a module script failed',
+ 'unable to preload css',
+ 'dynamically imported module',
+];
+
+export function isChunkLoadError(error: unknown): boolean {
+ if (!error) return false;
+ const message = error instanceof Error ? error.message : String(error);
+ const haystack = message.toLowerCase();
+ return CHUNK_ERROR_PATTERNS.some(p => haystack.includes(p));
+}
+
+/**
+ * Reload once per session for a given chunk failure, then give up and let the
+ * fallback show. Without the marker a chunk that is genuinely missing — a broken
+ * deploy, a proxy serving stale assets — would put the tab in a reload loop.
+ */
+export function reloadOnceForChunk(): boolean {
+ try {
+ if (sessionStorage.getItem(RELOAD_MARKER)) return false;
+ sessionStorage.setItem(RELOAD_MARKER, String(Date.now()));
+ } catch {
+ // Private mode or a blocked storage partition: reloading blind would risk a
+ // loop, so prefer showing the fallback with its manual reload button.
+ return false;
+ }
+ window.location.reload();
+ return true;
+}
+
+/** Called once the app has rendered successfully, so the next deploy can heal too. */
+export function clearChunkReloadMarker(): void {
+ try {
+ sessionStorage.removeItem(RELOAD_MARKER);
+ } catch {
+ // Nothing to clear if storage is unavailable.
+ }
+}
diff --git a/client/src/utils/globalErrorHandlers.test.ts b/client/src/utils/globalErrorHandlers.test.ts
new file mode 100644
index 000000000..db192b6aa
--- /dev/null
+++ b/client/src/utils/globalErrorHandlers.test.ts
@@ -0,0 +1,78 @@
+import { AxiosError } from 'axios';
+import { installGlobalErrorHandlers } from './globalErrorHandlers';
+
+let teardown: () => void;
+let consoleError: ReturnType;
+let reload: ReturnType;
+
+beforeEach(() => {
+ sessionStorage.clear();
+ consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ reload = vi.fn();
+ Object.defineProperty(window, 'location', { value: { ...window.location, reload }, writable: true });
+ teardown = installGlobalErrorHandlers();
+});
+
+afterEach(() => {
+ teardown();
+ consoleError.mockRestore();
+});
+
+/** jsdom has no PromiseRejectionEvent, so build the shape the handler reads. */
+function rejectWith(reason: unknown) {
+ const event = new Event('unhandledrejection') as Event & { reason: unknown };
+ event.reason = reason;
+ window.dispatchEvent(event);
+}
+
+describe('global error handlers', () => {
+ it('FE-UTIL-GLOBALERR-001: ignores axios rejections, which the app already reports itself', () => {
+ // The interceptor rejects onward and there are 600+ toast.error call sites;
+ // logging here as well would turn every handled 500 into a phantom crash.
+ rejectWith(new AxiosError('Request failed with status code 500'));
+ expect(consoleError).not.toHaveBeenCalled();
+ });
+
+ it('FE-UTIL-GLOBALERR-002: logs a genuine unhandled rejection', () => {
+ const reason = new Error('nobody caught me');
+ rejectWith(reason);
+ expect(consoleError).toHaveBeenCalledWith('[unhandledrejection]', reason);
+ });
+
+ it('FE-UTIL-GLOBALERR-003: reloads when a dynamic import rejects outside render', () => {
+ // A lazy chunk can also be pulled in from an effect or a prefetch, where no
+ // boundary is watching.
+ rejectWith(new Error('Failed to fetch dynamically imported module: /assets/x-1.js'));
+ expect(reload).toHaveBeenCalledTimes(1);
+ expect(consoleError).not.toHaveBeenCalled();
+ });
+
+ it('FE-UTIL-GLOBALERR-004: logs an uncaught error from an event handler', () => {
+ const error = new Error('thrown in onClick');
+ window.dispatchEvent(new ErrorEvent('error', { error, message: error.message }));
+ expect(consoleError).toHaveBeenCalledWith('[window.onerror]', error);
+ });
+
+ it('FE-UTIL-GLOBALERR-005: heals a chunk error arriving as a window error event', () => {
+ window.dispatchEvent(
+ new ErrorEvent('error', { error: new Error('Unable to preload CSS for /assets/x.css') }),
+ );
+ expect(reload).toHaveBeenCalledTimes(1);
+ });
+
+ it('FE-UTIL-GLOBALERR-006: teardown unsubscribes both listeners', () => {
+ // Asserted through removeEventListener rather than by firing an event: an
+ // ErrorEvent with nobody listening is an uncaught error, and vitest reports
+ // that against the file even when every test passed.
+ const remove = vi.spyOn(window, 'removeEventListener');
+ teardown();
+ expect(remove).toHaveBeenCalledWith('unhandledrejection', expect.any(Function));
+ expect(remove).toHaveBeenCalledWith('error', expect.any(Function));
+
+ // The rejection path has no such side effect, so it is safe to prove silence.
+ rejectWith(new Error('after teardown'));
+ expect(consoleError).not.toHaveBeenCalled();
+
+ teardown = () => {};
+ });
+});
diff --git a/client/src/utils/globalErrorHandlers.ts b/client/src/utils/globalErrorHandlers.ts
new file mode 100644
index 000000000..cbe9d88aa
--- /dev/null
+++ b/client/src/utils/globalErrorHandlers.ts
@@ -0,0 +1,58 @@
+import axios from 'axios'
+import { isChunkLoadError, reloadOnceForChunk } from './chunkReload'
+
+/**
+ * The half no boundary can reach: throws in event handlers, in async code, and
+ * rejected promises nobody caught.
+ *
+ * Deliberately quiet. It logs, and it heals one specific case — a dynamic import
+ * that failed because the chunk is gone after a deploy. It does not toast and it
+ * does not redirect: the axios interceptor already redirects on 401/403 and
+ * rewrites 429 (api/client.ts), and there are 600+ `toast.error` call sites that
+ * report their own failures. Reacting again here would mean double toasts and, in
+ * the redirect cases, redirect loops.
+ */
+
+/** Axios rejects onward from its interceptor, so every caller without a catch lands here. */
+function isHandledNetworkError(reason: unknown): boolean {
+ return axios.isAxiosError(reason)
+}
+
+function onUnhandledRejection(event: PromiseRejectionEvent): void {
+ const reason = event.reason
+
+ if (isChunkLoadError(reason)) {
+ // A lazy import can reject outside render too — a prefetch, or a chunk pulled
+ // in from an effect. Same cure, same once-per-session guard.
+ reloadOnceForChunk()
+ return
+ }
+
+ if (isHandledNetworkError(reason)) return
+
+ console.error('[unhandledrejection]', reason)
+}
+
+function onError(event: ErrorEvent): void {
+ if (isChunkLoadError(event.error ?? event.message)) {
+ reloadOnceForChunk()
+ return
+ }
+ console.error('[window.onerror]', event.error ?? event.message)
+}
+
+/**
+ * Call once, before the app renders. Returns a teardown so tests can install and
+ * remove it without leaking listeners between files.
+ */
+export function installGlobalErrorHandlers(): () => void {
+ window.addEventListener('unhandledrejection', onUnhandledRejection)
+ // Bubble phase only: the capture phase would also fire for every failed
+ // and , which on a map-heavy app means hundreds of tile requests.
+ window.addEventListener('error', onError)
+
+ return () => {
+ window.removeEventListener('unhandledrejection', onUnhandledRejection)
+ window.removeEventListener('error', onError)
+ }
+}
diff --git a/shared/src/i18n/ar/common.ts b/shared/src/i18n/ar/common.ts
index 59435ea99..e9c8460fd 100644
--- a/shared/src/i18n/ar/common.ts
+++ b/shared/src/i18n/ar/common.ts
@@ -64,5 +64,14 @@ const common: TranslationStrings = {
'common.datepicker.typeDate': 'Type a date', // en-fallback
'common.datepicker.dialog': 'Date picker', // en-fallback
'common.datepicker.clearDate': 'Clear date', // en-fallback
+ 'common.errorTitle': 'حدث خطأ ما',
+ 'common.errorBody': 'توقف هذا الجزء من التطبيق عن العمل. بياناتك آمنة.',
+ 'common.errorPanelTitle': 'تعذّر عرض هذا القسم',
+ 'common.errorPanelBody': 'بقية الصفحة لا تزال تعمل.',
+ 'common.errorRetry': 'إعادة المحاولة',
+ 'common.errorReload': 'إعادة تحميل الصفحة',
+ 'common.errorUpdateTitle': 'يتوفر إصدار جديد',
+ 'common.errorUpdateBody': 'تم تحديث TREK أثناء فتح علامة التبويب هذه. أعد التحميل للحصول على الإصدار الجديد.',
+ 'common.errorPluginTitle': 'تعذّر عرض هذه الإضافة',
};
export default common;
diff --git a/shared/src/i18n/br/common.ts b/shared/src/i18n/br/common.ts
index 6a75d6205..06a63a212 100644
--- a/shared/src/i18n/br/common.ts
+++ b/shared/src/i18n/br/common.ts
@@ -64,5 +64,14 @@ const common: TranslationStrings = {
'common.datepicker.typeDate': 'Type a date', // en-fallback
'common.datepicker.dialog': 'Date picker', // en-fallback
'common.datepicker.clearDate': 'Clear date', // en-fallback
+ 'common.errorTitle': 'Un dra bennak a zo aet a-dreuz',
+ 'common.errorBody': 'Al lodenn-mañ eus an arload n\'a ket en-dro ken. Hoc\'h roadennoù a zo diarvar.',
+ 'common.errorPanelTitle': 'N\'eus ket bet gallet diskwel al lodenn-mañ',
+ 'common.errorPanelBody': 'Peurrest ar bajenn a ya en-dro atav.',
+ 'common.errorRetry': 'Klask en-dro',
+ 'common.errorReload': 'Adkargañ ar bajenn',
+ 'common.errorUpdateTitle': 'Un handelv nevez a zo hegerz',
+ 'common.errorUpdateBody': 'TREK a zo bet hizivaet e-pad ma oa digor an ivinell-mañ. Adkargit evit kaout an handelv nevez.',
+ 'common.errorPluginTitle': 'N\'eus ket bet gallet diskwel an enlugellad-mañ',
};
export default common;
diff --git a/shared/src/i18n/ca/common.ts b/shared/src/i18n/ca/common.ts
index 34bf4e579..a0d8e23d1 100644
--- a/shared/src/i18n/ca/common.ts
+++ b/shared/src/i18n/ca/common.ts
@@ -65,5 +65,14 @@ const common: TranslationStrings = {
'common.datepicker.typeDate': 'Escriu una data',
'common.datepicker.dialog': 'Selector de data',
'common.datepicker.clearDate': 'Netejar data',
+ 'common.errorTitle': 'Alguna cosa ha anat malament',
+ 'common.errorBody': 'Aquesta part de l\'aplicació ha deixat de funcionar. Les teves dades estan segures.',
+ 'common.errorPanelTitle': 'No s\'ha pogut mostrar aquesta secció',
+ 'common.errorPanelBody': 'La resta de la pàgina continua funcionant.',
+ 'common.errorRetry': 'Torna-ho a provar',
+ 'common.errorReload': 'Torna a carregar la pàgina',
+ 'common.errorUpdateTitle': 'Hi ha una versió nova disponible',
+ 'common.errorUpdateBody': 'TREK s\'ha actualitzat mentre aquesta pestanya era oberta. Torna a carregar per obtenir la versió nova.',
+ 'common.errorPluginTitle': 'No s\'ha pogut mostrar aquest connector',
};
export default common;
diff --git a/shared/src/i18n/cs/common.ts b/shared/src/i18n/cs/common.ts
index e90aa2317..2dc1aca1d 100644
--- a/shared/src/i18n/cs/common.ts
+++ b/shared/src/i18n/cs/common.ts
@@ -64,5 +64,14 @@ const common: TranslationStrings = {
'common.datepicker.typeDate': 'Type a date', // en-fallback
'common.datepicker.dialog': 'Date picker', // en-fallback
'common.datepicker.clearDate': 'Clear date', // en-fallback
+ 'common.errorTitle': 'Něco se pokazilo',
+ 'common.errorBody': 'Tato část aplikace přestala fungovat. Vaše data jsou v bezpečí.',
+ 'common.errorPanelTitle': 'Tuto sekci se nepodařilo zobrazit',
+ 'common.errorPanelBody': 'Zbytek stránky funguje dál.',
+ 'common.errorRetry': 'Zkusit znovu',
+ 'common.errorReload': 'Načíst stránku znovu',
+ 'common.errorUpdateTitle': 'Je dostupná nová verze',
+ 'common.errorUpdateBody': 'TREK byl aktualizován, zatímco byla tato karta otevřená. Načtěte znovu pro novou verzi.',
+ 'common.errorPluginTitle': 'Tento plugin se nepodařilo zobrazit',
};
export default common;
diff --git a/shared/src/i18n/de/common.ts b/shared/src/i18n/de/common.ts
index 635d913dd..9fa9aa639 100644
--- a/shared/src/i18n/de/common.ts
+++ b/shared/src/i18n/de/common.ts
@@ -64,5 +64,14 @@ const common: TranslationStrings = {
'common.datepicker.typeDate': 'Datum eingeben',
'common.datepicker.dialog': 'Datumsauswahl',
'common.datepicker.clearDate': 'Datum löschen',
+ 'common.errorTitle': 'Etwas ist schiefgelaufen',
+ 'common.errorBody': 'Dieser Teil der App funktioniert nicht mehr. Deine Daten sind sicher.',
+ 'common.errorPanelTitle': 'Dieser Bereich konnte nicht angezeigt werden',
+ 'common.errorPanelBody': 'Der Rest der Seite funktioniert weiterhin.',
+ 'common.errorRetry': 'Erneut versuchen',
+ 'common.errorReload': 'Seite neu laden',
+ 'common.errorUpdateTitle': 'Eine neue Version ist verfügbar',
+ 'common.errorUpdateBody': 'TREK wurde aktualisiert, während dieser Tab offen war. Lade neu, um die neue Version zu erhalten.',
+ 'common.errorPluginTitle': 'Dieses Plugin konnte nicht angezeigt werden',
};
export default common;
diff --git a/shared/src/i18n/en/common.ts b/shared/src/i18n/en/common.ts
index aada638ef..ab6f2e272 100644
--- a/shared/src/i18n/en/common.ts
+++ b/shared/src/i18n/en/common.ts
@@ -64,5 +64,14 @@ const common: TranslationStrings = {
'common.datepicker.typeDate': 'Type a date',
'common.datepicker.dialog': 'Date picker',
'common.datepicker.clearDate': 'Clear date',
+ 'common.errorTitle': 'Something went wrong',
+ 'common.errorBody': 'This part of the app stopped working. Your data is safe.',
+ 'common.errorPanelTitle': 'This section could not be shown',
+ 'common.errorPanelBody': 'The rest of the page still works.',
+ 'common.errorRetry': 'Try again',
+ 'common.errorReload': 'Reload page',
+ 'common.errorUpdateTitle': 'A new version is available',
+ 'common.errorUpdateBody': 'TREK was updated while this tab was open. Reload to get the new version.',
+ 'common.errorPluginTitle': 'This plugin could not be shown',
};
export default common;
diff --git a/shared/src/i18n/es/common.ts b/shared/src/i18n/es/common.ts
index d7ee233ec..0e78ee833 100644
--- a/shared/src/i18n/es/common.ts
+++ b/shared/src/i18n/es/common.ts
@@ -64,5 +64,14 @@ const common: TranslationStrings = {
'common.datepicker.typeDate': 'Type a date', // en-fallback
'common.datepicker.dialog': 'Date picker', // en-fallback
'common.datepicker.clearDate': 'Clear date', // en-fallback
+ 'common.errorTitle': 'Algo salió mal',
+ 'common.errorBody': 'Esta parte de la aplicación dejó de funcionar. Tus datos están a salvo.',
+ 'common.errorPanelTitle': 'No se pudo mostrar esta sección',
+ 'common.errorPanelBody': 'El resto de la página sigue funcionando.',
+ 'common.errorRetry': 'Reintentar',
+ 'common.errorReload': 'Recargar la página',
+ 'common.errorUpdateTitle': 'Hay una nueva versión disponible',
+ 'common.errorUpdateBody': 'TREK se actualizó mientras esta pestaña estaba abierta. Recarga para obtener la nueva versión.',
+ 'common.errorPluginTitle': 'No se pudo mostrar este plugin',
};
export default common;
diff --git a/shared/src/i18n/fr/common.ts b/shared/src/i18n/fr/common.ts
index 53a07f391..240a8ef48 100644
--- a/shared/src/i18n/fr/common.ts
+++ b/shared/src/i18n/fr/common.ts
@@ -64,5 +64,14 @@ const common: TranslationStrings = {
'common.datepicker.typeDate': 'Type a date', // en-fallback
'common.datepicker.dialog': 'Date picker', // en-fallback
'common.datepicker.clearDate': 'Clear date', // en-fallback
+ 'common.errorTitle': 'Une erreur s\'est produite',
+ 'common.errorBody': 'Cette partie de l\'application ne fonctionne plus. Vos données sont en sécurité.',
+ 'common.errorPanelTitle': 'Cette section n\'a pas pu être affichée',
+ 'common.errorPanelBody': 'Le reste de la page fonctionne toujours.',
+ 'common.errorRetry': 'Réessayer',
+ 'common.errorReload': 'Recharger la page',
+ 'common.errorUpdateTitle': 'Une nouvelle version est disponible',
+ 'common.errorUpdateBody': 'TREK a été mis à jour pendant que cet onglet était ouvert. Rechargez pour obtenir la nouvelle version.',
+ 'common.errorPluginTitle': 'Ce plugin n\'a pas pu être affiché',
};
export default common;
diff --git a/shared/src/i18n/gr/common.ts b/shared/src/i18n/gr/common.ts
index 1207bbf8c..1bed8d578 100644
--- a/shared/src/i18n/gr/common.ts
+++ b/shared/src/i18n/gr/common.ts
@@ -64,5 +64,14 @@ const common: TranslationStrings = {
'common.datepicker.typeDate': 'Type a date', // en-fallback
'common.datepicker.dialog': 'Date picker', // en-fallback
'common.datepicker.clearDate': 'Clear date', // en-fallback
+ 'common.errorTitle': 'Κάτι πήγε στραβά',
+ 'common.errorBody': 'Αυτό το τμήμα της εφαρμογής σταμάτησε να λειτουργεί. Τα δεδομένα σας είναι ασφαλή.',
+ 'common.errorPanelTitle': 'Δεν ήταν δυνατή η εμφάνιση αυτής της ενότητας',
+ 'common.errorPanelBody': 'Η υπόλοιπη σελίδα λειτουργεί κανονικά.',
+ 'common.errorRetry': 'Δοκιμάστε ξανά',
+ 'common.errorReload': 'Επαναφόρτωση σελίδας',
+ 'common.errorUpdateTitle': 'Διατίθεται νέα έκδοση',
+ 'common.errorUpdateBody': 'Το TREK ενημερώθηκε ενώ αυτή η καρτέλα ήταν ανοιχτή. Κάντε επαναφόρτωση για τη νέα έκδοση.',
+ 'common.errorPluginTitle': 'Δεν ήταν δυνατή η εμφάνιση αυτού του πρόσθετου',
};
export default common;
diff --git a/shared/src/i18n/hu/common.ts b/shared/src/i18n/hu/common.ts
index 779b0100e..5deb64cf9 100644
--- a/shared/src/i18n/hu/common.ts
+++ b/shared/src/i18n/hu/common.ts
@@ -64,5 +64,14 @@ const common: TranslationStrings = {
'common.datepicker.typeDate': 'Type a date', // en-fallback
'common.datepicker.dialog': 'Date picker', // en-fallback
'common.datepicker.clearDate': 'Clear date', // en-fallback
+ 'common.errorTitle': 'Valami hiba történt',
+ 'common.errorBody': 'Az alkalmazás ezen része leállt. Az adatai biztonságban vannak.',
+ 'common.errorPanelTitle': 'Ezt a szakaszt nem sikerült megjeleníteni',
+ 'common.errorPanelBody': 'Az oldal többi része továbbra is működik.',
+ 'common.errorRetry': 'Újra',
+ 'common.errorReload': 'Oldal újratöltése',
+ 'common.errorUpdateTitle': 'Új verzió érhető el',
+ 'common.errorUpdateBody': 'A TREK frissült, amíg ez a lap nyitva volt. Töltse újra az új verzióért.',
+ 'common.errorPluginTitle': 'Ezt a bővítményt nem sikerült megjeleníteni',
};
export default common;
diff --git a/shared/src/i18n/id/common.ts b/shared/src/i18n/id/common.ts
index bb3bbd258..c6af809e7 100644
--- a/shared/src/i18n/id/common.ts
+++ b/shared/src/i18n/id/common.ts
@@ -64,5 +64,14 @@ const common: TranslationStrings = {
'common.datepicker.typeDate': 'Type a date', // en-fallback
'common.datepicker.dialog': 'Date picker', // en-fallback
'common.datepicker.clearDate': 'Clear date', // en-fallback
+ 'common.errorTitle': 'Terjadi kesalahan',
+ 'common.errorBody': 'Bagian aplikasi ini berhenti bekerja. Data Anda aman.',
+ 'common.errorPanelTitle': 'Bagian ini tidak dapat ditampilkan',
+ 'common.errorPanelBody': 'Bagian lain dari halaman masih berfungsi.',
+ 'common.errorRetry': 'Coba lagi',
+ 'common.errorReload': 'Muat ulang halaman',
+ 'common.errorUpdateTitle': 'Versi baru tersedia',
+ 'common.errorUpdateBody': 'TREK diperbarui saat tab ini terbuka. Muat ulang untuk mendapatkan versi baru.',
+ 'common.errorPluginTitle': 'Plugin ini tidak dapat ditampilkan',
};
export default common;
diff --git a/shared/src/i18n/it/common.ts b/shared/src/i18n/it/common.ts
index 04851d402..a8a718139 100644
--- a/shared/src/i18n/it/common.ts
+++ b/shared/src/i18n/it/common.ts
@@ -64,5 +64,14 @@ const common: TranslationStrings = {
'common.datepicker.typeDate': 'Inserisci una data',
'common.datepicker.dialog': 'Selettore di date',
'common.datepicker.clearDate': 'Cancella data',
+ 'common.errorTitle': 'Qualcosa è andato storto',
+ 'common.errorBody': 'Questa parte dell\'app ha smesso di funzionare. I tuoi dati sono al sicuro.',
+ 'common.errorPanelTitle': 'Impossibile mostrare questa sezione',
+ 'common.errorPanelBody': 'Il resto della pagina funziona ancora.',
+ 'common.errorRetry': 'Riprova',
+ 'common.errorReload': 'Ricarica la pagina',
+ 'common.errorUpdateTitle': 'È disponibile una nuova versione',
+ 'common.errorUpdateBody': 'TREK è stato aggiornato mentre questa scheda era aperta. Ricarica per ottenere la nuova versione.',
+ 'common.errorPluginTitle': 'Impossibile mostrare questo plugin',
};
export default common;
diff --git a/shared/src/i18n/ja/common.ts b/shared/src/i18n/ja/common.ts
index d904e9e57..670604f2b 100644
--- a/shared/src/i18n/ja/common.ts
+++ b/shared/src/i18n/ja/common.ts
@@ -64,5 +64,14 @@ const common: TranslationStrings = {
'common.datepicker.typeDate': 'Type a date', // en-fallback
'common.datepicker.dialog': 'Date picker', // en-fallback
'common.datepicker.clearDate': 'Clear date', // en-fallback
+ 'common.errorTitle': '問題が発生しました',
+ 'common.errorBody': 'アプリのこの部分が動作しなくなりました。データは安全です。',
+ 'common.errorPanelTitle': 'このセクションを表示できませんでした',
+ 'common.errorPanelBody': 'ページの他の部分は引き続き使用できます。',
+ 'common.errorRetry': '再試行',
+ 'common.errorReload': 'ページを再読み込み',
+ 'common.errorUpdateTitle': '新しいバージョンがあります',
+ 'common.errorUpdateBody': 'このタブを開いている間に TREK が更新されました。再読み込みして新しいバージョンを取得してください。',
+ 'common.errorPluginTitle': 'このプラグインを表示できませんでした',
};
export default common;
diff --git a/shared/src/i18n/ko/common.ts b/shared/src/i18n/ko/common.ts
index e91d9ac01..06b157008 100644
--- a/shared/src/i18n/ko/common.ts
+++ b/shared/src/i18n/ko/common.ts
@@ -64,5 +64,14 @@ const common: TranslationStrings = {
'common.datepicker.typeDate': 'Type a date', // en-fallback
'common.datepicker.dialog': 'Date picker', // en-fallback
'common.datepicker.clearDate': 'Clear date', // en-fallback
+ 'common.errorTitle': '문제가 발생했습니다',
+ 'common.errorBody': '앱의 이 부분이 작동을 멈췄습니다. 데이터는 안전합니다.',
+ 'common.errorPanelTitle': '이 섹션을 표시할 수 없습니다',
+ 'common.errorPanelBody': '페이지의 나머지 부분은 계속 작동합니다.',
+ 'common.errorRetry': '다시 시도',
+ 'common.errorReload': '페이지 새로고침',
+ 'common.errorUpdateTitle': '새 버전이 있습니다',
+ 'common.errorUpdateBody': '이 탭이 열려 있는 동안 TREK이 업데이트되었습니다. 새로고침하여 새 버전을 받으세요.',
+ 'common.errorPluginTitle': '이 플러그인을 표시할 수 없습니다',
};
export default common;
diff --git a/shared/src/i18n/nl/common.ts b/shared/src/i18n/nl/common.ts
index 3be76cce7..484ab4c85 100644
--- a/shared/src/i18n/nl/common.ts
+++ b/shared/src/i18n/nl/common.ts
@@ -64,5 +64,14 @@ const common: TranslationStrings = {
'common.datepicker.typeDate': 'Type a date', // en-fallback
'common.datepicker.dialog': 'Date picker', // en-fallback
'common.datepicker.clearDate': 'Clear date', // en-fallback
+ 'common.errorTitle': 'Er is iets misgegaan',
+ 'common.errorBody': 'Dit deel van de app werkt niet meer. Je gegevens zijn veilig.',
+ 'common.errorPanelTitle': 'Dit onderdeel kon niet worden getoond',
+ 'common.errorPanelBody': 'De rest van de pagina werkt nog steeds.',
+ 'common.errorRetry': 'Opnieuw proberen',
+ 'common.errorReload': 'Pagina herladen',
+ 'common.errorUpdateTitle': 'Er is een nieuwe versie beschikbaar',
+ 'common.errorUpdateBody': 'TREK is bijgewerkt terwijl dit tabblad open stond. Herlaad om de nieuwe versie te krijgen.',
+ 'common.errorPluginTitle': 'Deze plug-in kon niet worden getoond',
};
export default common;
diff --git a/shared/src/i18n/pl/common.ts b/shared/src/i18n/pl/common.ts
index b8ff42ccf..de4321452 100644
--- a/shared/src/i18n/pl/common.ts
+++ b/shared/src/i18n/pl/common.ts
@@ -64,5 +64,14 @@ const common: TranslationStrings = {
'common.datepicker.typeDate': 'Type a date', // en-fallback
'common.datepicker.dialog': 'Date picker', // en-fallback
'common.datepicker.clearDate': 'Clear date', // en-fallback
+ 'common.errorTitle': 'Coś poszło nie tak',
+ 'common.errorBody': 'Ta część aplikacji przestała działać. Twoje dane są bezpieczne.',
+ 'common.errorPanelTitle': 'Nie udało się wyświetlić tej sekcji',
+ 'common.errorPanelBody': 'Reszta strony nadal działa.',
+ 'common.errorRetry': 'Spróbuj ponownie',
+ 'common.errorReload': 'Odśwież stronę',
+ 'common.errorUpdateTitle': 'Dostępna jest nowa wersja',
+ 'common.errorUpdateBody': 'TREK został zaktualizowany, gdy ta karta była otwarta. Odśwież, aby pobrać nową wersję.',
+ 'common.errorPluginTitle': 'Nie udało się wyświetlić tej wtyczki',
};
export default common;
diff --git a/shared/src/i18n/ru/common.ts b/shared/src/i18n/ru/common.ts
index b3bc27e9d..cd3c981cb 100644
--- a/shared/src/i18n/ru/common.ts
+++ b/shared/src/i18n/ru/common.ts
@@ -64,5 +64,14 @@ const common: TranslationStrings = {
'common.datepicker.typeDate': 'Type a date', // en-fallback
'common.datepicker.dialog': 'Date picker', // en-fallback
'common.datepicker.clearDate': 'Clear date', // en-fallback
+ 'common.errorTitle': 'Что-то пошло не так',
+ 'common.errorBody': 'Эта часть приложения перестала работать. Ваши данные в безопасности.',
+ 'common.errorPanelTitle': 'Не удалось показать этот раздел',
+ 'common.errorPanelBody': 'Остальная часть страницы работает.',
+ 'common.errorRetry': 'Повторить',
+ 'common.errorReload': 'Обновить страницу',
+ 'common.errorUpdateTitle': 'Доступна новая версия',
+ 'common.errorUpdateBody': 'TREK обновился, пока эта вкладка была открыта. Обновите страницу, чтобы получить новую версию.',
+ 'common.errorPluginTitle': 'Не удалось показать этот плагин',
};
export default common;
diff --git a/shared/src/i18n/sv/common.ts b/shared/src/i18n/sv/common.ts
index 60efcedcd..2da689bbb 100644
--- a/shared/src/i18n/sv/common.ts
+++ b/shared/src/i18n/sv/common.ts
@@ -64,5 +64,14 @@ const common: TranslationStrings = {
'common.collapse': 'Dölj',
'common.copy': 'Kopiera',
'common.copied': 'Kopierad',
+ 'common.errorTitle': 'Något gick fel',
+ 'common.errorBody': 'Den här delen av appen slutade fungera. Dina data är säkra.',
+ 'common.errorPanelTitle': 'Det här avsnittet kunde inte visas',
+ 'common.errorPanelBody': 'Resten av sidan fungerar fortfarande.',
+ 'common.errorRetry': 'Försök igen',
+ 'common.errorReload': 'Ladda om sidan',
+ 'common.errorUpdateTitle': 'En ny version är tillgänglig',
+ 'common.errorUpdateBody': 'TREK uppdaterades medan den här fliken var öppen. Ladda om för att hämta den nya versionen.',
+ 'common.errorPluginTitle': 'Det här tillägget kunde inte visas',
};
export default common;
diff --git a/shared/src/i18n/tr/common.ts b/shared/src/i18n/tr/common.ts
index 7f6baf00f..f6f1c5d7b 100644
--- a/shared/src/i18n/tr/common.ts
+++ b/shared/src/i18n/tr/common.ts
@@ -64,5 +64,14 @@ const common: TranslationStrings = {
'common.datepicker.typeDate': 'Type a date', // en-fallback
'common.datepicker.dialog': 'Date picker', // en-fallback
'common.datepicker.clearDate': 'Clear date', // en-fallback
+ 'common.errorTitle': 'Bir şeyler ters gitti',
+ 'common.errorBody': 'Uygulamanın bu bölümü çalışmayı durdurdu. Verileriniz güvende.',
+ 'common.errorPanelTitle': 'Bu bölüm gösterilemedi',
+ 'common.errorPanelBody': 'Sayfanın geri kalanı çalışmaya devam ediyor.',
+ 'common.errorRetry': 'Yeniden dene',
+ 'common.errorReload': 'Sayfayı yenile',
+ 'common.errorUpdateTitle': 'Yeni bir sürüm mevcut',
+ 'common.errorUpdateBody': 'Bu sekme açıkken TREK güncellendi. Yeni sürümü almak için sayfayı yenileyin.',
+ 'common.errorPluginTitle': 'Bu eklenti gösterilemedi',
};
export default common;
diff --git a/shared/src/i18n/uk/common.ts b/shared/src/i18n/uk/common.ts
index 74af732c9..4db8a6081 100644
--- a/shared/src/i18n/uk/common.ts
+++ b/shared/src/i18n/uk/common.ts
@@ -64,5 +64,14 @@ const common: TranslationStrings = {
'common.datepicker.typeDate': 'Type a date', // en-fallback
'common.datepicker.dialog': 'Date picker', // en-fallback
'common.datepicker.clearDate': 'Clear date', // en-fallback
+ 'common.errorTitle': 'Щось пішло не так',
+ 'common.errorBody': 'Ця частина застосунку перестала працювати. Ваші дані в безпеці.',
+ 'common.errorPanelTitle': 'Не вдалося показати цей розділ',
+ 'common.errorPanelBody': 'Решта сторінки працює.',
+ 'common.errorRetry': 'Спробувати ще раз',
+ 'common.errorReload': 'Перезавантажити сторінку',
+ 'common.errorUpdateTitle': 'Доступна нова версія',
+ 'common.errorUpdateBody': 'TREK оновився, поки ця вкладка була відкрита. Перезавантажте, щоб отримати нову версію.',
+ 'common.errorPluginTitle': 'Не вдалося показати цей плагін',
};
export default common;
diff --git a/shared/src/i18n/vi/common.ts b/shared/src/i18n/vi/common.ts
index 9c1493ef7..6faa90102 100644
--- a/shared/src/i18n/vi/common.ts
+++ b/shared/src/i18n/vi/common.ts
@@ -64,5 +64,14 @@ const common: TranslationStrings = {
'common.collapse': 'Thu gọn',
'common.copy': 'Sao chép',
'common.copied': 'Đã sao chép',
+ 'common.errorTitle': 'Đã xảy ra lỗi',
+ 'common.errorBody': 'Phần này của ứng dụng đã ngừng hoạt động. Dữ liệu của bạn vẫn an toàn.',
+ 'common.errorPanelTitle': 'Không thể hiển thị mục này',
+ 'common.errorPanelBody': 'Phần còn lại của trang vẫn hoạt động.',
+ 'common.errorRetry': 'Thử lại',
+ 'common.errorReload': 'Tải lại trang',
+ 'common.errorUpdateTitle': 'Đã có phiên bản mới',
+ 'common.errorUpdateBody': 'TREK đã được cập nhật trong khi thẻ này đang mở. Hãy tải lại để nhận phiên bản mới.',
+ 'common.errorPluginTitle': 'Không thể hiển thị plugin này',
};
export default common;
diff --git a/shared/src/i18n/zh-TW/common.ts b/shared/src/i18n/zh-TW/common.ts
index d3349ffef..396a48cc1 100644
--- a/shared/src/i18n/zh-TW/common.ts
+++ b/shared/src/i18n/zh-TW/common.ts
@@ -64,5 +64,14 @@ const common: TranslationStrings = {
'common.datepicker.typeDate': 'Type a date', // en-fallback
'common.datepicker.dialog': 'Date picker', // en-fallback
'common.datepicker.clearDate': 'Clear date', // en-fallback
+ 'common.errorTitle': '發生錯誤',
+ 'common.errorBody': '應用程式的這個部分已停止運作。您的資料是安全的。',
+ 'common.errorPanelTitle': '無法顯示此區段',
+ 'common.errorPanelBody': '頁面的其餘部分仍可正常使用。',
+ 'common.errorRetry': '重試',
+ 'common.errorReload': '重新載入頁面',
+ 'common.errorUpdateTitle': '有新版本可用',
+ 'common.errorUpdateBody': '此分頁開啟期間 TREK 已更新。請重新載入以取得新版本。',
+ 'common.errorPluginTitle': '無法顯示此外掛程式',
};
export default common;
diff --git a/shared/src/i18n/zh/common.ts b/shared/src/i18n/zh/common.ts
index bbe871a2c..4c5fb1f96 100644
--- a/shared/src/i18n/zh/common.ts
+++ b/shared/src/i18n/zh/common.ts
@@ -64,5 +64,14 @@ const common: TranslationStrings = {
'common.datepicker.typeDate': '输入日期',
'common.datepicker.dialog': '日期选择器',
'common.datepicker.clearDate': '清除日期',
+ 'common.errorTitle': '出错了',
+ 'common.errorBody': '应用的这一部分已停止工作。您的数据是安全的。',
+ 'common.errorPanelTitle': '无法显示此部分',
+ 'common.errorPanelBody': '页面的其余部分仍可正常使用。',
+ 'common.errorRetry': '重试',
+ 'common.errorReload': '重新加载页面',
+ 'common.errorUpdateTitle': '有新版本可用',
+ 'common.errorUpdateBody': '此标签页打开期间 TREK 已更新。请重新加载以获取新版本。',
+ 'common.errorPluginTitle': '无法显示此插件',
};
export default common;