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
+11 -5
View File
@@ -165,7 +165,7 @@ export async function imRequest<T = unknown>(
);
}
}
if (!response.ok || (payload?.code && payload.code !== 0)) {
if (!response.ok || payload?.code) {
const known = translateReason(payload.reason, payload.details);
throw new ImApiError(
known || payload.message || payload.reason || response.statusText || '请求失败',
@@ -196,16 +196,22 @@ export const imDelete = <T = unknown>(path: string, opts: RequestOptions = {}) =
* only ever fetch page 1 (e.g. "am I this group's owner/admin" going undetected because the real
* answer was on page 2). Empty/repeated next_cursor means done.
*/
// Guards against a server-side cursor bug (e.g. two values that cycle back and forth) turning this
// into an infinite fetch loop -- 200 pages at a 20-50 item page size is already far more than any
// realistic friends/blocks/search list, so hitting this is itself a signal something's wrong server-
// side, not a real user's data legitimately needing more.
const MAX_PAGES = 200;
export async function fetchAllPages<T>(
fetchPage: (cursor: string) => Promise<{ items?: T[]; next_cursor?: string }>,
startCursor = '',
): Promise<T[]> {
let cursor = startCursor;
let items: T[] = [];
for (;;) {
const page = await fetchPage(cursor);
items = items.concat(page?.items || []);
const nextCursor = page?.next_cursor || '';
for (let page = 0; page < MAX_PAGES; page++) {
const result = await fetchPage(cursor);
items = items.concat(result?.items || []);
const nextCursor = result?.next_cursor || '';
if (!nextCursor || nextCursor === cursor) break;
cursor = nextCursor;
}