Follow-on to #119. That made the reload possible; this makes it automatic. Checking only on a 401 was deferred, not automatic — it needs the tab to make a request, so an idle tab kept running the old build until someone touched it.
The obvious signal was the wrong one, and testing showed it
A deploy kills the EventSource behind /api/events, which looks like the perfect cue. It isn't:
The drop arrives while the container is still being replaced, so the /api/health check that follows cannot reach the server. It fails and is never retried.
Waiting for the stream to return doesn't work either — the session died with the old container, so the reconnect is answered with a 401 and never reaches connected at all.
I found this by watching a tab sit unchanged through a simulated deploy. The drop is still watched (free, occasionally lands late enough to be useful), but nothing depends on it.
What the guarantee actually rests on
A 60s poll while the tab is visible, plus a check on visibilitychange. Neither depends on the stream or on anyone being at the keyboard. /api/health touches nothing upstream, so the cost is one small request per minute per open tab; hidden tabs don't poll at all and check when they surface.
The reload is unconditional
An earlier revision of this branch held the reload back while a compose window had unsaved text. That 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. The hold is gone.
The consequence is real and worth stating plainly: a deploy can take an unsent draft with it. Text that hasn't reached the server can't be saved after a deploy anyway, since the session went with the container. The trade is deliberate — a tab talking to a server it no longer matches is the worse failure, and it fails quietly.
Verified in a real browser, end to end
scenario
result
tab open, idle, deploy lands, nothing touched
reloaded on its own; marker gone, navType: "reload"
same, with a dirty compose open
reloaded anyway, ~1s after the new server came up; draft gone, as intended
hidden tab
no polling; checks on becoming visible
server unreachable / bad response
no reload — staying stale beats losing the page
server keeps reporting a version the bundle lacks
one reload, then stops; no loop
npm run typecheck, npm test (348 web + 80 server), npm run build all clean. 10 unit tests on this module, including fake-timer coverage of the poll and the hidden-tab case.
Follow-on to #119. That made the reload possible; this makes it **automatic**. Checking only on a 401 was deferred, not automatic — it needs the tab to make a request, so an idle tab kept running the old build until someone touched it.
### The obvious signal was the wrong one, and testing showed it
A deploy kills the EventSource behind `/api/events`, which looks like the perfect cue. It isn't:
- The drop arrives **while the container is still being replaced**, so the `/api/health` check that follows cannot reach the server. It fails and is never retried.
- Waiting for the stream to *return* doesn't work either — the session died with the old container, so the reconnect is answered with a 401 and never reaches `connected` at all.
I found this by watching a tab sit unchanged through a simulated deploy. The drop is still watched (free, occasionally lands late enough to be useful), but nothing depends on it.
### What the guarantee actually rests on
A 60s poll while the tab is visible, plus a check on `visibilitychange`. Neither depends on the stream or on anyone being at the keyboard. `/api/health` touches nothing upstream, so the cost is one small request per minute per open tab; hidden tabs don't poll at all and check when they surface.
### The reload is unconditional
An earlier revision of this branch held the reload back while a compose window had unsaved text. That 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. The hold is gone.
The consequence is real and worth stating plainly: **a deploy can take an unsent draft with it.** Text that hasn't reached the server can't be saved after a deploy anyway, since the session went with the container. The trade is deliberate — a tab talking to a server it no longer matches is the worse failure, and it fails quietly.
### Verified in a real browser, end to end
| scenario | result |
|---|---|
| tab open, idle, deploy lands, **nothing touched** | reloaded on its own; marker gone, `navType: "reload"` |
| same, with a dirty compose open | **reloaded anyway**, ~1s after the new server came up; draft gone, as intended |
| hidden tab | no polling; checks on becoming visible |
| server unreachable / bad response | no reload — staying stale beats losing the page |
| server keeps reporting a version the bundle lacks | one reload, then stops; no loop |
`npm run typecheck`, `npm test` (348 web + 80 server), `npm run build` all clean. 10 unit tests on this module, including fake-timer coverage of the poll and the hidden-tab case.
**Merged** 2026-08-27 as coffey-labs/ihasmail@f2e0cb632641
<sub>Rebuilt from: git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Follow-on to #119. That made the reload possible; this makes it automatic. Checking only on a 401 was deferred, not automatic — it needs the tab to make a request, so an idle tab kept running the old build until someone touched it.
The obvious signal was the wrong one, and testing showed it
A deploy kills the EventSource behind
/api/events, which looks like the perfect cue. It isn't:/api/healthcheck that follows cannot reach the server. It fails and is never retried.connectedat all.I found this by watching a tab sit unchanged through a simulated deploy. The drop is still watched (free, occasionally lands late enough to be useful), but nothing depends on it.
What the guarantee actually rests on
A 60s poll while the tab is visible, plus a check on
visibilitychange. Neither depends on the stream or on anyone being at the keyboard./api/healthtouches nothing upstream, so the cost is one small request per minute per open tab; hidden tabs don't poll at all and check when they surface.The reload is unconditional
An earlier revision of this branch held the reload back while a compose window had unsaved text. That 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. The hold is gone.
The consequence is real and worth stating plainly: a deploy can take an unsent draft with it. Text that hasn't reached the server can't be saved after a deploy anyway, since the session went with the container. The trade is deliberate — a tab talking to a server it no longer matches is the worse failure, and it fails quietly.
Verified in a real browser, end to end
navType: "reload"npm run typecheck,npm test(348 web + 80 server),npm run buildall clean. 10 unit tests on this module, including fake-timer coverage of the poll and the hidden-tab case.Merged 2026-08-27 as coffey-labs/ihasmail@f2e0cb6326
Rebuilt from: git history, session transcript.