Closes the gap found while switching production to immutable: a deploy signs everyone out, but it does not get them onto the new build.
client.handleUnauthenticated() sets state to anonymous and renders the sign-in view from the bundle already in memory. No reload happens — the only location.reload() in the client is the manual Refresh menu item (AppShell.tsx:106). So after a deploy a user signs back in and carries on running old JS against the new server until they happen to hard-refresh.
The pieces already existed
index.html is served no-cache, assets under it are content-hashed and immutable (static.ts:62,85) — a reload is sufficient
Vite bakes the build's version in as APP_VERSION (vite.config.ts:12)
/api/health reports the server's version
Only the comparison was missing.
Design choices
On a 401, not on a timer. That is the moment it matters, and it costs one small request.
Compare versions, don't reload on every 401. An ordinary session expiry still lands on the sign-in form with the page intact.
Check before showing the sign-in form, not after. Reloading a form someone has already started typing into would throw the password away.
Any unclear answer leaves the page alone. Unreachable server, non-OK response, missing version → no reload. Losing what is on screen is worse than staying stale.
Reload once per version. A server that keeps reporting a version the bundle does not match — stale proxy cache, half-finished deploy — cannot put the tab in a reload loop.
Verified in a real browser, not just unit tests
Built bundle + mock upstream, signed in, planted a marker on window, then restarted the server on a different version (dropping the in-memory session, exactly as an immutable deploy does), then clicked a folder like a user would:
markerStillThere: "GONE — the page reloaded"
navType: "reload"
The loop guard got tested for free: the static bundle stayed 2.16.118 while the server kept reporting 2.16.999, so the mismatch was permanent. Three further forced 401s produced no second reload — page up 23s across all of them.
Unit tests cover the six branches: mismatch reloads, match doesn't, once-per-version, guard clears when versions agree again, unreachable server, bad response / missing version.
npm run typecheck, npm test (344 web + 80 server), npm run build all clean.
Closes the gap found while switching production to immutable: **a deploy signs everyone out, but it does not get them onto the new build.**
`client.handleUnauthenticated()` sets state to `anonymous` and renders the sign-in view from the bundle already in memory. No reload happens — the only `location.reload()` in the client is the manual Refresh menu item (`AppShell.tsx:106`). So after a deploy a user signs back in and carries on running **old JS against the new server** until they happen to hard-refresh.
### The pieces already existed
- `index.html` is served `no-cache`, assets under it are content-hashed and `immutable` (`static.ts:62,85`) — a reload is sufficient
- Vite bakes the build's version in as `APP_VERSION` (`vite.config.ts:12`)
- `/api/health` reports the server's version
Only the comparison was missing.
### Design choices
**On a 401, not on a timer.** That is the moment it matters, and it costs one small request.
**Compare versions, don't reload on every 401.** An ordinary session expiry still lands on the sign-in form with the page intact.
**Check before showing the sign-in form, not after.** Reloading a form someone has already started typing into would throw the password away.
**Any unclear answer leaves the page alone.** Unreachable server, non-OK response, missing version → no reload. Losing what is on screen is worse than staying stale.
**Reload once per version.** A server that keeps reporting a version the bundle does not match — stale proxy cache, half-finished deploy — cannot put the tab in a reload loop.
### Verified in a real browser, not just unit tests
Built bundle + mock upstream, signed in, planted a marker on `window`, then restarted the server on a different version (dropping the in-memory session, exactly as an immutable deploy does), then clicked a folder like a user would:
```
markerStillThere: "GONE — the page reloaded"
navType: "reload"
```
The loop guard got tested for free: the static bundle stayed 2.16.118 while the server kept reporting 2.16.999, so the mismatch was permanent. Three further forced 401s produced **no second reload** — page up 23s across all of them.
Unit tests cover the six branches: mismatch reloads, match doesn't, once-per-version, guard clears when versions agree again, unreachable server, bad response / missing version.
`npm run typecheck`, `npm test` (344 web + 80 server), `npm run build` all clean.
**Merged** 2026-08-27 as coffey-labs/ihasmail@a3dc7e017c14
<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.
Closes the gap found while switching production to immutable: a deploy signs everyone out, but it does not get them onto the new build.
client.handleUnauthenticated()sets state toanonymousand renders the sign-in view from the bundle already in memory. No reload happens — the onlylocation.reload()in the client is the manual Refresh menu item (AppShell.tsx:106). So after a deploy a user signs back in and carries on running old JS against the new server until they happen to hard-refresh.The pieces already existed
index.htmlis servedno-cache, assets under it are content-hashed andimmutable(static.ts:62,85) — a reload is sufficientAPP_VERSION(vite.config.ts:12)/api/healthreports the server's versionOnly the comparison was missing.
Design choices
On a 401, not on a timer. That is the moment it matters, and it costs one small request.
Compare versions, don't reload on every 401. An ordinary session expiry still lands on the sign-in form with the page intact.
Check before showing the sign-in form, not after. Reloading a form someone has already started typing into would throw the password away.
Any unclear answer leaves the page alone. Unreachable server, non-OK response, missing version → no reload. Losing what is on screen is worse than staying stale.
Reload once per version. A server that keeps reporting a version the bundle does not match — stale proxy cache, half-finished deploy — cannot put the tab in a reload loop.
Verified in a real browser, not just unit tests
Built bundle + mock upstream, signed in, planted a marker on
window, then restarted the server on a different version (dropping the in-memory session, exactly as an immutable deploy does), then clicked a folder like a user would:The loop guard got tested for free: the static bundle stayed 2.16.118 while the server kept reporting 2.16.999, so the mismatch was permanent. Three further forced 401s produced no second reload — page up 23s across all of them.
Unit tests cover the six branches: mismatch reloads, match doesn't, once-per-version, guard clears when versions agree again, unreachable server, bad response / missing version.
npm run typecheck,npm test(344 web + 80 server),npm run buildall clean.Merged 2026-08-27 as coffey-labs/ihasmail@a3dc7e017c
Rebuilt from: git history, session transcript.