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 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 12:34:15 -06:00
co-authored by Claude Sonnet 5
parent dddda19238
commit 4b5fd3aab7
3 changed files with 32 additions and 2 deletions
+8 -2
View File
@@ -1,4 +1,5 @@
import { useRegisterSW } from 'virtual:pwa-register/react' import { useRegisterSW } from 'virtual:pwa-register/react'
import { checkForUpdate, setSwRegistration } from '../lib/swUpdate'
import './UpdateBanner.css' import './UpdateBanner.css'
// The service worker (registerType: 'prompt', sw.ts) already installs and // 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 // 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 // default, so a tab left open for hours could sit on a stale check
// indefinitely. This polls explicitly so "reload available" shows up // 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 const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000
export function UpdateBanner() { export function UpdateBanner() {
@@ -16,7 +21,8 @@ export function UpdateBanner() {
} = useRegisterSW({ } = useRegisterSW({
onRegisteredSW(_url, registration) { onRegisteredSW(_url, registration) {
if (!registration) return if (!registration) return
setInterval(() => registration.update(), UPDATE_CHECK_INTERVAL_MS) setSwRegistration(registration)
setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS)
}, },
}) })
+14
View File
@@ -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()
}
+10
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { checkForUpdate } from '../lib/swUpdate'
import type { ServerEnvelope } from '../types' import type { ServerEnvelope } from '../types'
interface UseChatSocketOptions { interface UseChatSocketOptions {
@@ -42,6 +43,13 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
let stopped = false let stopped = false
let reconnectDelay = RECONNECT_BASE_DELAY_MS let reconnectDelay = RECONNECT_BASE_DELAY_MS
let reconnectTimer: ReturnType<typeof setTimeout> | null = null let reconnectTimer: ReturnType<typeof setTimeout> | 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() { function connect() {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws' 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's callbacks can stomp on state that the second (real)
// socket already owns. // socket already owns.
if (socketRef.current !== ws) return if (socketRef.current !== ws) return
if (hasConnectedBefore) checkForUpdate()
hasConnectedBefore = true
reconnectDelay = RECONNECT_BASE_DELAY_MS reconnectDelay = RECONNECT_BASE_DELAY_MS
setConnected(true) setConnected(true)
// Re-join whatever rooms were joined before a reconnect -- the // Re-join whatever rooms were joined before a reconnect -- the