diff --git a/client/src/App.tsx b/client/src/App.tsx
index a8963c1cd..1290444e9 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -29,6 +29,7 @@ import { useInAppNotificationListener } from './hooks/useInAppNotificationListen
import { useRoadtripPreferencesSync } from './hooks/useRoadtripPreferencesSync'
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
import OfflineBanner from './components/Layout/OfflineBanner'
+import NewVersionNotice from './components/Layout/NewVersionNotice'
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
import HelpPanel from './components/Help/HelpPanel'
// Notice action registrations (side-effect imports):
@@ -400,6 +401,7 @@ export default function App() {
{!hideAuthedWidgets && }
{!hideAuthedWidgets && (isPhone ? : )}
+
{/* One boundary for all route chunks, above so it stays mounted
across navigations. react-router runs location updates inside a transition,
so a mounted boundary keeps the current page on screen instead of flashing
diff --git a/client/src/api/websocket.ts b/client/src/api/websocket.ts
index 511fc5bca..99b9d8fb9 100644
--- a/client/src/api/websocket.ts
+++ b/client/src/api/websocket.ts
@@ -1,5 +1,7 @@
// Singleton WebSocket manager for real-time collaboration
+import { useServerVersionStore } from '../store/serverVersionStore'
+
type WebSocketListener = (event: Record) => void
type RefetchCallback = (tripId: string) => void
@@ -81,6 +83,7 @@ function handleMessage(event: MessageEvent): void {
const parsed = JSON.parse(event.data)
if (parsed.type === 'welcome') {
mySocketId = parsed.socketId
+ useServerVersionStore.getState().note(parsed.version)
return
}
listeners.forEach(fn => {
diff --git a/client/src/components/Layout/NewVersionNotice.test.tsx b/client/src/components/Layout/NewVersionNotice.test.tsx
new file mode 100644
index 000000000..a5dfafebd
--- /dev/null
+++ b/client/src/components/Layout/NewVersionNotice.test.tsx
@@ -0,0 +1,75 @@
+import { act } from 'react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { render } from '../../../tests/helpers/render'
+import NewVersionNotice from './NewVersionNotice'
+import { useServerVersionStore } from '../../store/serverVersionStore'
+import { _resetNetworkMode, setForcedOffline } from '../../sync/networkMode'
+
+const switchToServerVersion = vi.fn(async (_version: string) => {})
+vi.mock('../../utils/versionHandover', async (importOriginal) => ({
+ ...(await importOriginal()),
+ switchToServerVersion: (version: string) => switchToServerVersion(version),
+}))
+
+// The version the bundle under test is built as (vite define, from package.json).
+const BUILT = __TREK_UI_VERSION__
+const NEXT = `${BUILT}-next`
+
+beforeEach(() => {
+ useServerVersionStore.setState({ reported: null })
+})
+
+afterEach(() => {
+ switchToServerVersion.mockClear()
+ sessionStorage.removeItem('trek_app_version_reload')
+ _resetNetworkMode()
+})
+
+describe('NewVersionNotice', () => {
+ it('FE-LAYOUT-NEWVER-001: says nothing before the server reported, or while it runs this very build', () => {
+ const { container } = render()
+ expect(container.querySelector('[role="status"]')).toBeNull()
+
+ act(() => useServerVersionStore.getState().note(BUILT))
+ expect(container.querySelector('[role="status"]')).toBeNull()
+ })
+
+ it('FE-LAYOUT-NEWVER-002: offers the reload once the server reports a newer build', async () => {
+ render()
+ act(() => useServerVersionStore.getState().note(NEXT))
+
+ expect(screen.getByRole('status')).toHaveTextContent('A new version is available')
+ await userEvent.click(screen.getByRole('button', { name: 'Reload page' }))
+
+ expect(switchToServerVersion).toHaveBeenCalledWith(NEXT)
+ expect(screen.getByRole('button', { name: 'Reload page' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: 'Close' })).toBeDisabled()
+ })
+
+ it('FE-LAYOUT-NEWVER-003: closing it hides that version, and the next release asks again', async () => {
+ render()
+ act(() => useServerVersionStore.getState().note(NEXT))
+ await userEvent.click(screen.getByRole('button', { name: 'Close' }))
+ expect(screen.queryByRole('status')).toBeNull()
+
+ act(() => useServerVersionStore.getState().note(`${BUILT}-later`))
+ expect(screen.getByRole('status')).toBeInTheDocument()
+ expect(switchToServerVersion).not.toHaveBeenCalled()
+ })
+
+ it('FE-LAYOUT-NEWVER-004: stays away offline, where a reload could not fetch the new build', () => {
+ setForcedOffline(true)
+ render()
+ act(() => useServerVersionStore.getState().note(NEXT))
+ expect(screen.queryByRole('status')).toBeNull()
+ })
+
+ it('FE-LAYOUT-NEWVER-005: stays away once this session reloaded for that version without getting it', () => {
+ sessionStorage.setItem('trek_app_version_reload', NEXT)
+ render()
+ act(() => useServerVersionStore.getState().note(NEXT))
+ expect(screen.queryByRole('status')).toBeNull()
+ })
+})
diff --git a/client/src/components/Layout/NewVersionNotice.tsx b/client/src/components/Layout/NewVersionNotice.tsx
new file mode 100644
index 000000000..3ef34d207
--- /dev/null
+++ b/client/src/components/Layout/NewVersionNotice.tsx
@@ -0,0 +1,65 @@
+/**
+ * NewVersionNotice: a release was deployed while this page stayed open.
+ *
+ * The server names its version on every socket (re)connect, and a deploy
+ * restarts it, so every open client hears of a new release within seconds. A
+ * launch brings an outdated page onto the current build by itself
+ * (versionHandover); a page in use is never reloaded under the user's hands, so
+ * this offers the reload instead, and the reload goes through the same handover.
+ *
+ * One notice for both shells, rendered once in App. Hidden offline, where a
+ * reload could not fetch the new build, and once dismissed for that version.
+ * It reads the error boundary's texts for the same situation, a tab that
+ * outlived a deploy.
+ */
+import React, { useState } from 'react'
+import { RefreshCw, X } from 'lucide-react'
+import { useServerVersionStore } from '../../store/serverVersionStore'
+import { useNetworkMode } from '../../hooks/useNetworkMode'
+import { offersNewBuild, switchToServerVersion } from '../../utils/versionHandover'
+import { useTranslation } from '../../i18n'
+
+export default function NewVersionNotice(): React.ReactElement | null {
+ const { t } = useTranslation()
+ const reported = useServerVersionStore(s => s.reported)
+ const { offline } = useNetworkMode()
+ const [dismissed, setDismissed] = useState(null)
+ const [switching, setSwitching] = useState(false)
+
+ if (offline || !reported || reported === dismissed || !offersNewBuild(reported)) return null
+
+ const reload = (): void => {
+ setSwitching(true)
+ void switchToServerVersion(reported)
+ }
+
+ return (
+
+
+ {t('common.errorUpdateTitle')}
+
+
+
+ )
+}
diff --git a/client/src/store/serverVersionStore.ts b/client/src/store/serverVersionStore.ts
new file mode 100644
index 000000000..c0349465b
--- /dev/null
+++ b/client/src/store/serverVersionStore.ts
@@ -0,0 +1,22 @@
+import { create } from 'zustand'
+import { asReleaseTag } from '../utils/versionHandover'
+
+/**
+ * The version the server says it runs, as its realtime socket reports it on
+ * every (re)connect. A deploy restarts the server, so a client that stayed open
+ * across one reconnects and learns the new version here (NewVersionNotice).
+ */
+interface ServerVersionState {
+ /** The last version the server reported, or null before its first report. */
+ reported: string | null
+ /** Takes what the welcome frame carries; anything but a release tag is ignored. */
+ note: (value: unknown) => void
+}
+
+export const useServerVersionStore = create()((set, get) => ({
+ reported: null,
+ note(value) {
+ const version = asReleaseTag(value)
+ if (version && version !== get().reported) set({ reported: version })
+ },
+}))
diff --git a/client/src/utils/versionHandover.test.ts b/client/src/utils/versionHandover.test.ts
index a48c1639e..bee27ad3c 100644
--- a/client/src/utils/versionHandover.test.ts
+++ b/client/src/utils/versionHandover.test.ts
@@ -484,3 +484,124 @@ describe('reconcileAppVersion: the reload guard', () => {
expect(reload).not.toHaveBeenCalled()
})
})
+
+describe('offersNewBuild: whether the notice offers a reload', () => {
+ it('FE-UTIL-VERSION-040: offers a version other than the one this page was built as', async () => {
+ const { offersNewBuild } = await loadModule()
+ expect(offersNewBuild(`${BUILT}-next`)).toBe(true)
+ expect(offersNewBuild(BUILT)).toBe(false)
+ expect(offersNewBuild(null)).toBe(false)
+ })
+
+ it('FE-UTIL-VERSION-041: not once this session reloaded for that version, since another reload shows the same build', async () => {
+ const { offersNewBuild } = await loadModule()
+ sessionStorage.setItem('trek_app_version_reload', `${BUILT}-next`)
+ expect(offersNewBuild(`${BUILT}-next`)).toBe(false)
+ expect(offersNewBuild(`${BUILT}-later`)).toBe(true)
+ sessionStorage.removeItem('trek_app_version_reload')
+ })
+
+ it('FE-UTIL-VERSION-042: not without session storage, where the reload guard and with it the reload is missing', async () => {
+ const { offersNewBuild } = await loadModule()
+ breakStorage('getItem', () => sessionStorage)
+ expect(offersNewBuild(`${BUILT}-next`)).toBe(false)
+ })
+
+ it('FE-UTIL-VERSION-043: asReleaseTag takes a release tag and nothing else', async () => {
+ const { asReleaseTag } = await loadModule()
+ expect(asReleaseTag('4.3.4-rc.1')).toBe('4.3.4-rc.1')
+ expect(asReleaseTag(' 4.3.4')).toBeNull()
+ expect(asReleaseTag(434)).toBeNull()
+ expect(asReleaseTag('4'.repeat(65))).toBeNull()
+ })
+})
+
+describe('switchToServerVersion: the reload the user asked for', () => {
+ afterEach(() => {
+ sessionStorage.removeItem('trek_app_version_reload')
+ vi.useRealTimers()
+ })
+
+ it('FE-UTIL-VERSION-050: without a worker it reloads at once, under a fresh URL', async () => {
+ const { switchToServerVersion } = await loadModule()
+ await switchToServerVersion('4.3.4')
+ expect(reload).toHaveBeenCalledTimes(1)
+ expect(plainReload).not.toHaveBeenCalled()
+ })
+
+ it('FE-UTIL-VERSION-051: waits for the new worker to take over, records the version, then reloads', async () => {
+ const { container, registration } = installWorker()
+ updateInstalls(registration)
+ const { switchToServerVersion } = await loadModule()
+ localStorage.setItem('trek_app_version', '4.3.3')
+
+ const switching = switchToServerVersion('4.3.4')
+ await vi.waitFor(() => expect(registration.update).toHaveBeenCalled())
+ expect(reload).not.toHaveBeenCalled()
+
+ container.takeOver()
+ await switching
+ expect(marker()).toBe('4.3.4')
+ expect(reload).toHaveBeenCalledTimes(1)
+ })
+
+ it('FE-UTIL-VERSION-052: an unchanged sw.js has nothing to wait for, so it reloads and leaves the marker', async () => {
+ const { registration } = installWorker()
+ const { switchToServerVersion } = await loadModule()
+ localStorage.setItem('trek_app_version', '4.3.3')
+
+ await switchToServerVersion('4.3.4')
+ expect(registration.update).toHaveBeenCalled()
+ expect(marker()).toBe('4.3.3')
+ expect(reload).toHaveBeenCalledTimes(1)
+ })
+
+ it('FE-UTIL-VERSION-053: an install that goes redundant still ends in the reload', async () => {
+ const { registration } = installWorker()
+ const incoming = updateInstalls(registration)
+ const { switchToServerVersion } = await loadModule()
+
+ const switching = switchToServerVersion('4.3.4')
+ await vi.waitFor(() => expect(registration.update).toHaveBeenCalled())
+ incoming.become('redundant')
+ await switching
+ expect(reload).toHaveBeenCalledTimes(1)
+ })
+
+ it('FE-UTIL-VERSION-054: a failed update or a worker that never takes over does not leave the user waiting', async () => {
+ const failing = installWorker()
+ failing.registration.update.mockRejectedValue(new Error('offline'))
+ let { switchToServerVersion } = await loadModule()
+ await switchToServerVersion('4.3.4')
+ expect(reload).toHaveBeenCalledTimes(1)
+
+ sessionStorage.removeItem('trek_app_version_reload')
+ const stuck = installWorker()
+ updateInstalls(stuck.registration)
+ ;({ switchToServerVersion } = await loadModule())
+ vi.useFakeTimers()
+ const switching = switchToServerVersion('4.3.5')
+ await vi.advanceTimersByTimeAsync(20_000)
+ await switching
+ expect(reload).toHaveBeenCalledTimes(2)
+ })
+
+ it('FE-UTIL-VERSION-055: a worker the browser swapped in already answers the reload, so nothing is asked of it', async () => {
+ const { container, registration } = installWorker()
+ const { switchToServerVersion } = await loadModule()
+ container.controller = new FakeWorker('activated')
+
+ await switchToServerVersion('4.3.4')
+ expect(registration.update).not.toHaveBeenCalled()
+ expect(reload).toHaveBeenCalledTimes(1)
+ })
+
+ it('FE-UTIL-VERSION-056: a registration lookup that throws still reloads', async () => {
+ const { container } = installWorker()
+ container.getRegistration.mockRejectedValue(new Error('SecurityError'))
+ const { switchToServerVersion } = await loadModule()
+
+ await switchToServerVersion('4.3.4')
+ expect(reload).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/client/src/utils/versionHandover.ts b/client/src/utils/versionHandover.ts
index 94b2867ba..42bbbc7e7 100644
--- a/client/src/utils/versionHandover.ts
+++ b/client/src/utils/versionHandover.ts
@@ -94,6 +94,82 @@ async function handOver(reg: ServiceWorkerRegistration, version: string, runsIt:
})
}
+/**
+ * A version is a short release tag and nothing else. It arrives over the wire
+ * and is written to this device's storage, so only a value made of the
+ * characters a tag may contain is taken, as the match itself. Anything else is
+ * null, which also keeps a malformed value from being compared against the
+ * stored marker and starting an update on every launch.
+ */
+export function asReleaseTag(value: unknown): string | null {
+ return /^[\w.+-]{1,64}$/.exec(typeof value === 'string' ? value : '')?.[0] ?? null
+}
+
+/**
+ * True when the server runs `version`, this page runs another build, and a
+ * reload can still bring the page onto it: once this session has reloaded for
+ * that version, another reload would only show the same build again (a proxy
+ * or the server reporting a version its bundle was not built as).
+ */
+export function offersNewBuild(version: string | null): boolean {
+ if (!version || !BUNDLE_VERSION || version === BUNDLE_VERSION) return false
+ try {
+ return sessionStorage.getItem(RELOAD_KEY) !== version
+ } catch {
+ // No session storage, no reload guard, and reloadOnce does not reload.
+ return false
+ }
+}
+
+/** How long a reload the user asked for waits for the new worker to take over. */
+const SWITCH_WAIT_MS = 20_000
+
+/**
+ * The user asked for the build the server runs now, from the notice a release
+ * deployed during the session raises. The same handover as a launch, but the
+ * reload is theirs to ask for, so it always comes: once the new worker has
+ * taken over, or when there is none to wait for, or after SWITCH_WAIT_MS. The
+ * marker only moves on a takeover; anything else is left to the next launch,
+ * as there.
+ */
+export async function switchToServerVersion(version: string): Promise {
+ let reg: ServiceWorkerRegistration | undefined
+ try {
+ reg = 'serviceWorker' in navigator ? await navigator.serviceWorker.getRegistration() : undefined
+ } catch {
+ reg = undefined
+ }
+ // Without a worker the reload goes to the network. A worker other than the
+ // one that served this page has taken over already, and answers it with the
+ // new build.
+ if (reg && currentController() === servedBy) await waitForTakeover(reg, version)
+ reloadOnce(version)
+}
+
+function waitForTakeover(reg: ServiceWorkerRegistration, version: string): Promise {
+ return new Promise(resolve => {
+ const done = (): void => {
+ clearTimeout(timer)
+ resolve()
+ }
+ const timer = setTimeout(done, SWITCH_WAIT_MS)
+ navigator.serviceWorker.addEventListener('controllerchange', () => {
+ markApplied(version)
+ done()
+ }, { once: true })
+ reg.update().then(() => {
+ const incoming = reg.installing ?? reg.waiting
+ if (!incoming) {
+ done()
+ return
+ }
+ incoming.addEventListener('statechange', () => {
+ if (incoming.state === 'redundant') done()
+ })
+ }, done)
+ })
+}
+
/**
* Compares the version the server reports with the one this device last ran
* and, when they differ, moves the app onto the new build. It never deletes a
@@ -103,14 +179,8 @@ async function handOver(reg: ServiceWorkerRegistration, version: string, runsIt:
* away the map tiles and files the user had downloaded on purpose.
*/
export async function reconcileAppVersion(reported: unknown): Promise {
- // A version is a short release tag and nothing else. It arrives over the
- // wire and is written to this device's storage, so only a value made of
- // the characters a tag may contain is taken, as the match itself. Anything
- // else is ignored, which also keeps a malformed value from being compared
- // against the stored marker and starting an update on every launch.
- const releaseTag = /^[\w.+-]{1,64}$/.exec(typeof reported === 'string' ? reported : '')
- if (!releaseTag) return
- const version = releaseTag[0]
+ const version = asReleaseTag(reported)
+ if (!version) return
let storedVersion: string | null
try {
diff --git a/client/tests/integration/api/websocket.test.ts b/client/tests/integration/api/websocket.test.ts
index 566a6e95c..aae4a0b5c 100644
--- a/client/tests/integration/api/websocket.test.ts
+++ b/client/tests/integration/api/websocket.test.ts
@@ -14,6 +14,7 @@ import {
getSocketId,
setRefetchCallback,
} from '../../../src/api/websocket';
+import { useServerVersionStore } from '../../../src/store/serverVersionStore';
// ── Fake WebSocket ────────────────────────────────────────────────────────────
@@ -290,6 +291,20 @@ describe('handleMessage / listeners', () => {
removeListener(listener);
});
+ it('FE-COMP-WS-012b: the welcome frame tells the store which version the server runs', async () => {
+ useServerVersionStore.setState({ reported: null });
+ const sock = await setupConnectedSocket();
+
+ sock.onmessage!({ data: JSON.stringify({ type: 'welcome', socketId: 'server-sid-2', version: '4.3.4' }) });
+ expect(useServerVersionStore.getState().reported).toBe('4.3.4');
+
+ // A server from before the version was sent, or a malformed value, changes nothing.
+ sock.onmessage!({ data: JSON.stringify({ type: 'welcome', socketId: 'server-sid-3' }) });
+ sock.onmessage!({ data: JSON.stringify({ type: 'welcome', socketId: 'server-sid-4', version: '5' }) });
+ expect(useServerVersionStore.getState().reported).toBe('4.3.4');
+ expect(getSocketId()).toBe('server-sid-4');
+ });
+
it('FE-COMP-WS-013: non-welcome messages are dispatched to all registered listeners', async () => {
const sock = await setupConnectedSocket();
const l1 = vi.fn();
diff --git a/client/tests/unit/stores/serverVersionStore.test.ts b/client/tests/unit/stores/serverVersionStore.test.ts
new file mode 100644
index 000000000..3bda70071
--- /dev/null
+++ b/client/tests/unit/stores/serverVersionStore.test.ts
@@ -0,0 +1,25 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import { useServerVersionStore } from '../../../src/store/serverVersionStore'
+
+beforeEach(() => {
+ useServerVersionStore.setState({ reported: null })
+})
+
+describe('serverVersionStore', () => {
+ it('FE-STORE-SRVVER-001: keeps the last release tag the server reported', () => {
+ useServerVersionStore.getState().note('4.3.4')
+ expect(useServerVersionStore.getState().reported).toBe('4.3.4')
+ useServerVersionStore.getState().note('4.4.0-rc.1')
+ expect(useServerVersionStore.getState().reported).toBe('4.4.0-rc.1')
+ })
+
+ it('FE-STORE-SRVVER-002: ignores what is not a release tag, and a repeat of the same version', () => {
+ useServerVersionStore.getState().note('4.3.4')
+ const before = useServerVersionStore.getState()
+ for (const value of [undefined, null, 434, '', '5', '4'.repeat(65)]) {
+ useServerVersionStore.getState().note(value)
+ }
+ useServerVersionStore.getState().note('4.3.4')
+ expect(useServerVersionStore.getState()).toBe(before)
+ })
+})
diff --git a/server/src/nest/realtime/realtime.gateway.ts b/server/src/nest/realtime/realtime.gateway.ts
index 14260e328..f2047ae64 100644
--- a/server/src/nest/realtime/realtime.gateway.ts
+++ b/server/src/nest/realtime/realtime.gateway.ts
@@ -35,6 +35,7 @@ import {
type TrekWebSocket,
} from './ws-state';
import { JourneyDomainService } from '../journey/journey-domain.service';
+import { hostVersion } from '../plugins/install/host-compat';
const HEARTBEAT_INTERVAL = 30_000;
@@ -153,7 +154,10 @@ export class RealtimeGateway
socket.isAlive = true;
const sid = registerSocket(socket, user as User);
- socket.send(JSON.stringify({ type: 'welcome', socketId: sid }));
+ // The version lets a client that stayed open across a deploy notice it
+ // runs the previous build: a deploy restarts the server, so every open
+ // client reconnects and reads this again.
+ socket.send(JSON.stringify({ type: 'welcome', socketId: sid, version: hostVersion() }));
socket.on('pong', () => { socket.isAlive = true; });
} catch (err) {
logError(`ws handshake failed: ${err instanceof Error ? err.message : String(err)}`);
diff --git a/server/tests/unit/nest/realtime.gateway.test.ts b/server/tests/unit/nest/realtime.gateway.test.ts
index 3783d2d8e..6f4b76dc0 100644
--- a/server/tests/unit/nest/realtime.gateway.test.ts
+++ b/server/tests/unit/nest/realtime.gateway.test.ts
@@ -20,6 +20,7 @@ vi.mock('../../../src/nest/audit/audit-log.logger', () => ({
}));
import { RealtimeGateway } from '../../../src/nest/realtime/realtime.gateway';
+import { hostVersion } from '../../../src/nest/plugins/install/host-compat';
import { logError } from '../../../src/nest/audit/audit-log.logger';
import {
bookPeers,
@@ -147,6 +148,15 @@ describe('RealtimeGateway handshake', () => {
expect(Number.isInteger(welcome.socketId)).toBe(true);
});
+ it('WSGW-007d: the welcome frame names the version the server runs', async () => {
+ // A client open across a deploy reconnects and compares this with the
+ // build it runs, so it can offer the new one (NewVersionNotice).
+ const { ws } = await connect('/ws?token=x');
+ const welcome = JSON.parse(ws.sent[0]) as { type: string; version: unknown };
+ expect(welcome.version).toBe(hostVersion());
+ expect(welcome.version).toMatch(/^\d+\.\d+\.\d+/);
+ });
+
it('WSGW-007b: survives an upgrade request with no url, rather than throwing at it', async () => {
const gw = new RealtimeGateway(db, tokens, journeys, users, appSettings);
const ws = socket();