fix(web): code review round 3 -- surviving races and a shape bug

Round 2's dedup fixes for connect()/openConversation left two gaps that
only showed up under closer scrutiny (and, for the first one, actual
live reproduction):

- closeConversation() awaited the shared in-flight subscribe promise
  before calling leave(), but didn't check whether a NEWER
  openConversation() had since claimed the same topic while it was
  waiting -- under React 19 StrictMode's mount->cleanup->remount, the
  stale cleanup's leave() would detach the subscription the surviving
  mount had just established, silently killing live updates for
  whatever conversation you'd just opened. Fixed with a per-topic
  generation counter: closeConversation only actually leaves if no
  newer open has since claimed the topic.
- prefetchPreview (chat-list preview subscribe) never joined the same
  in-flight tracking as openConversation, so opening a conversation
  while its own preview subscribe was still resolving could still send
  two overlapping {sub} packets at the same topic.
- resetTinode() didn't clear connectPromise/restorePromise or
  disconnect a connection that was still mid-handshake (only guarded on
  isConnected()) -- switching tenants while the old connection was slow
  to respond could leave a new login silently waiting on the abandoned
  old connection's outcome.
- subscribeMeTopic() got the same in-flight dedup as the others, for
  consistency (no concrete concurrent caller today, cheap insurance
  against a future one).

Also:
- getBlocks()'s response type was a flat Friend shape; the real
  contract (confirmed against both the protocol doc's JSON example and
  the old app's own consumer) nests the user under `.user`, so "already
  blocked" state in DetailColumn was silently always false.
- useBootstrap() had no tenant in its query key and staleTime:Infinity,
  so switching tenants without a full reload kept serving the PREVIOUS
  tenant's message_policy/features/permissions/security forever.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-08 05:27:16 +08:00
co-authored by Claude Sonnet 5
parent c9f4615c40
commit 8ec74dffcc
6 changed files with 124 additions and 19 deletions
+34 -4
View File
@@ -2,10 +2,17 @@ import { useEffect, useState } from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import { Toast } from '@/app/Toast';
import { restoreSession } from '@/lib/tinode/connection';
import { subscribeMeTopic } from '@/lib/tinode/topics';
import { useSessionStore } from '@/stores/session';
/** No connect-timeout exists anywhere in the SDK (its WS promise only settles on the underlying
* socket's own onopen/onerror) -- past this, assume the host is unreachable in a way that won't
* self-resolve (stalled handshake, silently-dropped middlebox) rather than hang the restore screen
* forever with no escape route. */
const RESTORE_TIMEOUT_MS = 10_000;
/**
* Gate for everything inside AppShell: needs a resolved tenant and an authenticated user.
*
@@ -22,33 +29,56 @@ export function RequireAuth() {
const setMyUserId = useSessionStore((s) => s.setMyUserId);
const [hasToken] = useState(() => !!sessionStorage.getItem('im-session-token'));
const [restoreFailed, setRestoreFailed] = useState(false);
const [restoreTimedOut, setRestoreTimedOut] = useState(false);
const [contactsError, setContactsError] = useState('');
useEffect(() => {
if (myUserId || !tenant || !hasToken) return undefined;
const token = sessionStorage.getItem('im-session-token');
if (!token) return undefined;
let cancelled = false;
const timeout = window.setTimeout(() => {
if (!cancelled) setRestoreTimedOut(true);
}, RESTORE_TIMEOUT_MS);
restoreSession(token)
.then((uid) => {
if (cancelled) return;
setMyUserId(uid);
subscribeMeTopic().catch(() => {});
subscribeMeTopic().catch(() => setContactsError('会话列表加载失败,请刷新页面重试'));
})
.catch(() => {
if (!cancelled) {
sessionStorage.removeItem('im-session-token');
setRestoreFailed(true);
}
})
.finally(() => {
window.clearTimeout(timeout);
});
return () => {
cancelled = true;
window.clearTimeout(timeout);
};
}, [tenant, myUserId, hasToken, setMyUserId]);
if (!tenant) return <Navigate to="/tenant" replace />;
if (myUserId) return <Outlet />;
if (hasToken && !restoreFailed) {
return <div className="grid h-dvh place-items-center bg-surface-page text-sm text-text-tertiary-3">正在恢复登录状态…</div>;
if (myUserId) {
return (
<>
<Outlet />
<Toast message={contactsError} variant="error" onDone={() => setContactsError('')} />
</>
);
}
if (hasToken && !restoreFailed && !restoreTimedOut) {
return (
<div className="flex h-dvh flex-col items-center justify-center gap-3 bg-surface-page text-sm text-text-tertiary-3">
正在恢复登录状态…
<a href="/login" className="text-accent no-underline">
改为重新登录
</a>
</div>
);
}
return <Navigate to="/login" replace />;
}