Files
TREK/client/src/components/Layout/NewVersionNotice.tsx
T
Maurice 0126ef3401 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.
2026-10-08 00:01:33 +00:00

71 lines
3.1 KiB
TypeScript

/**
* 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, 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.
* 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 first = useServerVersionStore(s => s.first)
const { offline } = useNetworkMode()
const [dismissed, setDismissed] = useState<string | null>(null)
const [switching, setSwitching] = useState(false)
// 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)
void switchToServerVersion(reported)
}
return (
<div
role="status"
aria-live="polite"
// Above the OfflineBanner pill, which sits 16px over the bottom nav.
style={{ bottom: 'calc(var(--bottom-nav-h) + 56px)' }}
className="fixed inset-x-4 z-[9999] flex items-center gap-3 rounded-xl border border-edge bg-surface-elevated px-4 py-3 text-content shadow-lg sm:inset-x-auto sm:start-4 sm:max-w-sm"
>
<RefreshCw size={16} className={`shrink-0 text-accent-on ${switching ? 'animate-spin' : ''}`} aria-hidden="true" />
<span className="min-w-0 flex-1 text-sm">{t('common.errorUpdateTitle')}</span>
<button
type="button"
onClick={reload}
disabled={switching}
className="shrink-0 rounded-lg bg-accent px-3 py-1.5 text-sm font-semibold text-accent-text hover:bg-accent-hover disabled:opacity-60"
>
{t('common.errorReload')}
</button>
<button
type="button"
onClick={() => setDismissed(reported)}
disabled={switching}
aria-label={t('common.close')}
className="shrink-0 rounded-md p-1 text-content-muted hover:bg-surface-hover disabled:opacity-60"
>
<X size={14} aria-hidden="true" />
</button>
</div>
)
}