fix(web): code review round 2 -- critical session/connection races

- RequireAuth: a page reload lost the session even with a valid token
  sitting in sessionStorage -- myUserId is never persisted (by design)
  but nothing ever re-derived it from the reusable login token. Added
  connection.ts's restoreSession(), which also fixes the race the first
  attempt at this ran into live: React 19 StrictMode double-invokes the
  restore effect in dev, and Tinode.loginToken() isn't idempotent (second
  call on an already-authenticated connection gets 409'd), so both the
  connect() and the login itself needed module-level in-flight dedup,
  not just a per-effect cancelled flag
- topics.ts: same dedup pattern for openConversation/closeConversation --
  StrictMode's mount->cleanup->remount always overlapped an open with
  itself, and a leave() landing before its own subscribe() resolved was
  silently no-op'ing instead of detaching
- LOGGING_ENABLED was unconditionally on, including prod builds --
  Tinode's verbose logging prints the raw login secret and full message
  content to the console
- imPost/imPatch/imPut/imDelete could silently drop the Idempotency-Key
  header for a caller that passes its own headers (object-spread order
  bug, dormant today since no call site does this yet)
- DetailColumn's getFriends()/getBlocks() only ever read page 1 despite
  fetchAllPages existing specifically to prevent this bug class
- availableActions() now defers to the server's message_policy.
  supported_actions when present instead of only a client-side time
  window, and exposes recallRemainingSeconds for the context menu's
  countdown label

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-08 04:48:28 +08:00
co-authored by Claude Sonnet 5
parent 1bd8d7f804
commit d3a0eb0dbc
12 changed files with 181 additions and 65 deletions
+36 -12
View File
@@ -98,6 +98,17 @@ export function prefetchPreview(topicName: string): void {
});
}
/**
* The SDK has no per-topic in-flight tracking of its own: `Topic.subscribe()` only short-circuits
* once `_attached` is already true, so two overlapping calls (React 19 StrictMode's mount->cleanup->
* remount runs every effect twice in dev, so openConversation() always overlaps with itself once per
* open; ChatListColumn's prefetchPreview and this both racing for the same just-deep-linked topic is
* the same shape in prod) would otherwise send two `{sub}` packets, and a `leave()` that lands before
* the first `subscribe()` resolves just rejects ("Cannot leave inactive topic") and no-ops instead of
* detaching. Tracked here so every caller for the same topic name shares one in-flight promise.
*/
const inFlightSubscribe = new Map<string, Promise<Topic>>();
/**
* Opens a topic for full conversation view: fetches a page of history and wires live updates.
* Uses withData(undefined, undefined, limit), NOT withLaterData(limit) -- if prefetchPreview already
@@ -108,20 +119,33 @@ export function prefetchPreview(topicName: string): void {
* at all until the user's first send (avoiding server-side subscription state for chats opened but
* never used) -- not yet replicated here since the new-conversation picker flow doesn't exist yet.
*/
export async function openConversation(topicName: string): Promise<Topic> {
const topic = getTinode().getTopic(topicName);
wireTopicCallbacks(topic);
if (topic.isSubscribed()) {
await topic.leave(false);
}
const query = topic.startMetaQuery().withLaterDesc().withLaterSub().withAux().withData(undefined, undefined, MESSAGES_PAGE);
await topic.subscribe(query.build());
return topic;
export function openConversation(topicName: string): Promise<Topic> {
const existing = inFlightSubscribe.get(topicName);
if (existing) return existing;
const promise = (async () => {
const topic = getTinode().getTopic(topicName);
wireTopicCallbacks(topic);
if (topic.isSubscribed()) {
await topic.leave(false);
}
const query = topic.startMetaQuery().withLaterDesc().withLaterSub().withAux().withData(undefined, undefined, MESSAGES_PAGE);
await topic.subscribe(query.build());
return topic;
})();
inFlightSubscribe.set(topicName, promise);
promise.finally(() => {
if (inFlightSubscribe.get(topicName) === promise) inFlightSubscribe.delete(topicName);
});
return promise;
}
/** Soft-leave (detach, not unsubscribe) -- cached messages stay in the SDK's local buffer. */
export function closeConversation(topicName: string): void {
getTinode().getTopic(topicName).leave(false).catch(() => {});
/** Soft-leave (detach, not unsubscribe) -- cached messages stay in the SDK's local buffer. Waits out
* any in-flight openConversation() for this same topic first, so a cleanup that fires before the
* subscribe it's undoing has even resolved doesn't just silently no-op (see inFlightSubscribe above). */
export async function closeConversation(topicName: string): Promise<void> {
const pending = inFlightSubscribe.get(topicName);
if (pending) await pending.catch(() => {});
await getTinode().getTopic(topicName).leave(false).catch(() => {});
}
let chatListSnapshot: Contact[] | null = null;