fix the sign-out reload loop I shipped an hour ago

The 401 handler ended with location.replace('/'), guarded by "unless the path starts
with /signin". There is no /signin route — the sign-in screen IS path="/". So every 401
on the signed-out landing page navigated to the page it was already on, fetched again,
401'd again. A hard refresh loop with no way out of the tab.

The reload was never what fixed anything: useAuth already renders the sign-in screen
when there is no token. It only existed to drop a stale query cache. So it is now the
last thing attempted and bounded three separate ways, any one of which breaks a loop
alone:

  1. no token -> return. A 401 while already signed out is expected, not a revocation.
     This one alone ends it, because a reloaded document has nothing left to clear.
  2. once per document, module flag.
  3. once per tab, sessionStorage marker — which also covers a host that re-injects the
     token on every load, where clearing storage cannot help and guard 1 never fires.

Anyone stuck in the loop from the previous build: localStorage.clear() in the console.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 18:34:13 +00:00
co-authored by Claude Opus 5
parent d3bed0add9
commit 4b058a6703
+38 -7
View File
@@ -167,20 +167,44 @@ export class ApiError extends Error {
/**
* A 401 on an authenticated call means the token is no longer good — deleted account, blocked account,
* changed password, explicit signout elsewhere. Clear it and get back to the sign-in screen.
* changed password, explicit signout elsewhere. Drop it so the app stops presenting a session that is over.
*
* Until this existed, nothing in the client reacted to a 401 at all: `onError` fed the bug-report form and
* that was the end of it. So an account deleted from the dashboard kept its window rendering happily off
* cached React Query data, and a refresh looked like a live session. Observed 2026-08-11.
*
* `/auth/…` is exempt, and that exemption is load-bearing rather than tidy: a wrong password IS a 401, and
* reloading the sign-in page in response would wipe the form and look like a crash.
* ── The reload loop this caused, and the three guards that make it impossible ──
*
* `location.replace` rather than a router navigation: this module has no router, and after revocation the
* cleanest state is a fresh document with no stale query cache in it.
* The first version ended with `location.replace('/')`, guarded by "unless the path starts with /signin".
* There is no /signin route: the sign-in screen IS `path="/"`. So every 401 on the signed-out landing page
* navigated to the page it was already on, which fetched again, which 401'd again — a hard refresh loop with
* no way out of the tab. Shipped and hit within the hour.
*
* The reload is not what fixes anything — `useAuth` already renders the sign-in screen when there is no
* token. It exists only to drop a stale query cache. So it is now the last thing tried, and bounded three
* separate ways, any ONE of which breaks a loop on its own:
*
* 1. no token → return. A 401 while already signed out is expected, not a revocation. This alone ends the
* loop, because the reloaded document has nothing to clear.
* 2. once per document, via a module flag.
* 3. once per TAB, via a sessionStorage marker — which also covers a host that re-injects the token on
* every load (`window.officerBearerToken`, `document.body.dataset`), where clearing storage cannot help
* and guard 1 would not fire.
*
* `/auth/…` stays exempt, and that exemption is load-bearing rather than tidy: a wrong password IS a 401,
* and reloading the sign-in page in response would wipe the form and look like a crash.
*/
let revocationHandled = false;
const RELOAD_MARKER = 'officer:session-revoked-reload';
const handleRevokedSession = (url: string) => {
if (url.includes('/auth/')) return;
// Guard 1, and the important one: nothing to revoke means this 401 is the signed-out app asking a
// question it was always going to be refused.
if (!theToken) return;
// Guard 2.
if (revocationHandled) return;
revocationHandled = true;
theToken = null;
// Every key `createClient` reads, or the next load signs straight back in with the dead token.
@@ -190,8 +214,15 @@ const handleRevokedSession = (url: string) => {
}
window.officerBearerToken = undefined;
// Guard against a reload loop if the sign-in screen itself ever 401s on a non-/auth call.
if (!window.location.pathname.startsWith('/signin')) window.location.replace('/');
// Guard 3. Set BEFORE the reload and read after, so a token-injecting host reloads at most once ever.
try {
if (sessionStorage.getItem(RELOAD_MARKER)) return;
sessionStorage.setItem(RELOAD_MARKER, '1');
} catch {
// Private mode with storage disabled: skip the reload entirely rather than risk an unbounded one.
return;
}
window.location.reload();
};
const validateResponse = async (res: Response) => {