Holding the reload back while a compose window had unsaved text protected the
text, but it meant a tab could sit on a build the server no longer runs for as
long as someone left a draft open -- which is not automatic, and automatic is
the point.
So the reload is unconditional once the versions differ, and this will
sometimes take an unsent draft with it. The trade is deliberate: a tab talking
to a server it does not match is the worse failure, and it fails quietly.
Checking only on a 401 was not automatic, just deferred. It needs the tab to
make a request, so one left open and idle went on running the old build until
somebody touched it -- which is exactly the thing that cannot be relied on.
The obvious signal turned out to be the wrong one, and testing is what showed
it. A deploy kills the EventSource behind /api/events, which looks like the
perfect cue, except it arrives while the container is still being replaced: the
check that follows cannot reach the server, fails, and is never retried.
Waiting for the stream to come back instead does not work either, because the
session died with the old container, so the reconnect is answered with a 401
and never reaches "connected" at all. The drop is still watched, since it costs
nothing and sometimes lands late enough to be useful, but nothing depends on it.
What the guarantee rests on is a slow poll while the tab is visible, plus a
check when it becomes visible again. Neither cares what the stream is doing or
whether anyone is at the keyboard. /api/health touches nothing upstream, so a
minute between checks costs one small request per open tab.
Reloading is now something that happens to people rather than something they
ask for, which makes it able to destroy work. A compose window holds text that
has not reached the server, and after a deploy it cannot be saved at all --
the session went with the container. Reloading would be the difference between
signing in again and pressing send, and losing what was written. So anything
holding such state can say so, and compose does; the tab stays on the old build
until the draft is dealt with, and catches up on the next check afterwards.
Being signed out and picking up a new version are separate things, and only
the first was happening. An immutable instance holds sessions in memory, so a
deploy signs everyone out -- but a 401 only swaps the view to the sign-in form,
client-side. The tab keeps the bundle it already has, and the old JavaScript
goes on talking to the new server until someone happens to reload by hand.
The pieces for fixing it were already there. index.html is served no-cache and
the assets under it are content-hashed and immutable, so a reload is all it
takes; Vite bakes the build's own version in as APP_VERSION; and /api/health
reports the server's. What was missing was something to compare them.
The check runs on a 401 rather than on a timer, which is the moment it matters
and costs one small request. It compares versions rather than reloading on
every 401, so an ordinary session expiry still lands on the sign-in form with
the page intact. And it runs before the sign-in form is shown rather than
after, because reloading a form someone has already started typing into would
throw the password away.
Failing to reach the server is not a reason to throw away what is on screen, so
anything other than a clear answer leaves the page alone. The version that was
reloaded for is remembered for the session, so a server that keeps reporting a
version the bundle does not match -- a stale proxy cache, a half-finished
deploy -- cannot put the tab in a reload loop.