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