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).