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:
co-authored by
Claude Sonnet 5
parent
c9f4615c40
commit
8ec74dffcc
@@ -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 />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user