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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user