diff --git a/client/src/App.tsx b/client/src/App.tsx index 1290444e9..d1de86eba 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -18,6 +18,7 @@ import MRouteFallback from './mobile/components/MRouteFallback' import ErrorBoundary from './components/shared/ErrorBoundary' import { lazyWithRetry } from './utils/lazyWithRetry' import { reconcileAppVersion } from './utils/versionHandover' +import { useServerVersionStore } from './store/serverVersionStore' import { useIsPhone } from './mobile/useIsPhone' import { TranslationProvider, useTranslation } from './i18n' import { authApi, isAuthPublicPath } from './api/client' @@ -335,6 +336,8 @@ export default function App() { if (config?.place_shadow_enabled !== undefined) setPlaceShadowEnabled(config.place_shadow_enabled) if (typeof config?.max_upload_mb === 'number' && config.max_upload_mb > 0) useAuthStore.getState().setMaxUploadMb(config.max_upload_mb) if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions) + // The version this page starts out with, before the realtime socket reports. + useServerVersionStore.getState().note(config?.version) // Last, since a new release reloads the page from here. await reconcileAppVersion(config?.version) }).catch(() => {}) diff --git a/client/src/components/Layout/NewVersionNotice.test.tsx b/client/src/components/Layout/NewVersionNotice.test.tsx index a5dfafebd..5fad4ceed 100644 --- a/client/src/components/Layout/NewVersionNotice.test.tsx +++ b/client/src/components/Layout/NewVersionNotice.test.tsx @@ -18,9 +18,17 @@ const BUILT = __TREK_UI_VERSION__ const NEXT = `${BUILT}-next` beforeEach(() => { - useServerVersionStore.setState({ reported: null }) + useServerVersionStore.setState({ first: null, reported: null }) }) +/** The page starts on this build's version; then the server reports `version`. */ +function deploy(version: string) { + act(() => { + useServerVersionStore.getState().note(BUILT) + useServerVersionStore.getState().note(version) + }) +} + afterEach(() => { switchToServerVersion.mockClear() sessionStorage.removeItem('trek_app_version_reload') @@ -38,7 +46,7 @@ describe('NewVersionNotice', () => { it('FE-LAYOUT-NEWVER-002: offers the reload once the server reports a newer build', async () => { render() - act(() => useServerVersionStore.getState().note(NEXT)) + deploy(NEXT) expect(screen.getByRole('status')).toHaveTextContent('A new version is available') await userEvent.click(screen.getByRole('button', { name: 'Reload page' })) @@ -50,7 +58,7 @@ describe('NewVersionNotice', () => { it('FE-LAYOUT-NEWVER-003: closing it hides that version, and the next release asks again', async () => { render() - act(() => useServerVersionStore.getState().note(NEXT)) + deploy(NEXT) await userEvent.click(screen.getByRole('button', { name: 'Close' })) expect(screen.queryByRole('status')).toBeNull() @@ -62,13 +70,20 @@ describe('NewVersionNotice', () => { 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)) + deploy(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() + deploy(NEXT) + expect(screen.queryByRole('status')).toBeNull() + }) + + it('FE-LAYOUT-NEWVER-006: a different version from the start is the launch handover\'s, not the notice\'s', () => { + // Also an install whose server never reports the bundle's version: no notice every session. + 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 index 3ef34d207..374fedec5 100644 --- a/client/src/components/Layout/NewVersionNotice.tsx +++ b/client/src/components/Layout/NewVersionNotice.tsx @@ -5,7 +5,8 @@ * 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. + * this offers the reload instead, for a version that changed while the page was + * open, 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. @@ -22,11 +23,15 @@ import { useTranslation } from '../../i18n' export default function NewVersionNotice(): React.ReactElement | null { const { t } = useTranslation() const reported = useServerVersionStore(s => s.reported) + const first = useServerVersionStore(s => s.first) const { offline } = useNetworkMode() const [dismissed, setDismissed] = useState(null) const [switching, setSwitching] = useState(false) - if (offline || !reported || reported === dismissed || !offersNewBuild(reported)) return null + // Only a version that changed while this page was open: a different build at + // launch is the launch handover's to fix, and an install whose server never + // reports the bundle's version would otherwise be asked every session. + if (offline || !reported || reported === first || reported === dismissed || !offersNewBuild(reported)) return null const reload = (): void => { setSwitching(true) diff --git a/client/src/store/serverVersionStore.ts b/client/src/store/serverVersionStore.ts index c0349465b..3c3899c95 100644 --- a/client/src/store/serverVersionStore.ts +++ b/client/src/store/serverVersionStore.ts @@ -2,21 +2,26 @@ 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). + * The version the server says it runs: first from the app config at launch, + * then from its realtime socket 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 first version this page heard of. A different build at that point is the launch handover's to fix. */ + first: string | null /** 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. */ + /** Takes a reported version; anything but a release tag is ignored. */ note: (value: unknown) => void } export const useServerVersionStore = create()((set, get) => ({ + first: null, reported: null, note(value) { const version = asReleaseTag(value) - if (version && version !== get().reported) set({ reported: version }) + if (!version || version === get().reported) return + set({ reported: version, first: get().first ?? version }) }, })) diff --git a/client/src/utils/versionHandover.test.ts b/client/src/utils/versionHandover.test.ts index bee27ad3c..61e2c8bf6 100644 --- a/client/src/utils/versionHandover.test.ts +++ b/client/src/utils/versionHandover.test.ts @@ -517,16 +517,19 @@ describe('offersNewBuild: whether the notice offers a reload', () => { }) describe('switchToServerVersion: the reload the user asked for', () => { + const guard = () => sessionStorage.getItem('trek_app_version_reload') + 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 () => { + it('FE-UTIL-VERSION-050: without a worker it reloads at once, under a fresh URL, and spends the guard', async () => { const { switchToServerVersion } = await loadModule() await switchToServerVersion('4.3.4') expect(reload).toHaveBeenCalledTimes(1) expect(plainReload).not.toHaveBeenCalled() + expect(guard()).toBe('4.3.4') }) it('FE-UTIL-VERSION-051: waits for the new worker to take over, records the version, then reloads', async () => { @@ -543,6 +546,7 @@ describe('switchToServerVersion: the reload the user asked for', () => { await switching expect(marker()).toBe('4.3.4') expect(reload).toHaveBeenCalledTimes(1) + expect(guard()).toBe('4.3.4') }) it('FE-UTIL-VERSION-052: an unchanged sw.js has nothing to wait for, so it reloads and leaves the marker', async () => { @@ -554,9 +558,10 @@ describe('switchToServerVersion: the reload the user asked for', () => { expect(registration.update).toHaveBeenCalled() expect(marker()).toBe('4.3.3') expect(reload).toHaveBeenCalledTimes(1) + expect(guard()).toBe('4.3.4') }) - it('FE-UTIL-VERSION-053: an install that goes redundant still ends in the reload', async () => { + it('FE-UTIL-VERSION-053: an install that goes redundant still reloads, but leaves the guard to the next launch', async () => { const { registration } = installWorker() const incoming = updateInstalls(registration) const { switchToServerVersion } = await loadModule() @@ -566,6 +571,8 @@ describe('switchToServerVersion: the reload the user asked for', () => { incoming.become('redundant') await switching expect(reload).toHaveBeenCalledTimes(1) + // The launch on the other side retries the handover and reloads by itself. + expect(guard()).toBeNull() }) it('FE-UTIL-VERSION-054: a failed update or a worker that never takes over does not leave the user waiting', async () => { @@ -574,8 +581,8 @@ describe('switchToServerVersion: the reload the user asked for', () => { let { switchToServerVersion } = await loadModule() await switchToServerVersion('4.3.4') expect(reload).toHaveBeenCalledTimes(1) + expect(guard()).toBeNull() - sessionStorage.removeItem('trek_app_version_reload') const stuck = installWorker() updateInstalls(stuck.registration) ;({ switchToServerVersion } = await loadModule()) @@ -584,9 +591,10 @@ describe('switchToServerVersion: the reload the user asked for', () => { await vi.advanceTimersByTimeAsync(20_000) await switching expect(reload).toHaveBeenCalledTimes(2) + expect(guard()).toBeNull() }) - it('FE-UTIL-VERSION-055: a worker the browser swapped in already answers the reload, so nothing is asked of it', async () => { + it('FE-UTIL-VERSION-055: a worker that replaced the one that served the page already answers the reload', async () => { const { container, registration } = installWorker() const { switchToServerVersion } = await loadModule() container.controller = new FakeWorker('activated') @@ -596,6 +604,25 @@ describe('switchToServerVersion: the reload the user asked for', () => { expect(reload).toHaveBeenCalledTimes(1) }) + it('FE-UTIL-VERSION-057: a worker that only claimed a page from the network holds this build, so the handover still runs', async () => { + const { container, registration } = installWorker(null) + const { switchToServerVersion } = await loadModule() + // registerSW installed this build's worker after the page loaded, and it claimed the page. + const claimed = new FakeWorker('activated') + container.controller = claimed + registration.active = claimed + updateInstalls(registration) + + 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-056: a registration lookup that throws still reloads', async () => { const { container } = installWorker() container.getRegistration.mockRejectedValue(new Error('SecurityError')) diff --git a/client/src/utils/versionHandover.ts b/client/src/utils/versionHandover.ts index 42bbbc7e7..22e70eae0 100644 --- a/client/src/utils/versionHandover.ts +++ b/client/src/utils/versionHandover.ts @@ -128,9 +128,14 @@ 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. + * taken over, or when there is none to wait for, or after SWITCH_WAIT_MS. + * + * Only a reload that should land on the new build spends the session's reload + * guard: after a takeover, without a worker, or with an unchanged sw.js. One + * that comes before the new worker could take over (a slow precache, a failed + * update) leaves it free, so the launch on the other side finishes the + * handover and reloads by itself, as any launch does. The marker only moves on + * a takeover. */ export async function switchToServerVersion(version: string): Promise { let reg: ServiceWorkerRegistration | undefined @@ -139,34 +144,46 @@ export async function switchToServerVersion(version: string): Promise { } 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) + // Without a worker the reload goes to the network. A worker that replaced the + // one that served this page answers it with the new build already; one that + // only claimed a page served from the network holds this page's own build. + const replaced = servedBy !== null && currentController() !== servedBy + if (!reg || replaced) { + reloadOnce(version) + return + } + const outcome = await waitForTakeover(reg, version) + if (outcome === 'stalled') reloadFresh() + else reloadOnce(version) } -function waitForTakeover(reg: ServiceWorkerRegistration, version: string): Promise { +/** How a requested handover ended: the new worker took over, there was none, or it did not get there. */ +type Takeover = 'took-over' | 'unchanged' | 'stalled' + +function waitForTakeover(reg: ServiceWorkerRegistration, version: string): Promise { return new Promise(resolve => { - const done = (): void => { + let settled = false + const done = (outcome: Takeover): void => { + if (settled) return + settled = true clearTimeout(timer) - resolve() + resolve(outcome) } - const timer = setTimeout(done, SWITCH_WAIT_MS) + const timer = setTimeout(() => done('stalled'), SWITCH_WAIT_MS) navigator.serviceWorker.addEventListener('controllerchange', () => { markApplied(version) - done() + done('took-over') }, { once: true }) reg.update().then(() => { const incoming = reg.installing ?? reg.waiting if (!incoming) { - done() + done('unchanged') return } incoming.addEventListener('statechange', () => { - if (incoming.state === 'redundant') done() + if (incoming.state === 'redundant') done('stalled') }) - }, done) + }, () => done('stalled')) }) } diff --git a/client/tests/integration/api/websocket.test.ts b/client/tests/integration/api/websocket.test.ts index aae4a0b5c..d597e795a 100644 --- a/client/tests/integration/api/websocket.test.ts +++ b/client/tests/integration/api/websocket.test.ts @@ -292,7 +292,7 @@ describe('handleMessage / listeners', () => { }); it('FE-COMP-WS-012b: the welcome frame tells the store which version the server runs', async () => { - useServerVersionStore.setState({ reported: null }); + useServerVersionStore.setState({ first: null, reported: null }); const sock = await setupConnectedSocket(); sock.onmessage!({ data: JSON.stringify({ type: 'welcome', socketId: 'server-sid-2', version: '4.3.4' }) }); diff --git a/client/tests/unit/stores/serverVersionStore.test.ts b/client/tests/unit/stores/serverVersionStore.test.ts index 3bda70071..57650e25e 100644 --- a/client/tests/unit/stores/serverVersionStore.test.ts +++ b/client/tests/unit/stores/serverVersionStore.test.ts @@ -2,15 +2,15 @@ import { beforeEach, describe, expect, it } from 'vitest' import { useServerVersionStore } from '../../../src/store/serverVersionStore' beforeEach(() => { - useServerVersionStore.setState({ reported: null }) + useServerVersionStore.setState({ first: null, reported: null }) }) describe('serverVersionStore', () => { - it('FE-STORE-SRVVER-001: keeps the last release tag the server reported', () => { + it('FE-STORE-SRVVER-001: keeps the last release tag the server reported, and the first one', () => { useServerVersionStore.getState().note('4.3.4') - expect(useServerVersionStore.getState().reported).toBe('4.3.4') + expect(useServerVersionStore.getState()).toMatchObject({ first: '4.3.4', reported: '4.3.4' }) useServerVersionStore.getState().note('4.4.0-rc.1') - expect(useServerVersionStore.getState().reported).toBe('4.4.0-rc.1') + expect(useServerVersionStore.getState()).toMatchObject({ first: '4.3.4', reported: '4.4.0-rc.1' }) }) it('FE-STORE-SRVVER-002: ignores what is not a release tag, and a repeat of the same version', () => { diff --git a/server/tests/websocket/wire-envelope.test.ts b/server/tests/websocket/wire-envelope.test.ts index c827a6131..115e52cec 100644 --- a/server/tests/websocket/wire-envelope.test.ts +++ b/server/tests/websocket/wire-envelope.test.ts @@ -118,9 +118,13 @@ describe('WS wire envelope', () => { const frames = await connect(createEphemeralToken(user.id, 'ws')!); try { const msg = await frames.next(); - expect(Object.keys(msg).sort()).toEqual(['socketId', 'type']); + expect(Object.keys(msg).sort()).toEqual(['socketId', 'type', 'version']); expect(msg.type).toBe('welcome'); expect(typeof msg.socketId).toBe('number'); + // Added beside the id, never instead of it: a client from before reads + // socketId as it always did, a current one compares the version with its + // own build (NewVersionNotice). + expect(typeof msg.version).toBe('string'); // X-Socket-Id is read straight off this field (client/src/api/websocket.ts). // Nesting it under `data` would silently disable echo suppression. expect(msg).not.toHaveProperty('data'); diff --git a/wiki/Updating.md b/wiki/Updating.md index 3a121ac63..6ce4ccccb 100644 --- a/wiki/Updating.md +++ b/wiki/Updating.md @@ -151,6 +151,10 @@ See [Install-Portainer](Install-Portainer) for the full installation walkthrough In the Unraid Docker tab, click the TREK container and select **Update**. Unraid will pull the latest image and restart with the same volumes. +## Open apps after an update + +A browser tab or installed app that stays open while you update notices the new version as soon as it reconnects to the restarted server. It shows a small notice with **Reload page**, which loads the new version without waiting for the next start; a page that is closed and opened again switches on its own. The notice stays away while the device is offline and can be closed until the next start. + ## Next Steps - [Backups](Backups) — schedule automatic backups so you always have a restore point before updates