From 93fbbfc0d0126319cff25900ea14cccbe3686755 Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 29 May 2026 11:51:31 -0700 Subject: [PATCH] =?UTF-8?q?fix(web):=20expired=20token=20on=20load=20?= =?UTF-8?q?=E2=86=92=20Login=20instead=20of=20stuck=20on=20Loading=20scree?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening the app with an already-expired/invalid legacy localStorage token left the user permanently on the "Loading…" screen. useProfile swallows the /api/profile 401 and sets loading=false with profile=null; the combined `profileLoading || !profile` render gate then trapped on LoadingScreen forever. The global onAuthEvent('unauthorized') handler that normally clears the token races the useProfile fetch on a fresh page-load (the handler can be null when the 401 lands), so the redirect was dropped. Split the render gate so a settled-but-null profile renders Login deterministically, and add an effect that clears the dead credential (signOut, never during render) once the load settles with no profile. signOut nulls token+user so the effect's `(token||user)` guard goes false on the next render → no loop. Happy path is unaffected: useProfile never nulls profile after a successful load, so a logged-in user can't be bounced to Login on a transient refetch blip. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/App.tsx | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index e5f2f9c..1461bdf 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -145,6 +145,20 @@ export default function App() { return () => onAuthEvent(null) }, [route, signOut]) + // Dead-credential cleanup. If the profile load has SETTLED (not loading) but + // produced no profile while a token/user is still present, the credential is + // dead — a 401 the global onAuthEvent handler may have missed due to the + // effect-registration race. Clear it here (signOut, NOT during render) so the + // WS and other hooks stop hammering 401 with the stale token. signOut clears + // token+user → the `(token || user)` condition becomes false on the next + // render → this effect no-ops → no loop. The `!profile` render branch above + // shows Login in the meantime. + useEffect(() => { + if (!profileLoading && !profile && (token || user)) { + signOut() + } + }, [profileLoading, profile, token, user, signOut]) + const navigate = useCallback((hash: string) => { window.location.hash = hash }, []) @@ -173,10 +187,19 @@ export default function App() { return } - if (profileLoading || !profile) { + if (profileLoading) { return } + if (!profile) { + // Profile finished loading but is null → the token is dead (a 401 the + // global onAuthEvent handler may have missed due to the registration race + // between useProfile's fetch effect and App's onAuthEvent effect). Treat as + // unauthenticated and render Login deterministically rather than trapping + // on LoadingScreen forever. The effect below clears the dead credential. + return + } + return ( // REVIEW BL-01: single shared /ws/client connection for all consumers // (NotificationsBridge + page route + chat surfaces).