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
@@ -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(() => {});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user