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