From 4b058a6703568e092c9fdc182c81cd588baa230f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 11 Aug 2026 18:34:13 +0000 Subject: [PATCH] fix the sign-out reload loop I shipped an hour ago MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/workspaces/hooks/src/useClient.ts | 45 ++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/src/workspaces/hooks/src/useClient.ts b/src/workspaces/hooks/src/useClient.ts index 630840fc..a51fbe9c 100644 --- a/src/workspaces/hooks/src/useClient.ts +++ b/src/workspaces/hooks/src/useClient.ts @@ -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) => {