From 4b5fd3aab7d32f17bd1ec72f9421e028594f9617 Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Mon, 17 Aug 2026 12:34:15 -0600 Subject: [PATCH] Trigger update checks off WS reconnect, not just the hourly poll (#42) A backend restart during a deploy kills every open WebSocket, and the chat socket's existing reconnect-with-backoff already re-fires onopen within seconds -- reuse that as a reliable "the server just restarted" signal to check for a new service worker version, instead of waiting up to an hour for UpdateBanner's poll. The hourly poll stays as a fallback. Co-Authored-By: Claude Sonnet 5 --- frontend/src/components/UpdateBanner.tsx | 10 ++++++++-- frontend/src/lib/swUpdate.ts | 14 ++++++++++++++ frontend/src/ws/useChatSocket.ts | 10 ++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 frontend/src/lib/swUpdate.ts diff --git a/frontend/src/components/UpdateBanner.tsx b/frontend/src/components/UpdateBanner.tsx index df5a093..e7da185 100644 --- a/frontend/src/components/UpdateBanner.tsx +++ b/frontend/src/components/UpdateBanner.tsx @@ -1,4 +1,5 @@ import { useRegisterSW } from 'virtual:pwa-register/react' +import { checkForUpdate, setSwRegistration } from '../lib/swUpdate' import './UpdateBanner.css' // The service worker (registerType: 'prompt', sw.ts) already installs and @@ -6,7 +7,11 @@ import './UpdateBanner.css' // never navigates, and a page only checks for a new SW on navigation by // default, so a tab left open for hours could sit on a stale check // indefinitely. This polls explicitly so "reload available" shows up -// without the user having to close and reopen the app first. +// without the user having to close and reopen the app first. It's now a +// fallback, not the primary trigger -- useChatSocket.ts also calls +// checkForUpdate() on every WS reconnect, which reliably fires within +// seconds of a deploy (the backend restart that ships a new version also +// kills every open WS connection) rather than waiting up to an hour. const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000 export function UpdateBanner() { @@ -16,7 +21,8 @@ export function UpdateBanner() { } = useRegisterSW({ onRegisteredSW(_url, registration) { if (!registration) return - setInterval(() => registration.update(), UPDATE_CHECK_INTERVAL_MS) + setSwRegistration(registration) + setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS) }, }) diff --git a/frontend/src/lib/swUpdate.ts b/frontend/src/lib/swUpdate.ts new file mode 100644 index 0000000..29219c4 --- /dev/null +++ b/frontend/src/lib/swUpdate.ts @@ -0,0 +1,14 @@ +// Bridges the service worker registration (owned by UpdateBanner, which +// mounts outside ChatSocketProvider -- see App.tsx) out to code that has no +// other way to reach it, namely useChatSocket.ts's reconnect handler, which +// wants to trigger an update check whenever the WS reconnects (a reliable +// signal the backend just restarted, i.e. a deploy happened). +let registration: ServiceWorkerRegistration | null = null + +export function setSwRegistration(reg: ServiceWorkerRegistration): void { + registration = reg +} + +export function checkForUpdate(): void { + void registration?.update() +} diff --git a/frontend/src/ws/useChatSocket.ts b/frontend/src/ws/useChatSocket.ts index e313442..73e568a 100644 --- a/frontend/src/ws/useChatSocket.ts +++ b/frontend/src/ws/useChatSocket.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { checkForUpdate } from '../lib/swUpdate' import type { ServerEnvelope } from '../types' interface UseChatSocketOptions { @@ -42,6 +43,13 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) { let stopped = false let reconnectDelay = RECONNECT_BASE_DELAY_MS let reconnectTimer: ReturnType | null = null + // False only for the very first connect() of this effect's lifetime -- + // every connect() after that was triggered by onclose's retry logic, + // i.e. this is a genuine reconnect. A reconnect reliably means the + // backend process just restarted (a deploy kills every open WS), so + // it's used as the trigger for an out-of-band SW update check instead + // of waiting on UpdateBanner's hourly poll -- see lib/swUpdate.ts. + let hasConnectedBefore = false function connect() { const protocol = location.protocol === 'https:' ? 'wss' : 'ws' @@ -56,6 +64,8 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) { // socket's callbacks can stomp on state that the second (real) // socket already owns. if (socketRef.current !== ws) return + if (hasConnectedBefore) checkForUpdate() + hasConnectedBefore = true reconnectDelay = RECONNECT_BASE_DELAY_MS setConnected(true) // Re-join whatever rooms were joined before a reconnect -- the