mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
fix(client): the version notice asks about a change during the session only
Three gaps in the notice from the previous commit, found in review. A page served from the network and claimed later by its own build's worker counted as already taken over, so Reload went to that worker, got the old build again and spent the session's reload: no notice and no new build until the next start. Only a worker that replaced the one that served the page skips the handover now, as at launch. A reload that came before the new worker could take over (a slow precache, a failed update, an install gone redundant) also spent that guard, so the launch behind it could no longer finish the handover with its own reload. That reload leaves the guard alone now. An install whose server never reports the bundle's version (a hand-set APP_VERSION) showed the notice in every session, and Reload could not clear it. The store keeps the first version a page hears of, from the app config at launch, and the notice only asks when a later report differs: a different build at launch is the launch handover's to fix. The server's wire-envelope test pins the welcome frame's fields and now expects the version beside the socket id. The wiki's update page says what an open tab shows after an update.
This commit is contained in:
@@ -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(() => {})
|
||||
|
||||
@@ -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(<NewVersionNotice />)
|
||||
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(<NewVersionNotice />)
|
||||
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(<NewVersionNotice />)
|
||||
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(<NewVersionNotice />)
|
||||
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(<NewVersionNotice />)
|
||||
act(() => useServerVersionStore.getState().note(NEXT))
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -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<string | null>(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)
|
||||
|
||||
@@ -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<ServerVersionState>()((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 })
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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<void> {
|
||||
let reg: ServiceWorkerRegistration | undefined
|
||||
@@ -139,34 +144,46 @@ export async function switchToServerVersion(version: string): Promise<void> {
|
||||
} 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<void> {
|
||||
/** 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<Takeover> {
|
||||
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'))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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' }) });
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user