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 ( +
+
+ ) +} 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();