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
+47 -3
View File
@@ -71,8 +71,15 @@ export function getMessagesSnapshot(topicName: string): StoredMessage[] {
return cached;
}
/** Deduped the same way openConversation/connect are -- subscribing the same `me` topic twice
* concurrently is the same non-idempotent-WS-call shape as those (currently reachable from
* RequireAuth.tsx/LoginPage.tsx/RegisterPage.tsx, which don't overlap today, but this is cheap
* insurance against a future call site that does, e.g. a manual "reload contacts" retry button). */
let inFlightMeSubscribe: Promise<unknown> | null = null;
/** Call once at session start (after login) -- chat-list metadata only, no message bodies. */
export function subscribeMeTopic(): Promise<unknown> {
if (inFlightMeSubscribe) return inFlightMeSubscribe;
const me = getTinode().getMeTopic();
wireTopicCallbacks(me);
// Read/recv/unread updates (e.g. from noteRead() when opening a conversation) travel through
@@ -80,9 +87,19 @@ export function subscribeMeTopic(): Promise<unknown> {
// doesn't touch it. Without this, the chat list's unread badge/ordering goes stale after reading
// a conversation until some unrelated topic event happens to invalidate its cached snapshot.
me.onContactUpdate = () => notifyTopic(me.name);
return me.subscribe(me.startMetaQuery().withLaterSub().withDesc().withTags().withCred().build());
inFlightMeSubscribe = me.subscribe(me.startMetaQuery().withLaterSub().withDesc().withTags().withCred().build()).finally(() => {
inFlightMeSubscribe = null;
});
return inFlightMeSubscribe;
}
/** Tracks prefetchPreview's own in-flight subscribe per topic, so openConversation (below) can wait
* for it to settle first instead of firing a second overlapping `{sub}` packet at the same topic --
* this is a real path, not just theoretical: ChatListColumn prefetches every visible contact's
* preview on render, and a deep link or fast click can open one of those same topics before its
* preview subscribe has resolved. */
const inFlightPreview = new Map<string, Promise<unknown>>();
/**
* One-shot per topic: subscribes just enough to learn its latest message for the chat-list preview.
* Deliberately never left afterward (matches the old app) -- simpler than tracking teardown for a
@@ -93,9 +110,13 @@ export function prefetchPreview(topicName: string): void {
previewPrefetched.add(topicName);
const topic = getTinode().getTopic(topicName);
wireTopicCallbacks(topic);
topic.subscribe(topic.startMetaQuery().withLaterData(1).build()).catch(() => {
const promise = topic.subscribe(topic.startMetaQuery().withLaterData(1).build()).catch(() => {
previewPrefetched.delete(topicName);
});
inFlightPreview.set(topicName, promise);
promise.finally(() => {
if (inFlightPreview.get(topicName) === promise) inFlightPreview.delete(topicName);
});
}
/**
@@ -109,6 +130,20 @@ export function prefetchPreview(topicName: string): void {
*/
const inFlightSubscribe = new Map<string, Promise<Topic>>();
/**
* Bumped by every openConversation() call, checked by closeConversation() after it's done waiting --
* without this, StrictMode's mount->cleanup->remount still breaks a freshly (re)opened conversation:
* cleanup-1 awaits the SAME shared inFlightSubscribe promise mount-2 is also relying on (they're
* deduped to one promise, per the comment above), and once it resolves, cleanup-1's queued
* continuation calls leave() unconditionally -- detaching the subscription mount-2 just established
* a moment earlier, with no error surfaced anywhere (confirmed by reading tinode.dev.js: `_attached`
* only flips true on a successful subscribe, and leave(false) while `_attached` is true really does
* send `{leave}` and reset it). The topic silently stops receiving live messages/receipts. Capturing
* the generation synchronously at closeConversation's call time and re-checking it after all awaits
* settle detects "a newer open has since claimed this topic" and skips the stale leave().
*/
const topicGeneration = new Map<string, number>();
/**
* 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
@@ -120,9 +155,14 @@ const inFlightSubscribe = new Map<string, Promise<Topic>>();
* never used) -- not yet replicated here since the new-conversation picker flow doesn't exist yet.
*/
export function openConversation(topicName: string): Promise<Topic> {
topicGeneration.set(topicName, (topicGeneration.get(topicName) || 0) + 1);
const existing = inFlightSubscribe.get(topicName);
if (existing) return existing;
const promise = (async () => {
// Let any in-flight chat-list preview subscribe for this same topic settle first, rather than
// firing a second overlapping `{sub}` packet at it (see inFlightPreview above).
const previewPending = inFlightPreview.get(topicName);
if (previewPending) await previewPending.catch(() => {});
const topic = getTinode().getTopic(topicName);
wireTopicCallbacks(topic);
if (topic.isSubscribed()) {
@@ -141,10 +181,14 @@ export function openConversation(topicName: string): Promise<Topic> {
/** 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). */
* subscribe it's undoing has even resolved doesn't just silently no-op (see inFlightSubscribe above)
* -- then, per topicGeneration's comment above, skips the actual leave() if a NEWER openConversation
* call has since claimed this topic while this one was waiting. */
export async function closeConversation(topicName: string): Promise<void> {
const myGeneration = topicGeneration.get(topicName) || 0;
const pending = inFlightSubscribe.get(topicName);
if (pending) await pending.catch(() => {});
if (topicGeneration.get(topicName) !== myGeneration) return;
await getTinode().getTopic(topicName).leave(false).catch(() => {});
}