feat(client): catch render errors instead of blanking the page (#1805)

* feat(client): catch render errors instead of blanking the page

There were no error boundaries anywhere, so one throw during render unmounted the
whole tree and left the user staring at a white page with no way back.

ErrorBoundary holds the state in a class — getDerivedStateFromError has no hook
equivalent — and renders a function component for the visible half, so the panel
can translate normally. TranslationContext isn't exported, so contextType was not
an option anyway.

Two things it does beyond showing a message. A chunk that 404s after a deploy gets
one reload rather than a retry button: React.lazy caches the rejected promise, so
retrying re-throws immediately. And the reload happens once per session, or a
genuinely missing chunk would spin the tab.

The global handlers in main.tsx cover what no boundary reaches — event handlers
and async code. They filter axios errors: the interceptor already redirects on
401/403 and there are 600+ toast.error call sites, so reporting again would mean
double toasts and, for the redirects, loops.

* feat(client): isolate routes, widgets and the shell chrome

The route boundary goes on the single line where all 16 protected routes render
their content, inside MobileShell rather than around it: the navigation survives,
so a broken page is something the user can walk away from instead of a dead end.
The mobile --m-* tokens live on the shell's .m-root too, so a fallback outside it
would render invisible.

key={location.pathname}, not resetKeys — ProtectedRoute is the same component at
the same position for every protected route, so otherwise a failure could follow
the user across a navigation.

The public routes render outside ProtectedRoute and were therefore uncovered,
including /login (where people land when everything else failed) and the two
anonymous share pages. PublicRoute wraps those seven.

The five global widgets and the shell chrome get fallback={null} each: a broken
widget should disappear quietly, and if BottomNav dies the user loses the very
thing they need to leave the route.

* feat(client): isolate the GL maps and the plugin frame

Both map wrappers put the boundary outside their Suspense, not inside: Suspense
handles a pending promise, a rejected one throws straight past it. Falling back to
the Leaflet renderer keeps a usable map rather than showing an error card — the
same downgrade the Suspense fallback already does while loading.

PluginFrame is third-party code rendering in our tree; a throw there should cost
the plugin, not the page. resetKeys={[pluginId]} so switching plugins clears it.

The locale loader had no .catch (TranslationContext.tsx). After a deploy the chunk
can be gone, which produced an unhandled rejection and left the UI silently on the
previous language. It now keeps the strings it has and says so in the console.
This commit is contained in:
Maurice
2026-08-06 23:42:57 +02:00
committed by GitHub
parent 6c63a9bb60
commit 39b6d9f786
36 changed files with 1017 additions and 50 deletions
+45 -12
View File
@@ -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 <MobileShell isPhone={isPhone}>{children}</MobileShell>
// 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 (
<MobileShell isPhone={isPhone}>
<ErrorBoundary
key={location.pathname}
boundaryId="route"
level="route"
variant={isPhone ? 'mobile' : 'desktop'}
>
{children}
</ErrorBoundary>
</MobileShell>
)
}
/**
* 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 (
<ErrorBoundary key={location.pathname} boundaryId="public-route" level="route">
{children}
</ErrorBoundary>
)
}
function RootRedirect() {
@@ -210,21 +243,21 @@ export default function App() {
return (
<TranslationProvider>
{!isAuthPage && <SystemNoticeHost />}
<ToastContainer />
{!isAuthPage && <BackgroundTasksWidget />}
{!isAuthPage && <ErrorBoundary boundaryId="widget:system-notice" fallback={null}><SystemNoticeHost /></ErrorBoundary>}
<ErrorBoundary boundaryId="widget:toast" fallback={null}><ToastContainer /></ErrorBoundary>
{!isAuthPage && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
{!isAuthPage && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
<OfflineBanner />
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/shared/:token" element={<SharedTripPage />} />
<Route path="/public/journey/:token" element={<JourneyPublicPage />} />
<Route path="/register" element={<LoginPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/login" element={<PublicRoute><LoginPage /></PublicRoute>} />
<Route path="/shared/:token" element={<PublicRoute><SharedTripPage /></PublicRoute>} />
<Route path="/public/journey/:token" element={<PublicRoute><JourneyPublicPage /></PublicRoute>} />
<Route path="/register" element={<PublicRoute><LoginPage /></PublicRoute>} />
<Route path="/forgot-password" element={<PublicRoute><ForgotPasswordPage /></PublicRoute>} />
<Route path="/reset-password" element={<PublicRoute><ResetPasswordPage /></PublicRoute>} />
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
<Route path="/oauth/consent" element={<OAuthAuthorizePage />} />
<Route path="/oauth/consent" element={<PublicRoute><OAuthAuthorizePage /></PublicRoute>} />
<Route
path="/dashboard"
element={
@@ -1,6 +1,7 @@
import { forwardRef, lazy, Suspense, useImperativeHandle, useRef } from 'react'
import { useSettingsStore } from '../../store/settingsStore'
import JourneyMap, { type JourneyMapHandle } from './JourneyMap'
import ErrorBoundary from '../shared/ErrorBoundary'
import type { JourneyMapGLHandle } from './JourneyMapGL'
// Lazy-load the GL renderer (and its ~230 KB gzip engine) so Leaflet-only
@@ -53,10 +54,15 @@ const JourneyMapAuto = forwardRef<JourneyMapAutoHandle, Props>(function JourneyM
if (useGL) {
return (
<Suspense fallback={null}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<JourneyMapGL ref={glRef} {...(props as any)} glProvider={glProvider} />
</Suspense>
// 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
<ErrorBoundary boundaryId="journey-map:gl" fallback={<JourneyMap ref={leafletRef} {...(props as any)} />}>
<Suspense fallback={null}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<JourneyMapGL ref={glRef} {...(props as any)} glProvider={glProvider} />
</Suspense>
</ErrorBoundary>
)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
+9 -3
View File
@@ -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 (
<Suspense fallback={<MapView {...props} />}>
<MapViewGL {...props} glProvider={glProvider} />
</Suspense>
// 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.
<ErrorBoundary boundaryId="map:gl" fallback={<MapView {...props} />}>
<Suspense fallback={<MapView {...props} />}>
<MapViewGL {...props} glProvider={glProvider} />
</Suspense>
</ErrorBoundary>
)
}
return <MapView {...props} />
+31 -27
View File
@@ -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 (
<>
<iframe
key={pluginId}
ref={frameRef}
src={`/plugin-frame/${pluginId}/${path}`}
// Deliver the context as soon as the document is parsed (the plugin sets up its
// message listener during parse), closing the trek:ready race so the theme is
// right on first paint. A 2nd load is a self-navigation — don't bridge to it.
onLoad={() => { loadsRef.current += 1; if (loadsRef.current === 1) postFrame(buildContext()) }}
sandbox="allow-scripts allow-forms"
referrerPolicy="no-referrer"
loading="lazy"
title={title || pluginId}
className={className}
style={{ width: '100%', height: fill ? '100%' : height ?? '100%', border: 0 }}
/>
<ConfirmDialog
isOpen={confirmReq != null}
onClose={() => answerConfirm(false)}
onConfirm={() => answerConfirm(true)}
title={confirmTitle}
message={confirmReq?.message}
confirmLabel={confirmReq?.confirmLabel}
cancelLabel={confirmReq?.cancelLabel}
danger={confirmReq?.danger}
/>
</>
// Third-party code: a throw in here must cost the plugin, not the page.
<ErrorBoundary boundaryId="plugin-frame" label={pluginLabel} resetKeys={[pluginId]}>
<>
<iframe
key={pluginId}
ref={frameRef}
src={`/plugin-frame/${pluginId}/${path}`}
// Deliver the context as soon as the document is parsed (the plugin sets up its
// message listener during parse), closing the trek:ready race so the theme is
// right on first paint. A 2nd load is a self-navigation — don't bridge to it.
onLoad={() => { loadsRef.current += 1; if (loadsRef.current === 1) postFrame(buildContext()) }}
sandbox="allow-scripts allow-forms"
referrerPolicy="no-referrer"
loading="lazy"
title={title || pluginId}
className={className}
style={{ width: '100%', height: fill ? '100%' : height ?? '100%', border: 0 }}
/>
<ConfirmDialog
isOpen={confirmReq != null}
onClose={() => answerConfirm(false)}
onConfirm={() => answerConfirm(true)}
title={confirmTitle}
message={confirmReq?.message}
confirmLabel={confirmReq?.confirmLabel}
cancelLabel={confirmReq?.cancelLabel}
danger={confirmReq?.danger}
/>
</>
</ErrorBoundary>
)
}
@@ -0,0 +1,216 @@
import React from 'react';
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import ErrorBoundary, { RootErrorFallback } from './ErrorBoundary';
// React logs every caught error itself; without this each of these tests prints a
// full component stack and buries the real output.
let consoleError: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
sessionStorage.clear();
});
afterEach(() => {
consoleError.mockRestore();
});
function Boom({ message = 'kaboom' }: { message?: string }): React.ReactElement {
throw new Error(message);
}
const CHUNK_MESSAGE = 'Failed to fetch dynamically imported module: /assets/DashboardPage-abc123.js';
describe('ErrorBoundary', () => {
it('FE-COMP-ERRBOUND-001: renders its children while nothing throws', () => {
render(
<ErrorBoundary boundaryId="test">
<p>all good</p>
</ErrorBoundary>,
);
expect(screen.getByText('all good')).toBeInTheDocument();
expect(screen.queryByRole('alert')).toBeNull();
});
it('FE-COMP-ERRBOUND-002: swaps in the fallback when a child throws', () => {
render(
<ErrorBoundary boundaryId="test">
<Boom />
</ErrorBoundary>,
);
expect(screen.getByRole('alert')).toBeInTheDocument();
// The message is surfaced on purpose — self-hosted users file their own issues.
expect(screen.getByText('kaboom')).toBeInTheDocument();
});
it('FE-COMP-ERRBOUND-003: logs the boundaryId so a report says which one tripped', () => {
render(
<ErrorBoundary boundaryId="planner-tabs">
<Boom />
</ErrorBoundary>,
);
expect(consoleError).toHaveBeenCalledWith(
'[ErrorBoundary:planner-tabs]',
expect.any(Error),
expect.anything(),
);
});
it('FE-COMP-ERRBOUND-004: a custom node fallback replaces the default panel', () => {
render(
<ErrorBoundary boundaryId="test" fallback={<p>quiet fallback</p>}>
<Boom />
</ErrorBoundary>,
);
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(
<ErrorBoundary boundaryId="widget:test" fallback={null}>
<Boom />
</ErrorBoundary>,
);
expect(container).toBeEmptyDOMElement();
});
it('FE-COMP-ERRBOUND-006: a function fallback receives the error and the reset handle', () => {
render(
<ErrorBoundary
boundaryId="test"
fallback={s => <p>{s.error instanceof Error ? s.error.message : 'none'} / {String(s.isChunkError)}</p>}
>
<Boom message="detail" />
</ErrorBoundary>,
);
expect(screen.getByText('detail / false')).toBeInTheDocument();
});
it('FE-COMP-ERRBOUND-007: retry clears the error and re-renders the children', () => {
function Flaky({ shouldThrow }: { shouldThrow: boolean }) {
if (shouldThrow) throw new Error('transient');
return <p>recovered</p>;
}
function Harness() {
const [shouldThrow, setShouldThrow] = React.useState(true);
return (
<ErrorBoundary boundaryId="test" onReset={() => setShouldThrow(false)}>
<Flaky shouldThrow={shouldThrow} />
</ErrorBoundary>
);
}
render(<Harness />);
fireEvent.click(screen.getByText('Try again'));
expect(screen.getByText('recovered')).toBeInTheDocument();
});
it('FE-COMP-ERRBOUND-008: a changed resetKey clears the error on its own', () => {
function Harness({ id, throws }: { id: number; throws: boolean }) {
return (
<ErrorBoundary boundaryId="test" resetKeys={[id]}>
{throws ? <Boom /> : <p>panel {id}</p>}
</ErrorBoundary>
);
}
const { rerender } = render(<Harness id={1} throws />);
expect(screen.getByRole('alert')).toBeInTheDocument();
// Switching to another trip/tab must not carry the previous failure over.
rerender(<Harness id={2} throws={false} />);
expect(screen.getByText('panel 2')).toBeInTheDocument();
});
it('FE-COMP-ERRBOUND-009: an unchanged resetKey leaves the error standing', () => {
function Harness({ id }: { id: number }) {
return (
<ErrorBoundary boundaryId="test" resetKeys={[id]}>
<Boom />
</ErrorBoundary>
);
}
const { rerender } = render(<Harness id={1} />);
rerender(<Harness id={1} />);
expect(screen.getByRole('alert')).toBeInTheDocument();
});
it('FE-COMP-ERRBOUND-010: a dead chunk offers a reload instead of a retry', () => {
render(
<ErrorBoundary boundaryId="route" level="route">
<Boom message={CHUNK_MESSAGE} />
</ErrorBoundary>,
);
// 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(
<ErrorBoundary boundaryId="a">
<Boom message={CHUNK_MESSAGE} />
</ErrorBoundary>,
);
expect(reload).toHaveBeenCalledTimes(1);
// Second failure in the same session: the marker is set, so no reload loop.
render(
<ErrorBoundary boundaryId="b">
<Boom message={CHUNK_MESSAGE} />
</ErrorBoundary>,
);
expect(reload).toHaveBeenCalledTimes(1);
});
it('FE-COMP-ERRBOUND-012: a labelled boundary names what failed', () => {
render(
<ErrorBoundary boundaryId="plugin-frame" label="Koffi & Friends">
<Boom />
</ErrorBoundary>,
);
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(
<ErrorBoundary boundaryId="panel" level="panel">
<Boom />
</ErrorBoundary>,
);
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(
<ErrorBoundary boundaryId="route" level="route" variant="mobile">
<Boom />
</ErrorBoundary>,
);
// --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(<RootErrorFallback error={new Error('boot failed')} reset={() => {}} 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(<RootErrorFallback error={new Error(CHUNK_MESSAGE)} reset={() => {}} isChunkError />);
expect(screen.getByText('A new version is available')).toBeInTheDocument();
});
});
@@ -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<ErrorBoundaryProps, ErrorBoundaryState> {
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 <ErrorFallback {...state} level={level} variant={variant} label={label} />
}
}
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 (
<div
role="alert"
className={[
'flex flex-col items-center justify-center gap-3 rounded-xl border p-6 text-center',
isPanel ? 'h-full min-h-[180px]' : 'm-6 min-h-[240px]',
mobile ? 'border-[color:var(--m-rowbr)] bg-m-card text-m-ink' : 'border-edge bg-surface-card',
].join(' ')}
>
<AlertTriangle size={22} className={mobile ? 'text-m-muted' : 'text-content-muted'} aria-hidden />
<div className="space-y-1">
<p className={`text-subtitle font-semibold ${mobile ? 'text-m-ink' : 'text-content'}`}>{title}</p>
{label && <p className={`text-body ${mobile ? 'text-m-muted' : 'text-content-secondary'}`}>{label}</p>}
<p className={`text-body ${mobile ? 'text-m-muted' : 'text-content-secondary'}`}>{body}</p>
</div>
{/* Worth showing: TREK is self-hosted, so the person seeing this is often the
same person who will paste it into an issue. */}
{message && (
<code className={`max-w-full truncate text-caption ${mobile ? 'text-m-faint' : 'text-content-faint'}`}>
{message}
</code>
)}
<div className="flex flex-wrap items-center justify-center gap-2 pt-1">
{/* No retry for a dead chunk — React.lazy would throw again immediately. */}
{!isChunkError && (
<button
type="button"
onClick={reset}
className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-2 text-caption font-medium text-white"
>
<RotateCcw size={14} aria-hidden />
{t('common.errorRetry')}
</button>
)}
{(!isPanel || isChunkError) && (
<button
type="button"
onClick={() => window.location.reload()}
className={[
'inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-caption font-medium',
mobile ? 'border-[color:var(--m-rowbr)] text-m-ink' : 'border-edge text-content',
].join(' ')}
>
<RefreshCw size={14} aria-hidden />
{t('common.errorReload')}
</button>
)}
</div>
</div>
)
}
/**
* 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 (
<div
role="alert"
className="flex min-h-dvh flex-col items-center justify-center gap-3 p-6 text-center"
style={{ fontFamily: 'var(--font-system)' }}
>
<AlertTriangle size={24} className="text-content-muted" aria-hidden />
<p className="text-subtitle font-semibold text-content">
{isChunkError ? 'A new version is available' : 'TREK could not start'}
</p>
<p className="text-body text-content-secondary">
{isChunkError
? 'TREK was updated while this tab was open. Reload to get the new version.'
: 'Reloading usually fixes this. Your data is safe.'}
</p>
{message && <code className="max-w-full truncate text-caption text-content-faint">{message}</code>}
<button
type="button"
onClick={() => window.location.reload()}
className="mt-1 inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-2 text-caption font-medium text-white"
>
<RefreshCw size={14} aria-hidden />
Reload page
</button>
</div>
)
}
+4
View File
@@ -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])
+14 -1
View File
@@ -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(
<React.StrictMode>
<BrowserRouter>
<App />
{/* 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. */}
<ErrorBoundary boundaryId="root" level="root" fallback={s => <RootErrorFallback {...s} />}>
<App />
</ErrorBoundary>
</BrowserRouter>
</React.StrictMode>,
)
// The app rendered, so a later deploy is allowed to trigger its own reload.
clearChunkReloadMarker()
+4 -3
View File
@@ -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 (
<div className="flex flex-col h-dvh md:block md:h-auto">
<div className="flex-1 overflow-y-auto md:overflow-visible">{children}</div>
<BottomNav />
<ErrorBoundary boundaryId="chrome:bottom-nav" fallback={null}><BottomNav /></ErrorBoundary>
</div>
)
}
@@ -40,8 +41,8 @@ export default function MobileShell({ isPhone, children }: MobileShellProps) {
return (
<div className="m-root flex h-dvh min-h-dvh flex-col bg-[color:var(--m-bg)] bg-[image:var(--m-scr)] text-m-ink">
<div className="min-h-0 flex-1 overflow-y-auto">{children}</div>
{!inTripPlanner && <MBottomNav />}
<MToastHost />
{!inTripPlanner && <ErrorBoundary boundaryId="chrome:m-bottom-nav" fallback={null}><MBottomNav /></ErrorBoundary>}
<ErrorBoundary boundaryId="chrome:m-toast" fallback={null}><MToastHost /></ErrorBoundary>
<div id="m-sheet-root" />
</div>
)
+73
View File
@@ -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();
});
});
+56
View File
@@ -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.
}
}
@@ -0,0 +1,78 @@
import { AxiosError } from 'axios';
import { installGlobalErrorHandlers } from './globalErrorHandlers';
let teardown: () => void;
let consoleError: ReturnType<typeof vi.spyOn>;
let reload: ReturnType<typeof vi.fn>;
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 = () => {};
});
});
+58
View File
@@ -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 <img>
// and <link>, which on a map-heavy app means hundreds of tile requests.
window.addEventListener('error', onError)
return () => {
window.removeEventListener('unhandledrejection', onUnhandledRejection)
window.removeEventListener('error', onError)
}
}
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;
+9
View File
@@ -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;