fix(groups): correct rename-sync event name and unreliable getMeta path

The previous fix listened for a documented-but-wrong event name
(group.profile.updated); live instrumentation of a real rename showed
the actual wire event is group.settings.updated. Fixing only the name
still didn't work: the refresh mechanism it triggered
(getMeta(startMetaQuery().withDesc().build())) was itself unreliable
against this backend -- live-confirmed to either time out (504) on a
brand-new topic or return stale pre-rename data on an established one,
which also explains the "second rename flips back to the original
name" symptom (a late, stale response from an earlier call clobbering
the correct value).

Replace it with the already-reliable REST getGroup() response applied
locally via the SDK's own _processMetaDesc (same internal method the
SDK calls when a real meta response arrives; confirmed to reach
me.contacts(), which the sidebar/header read).

Also fixes a second, independently-discovered bug found while
verifying the above: notifyTopic's requestAnimationFrame-based
notification batching could get permanently stuck if the callback
was scheduled while the page wasn't compositing frames, silently
freezing all future topic-list updates (not just renames) until a
full reload. Falls back to setTimeout when the document is hidden,
since that is never dropped the way the rAF was.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-18 18:07:41 +08:00
co-authored by Claude Sonnet 5
parent 5748f1290d
commit 7cf8e4f4e0
3 changed files with 59 additions and 17 deletions
@@ -172,10 +172,13 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
queryClient.setQueryData(['group', topicId], (prev: GroupProfile | undefined) => (prev ? { ...prev, ...data } : data)); queryClient.setQueryData(['group', topicId], (prev: GroupProfile | undefined) => (prev ? { ...prev, ...data } : data));
setNameEditing(false); setNameEditing(false);
// The renamer's OWN sidebar/header (`contact.public?.fn`, native SDK state) doesn't update from // The renamer's OWN sidebar/header (`contact.public?.fn`, native SDK state) doesn't update from
// this REST call either -- only from the `group.profile.updated` push topics.ts now listens for, // this REST call either -- only from the `group.settings.updated` push topics.ts listens for,
// which this account won't receive for its own action. Trigger the same SDK-sanctioned refresh // which this account won't receive for its own action. `data` here is already the fresh REST
// directly instead of waiting on an unconfirmed self-echo of that push. // response, so apply it locally via `_processMetaDesc` (live-confirmed to reach `me.contacts()`,
tinode.getTopic(topicId).getMeta(tinode.getTopic(topicId).startMetaQuery().withDesc().build()).catch(() => {}); // see topics.ts's comment on the same call) instead of an extra `getMeta(...withDesc())` round
// trip -- that SDK-sanctioned path was live-instrumented and found to reliably time out (504) or
// return stale data against this backend, so it's not used anywhere in this fix anymore.
tinode.getTopic(topicId)._processMetaDesc({ public: { fn: data.name, name: data.name } });
}, },
onError: (err) => showError(err, '群名保存失败'), onError: (err) => showError(err, '群名保存失败'),
}); });
+18
View File
@@ -183,6 +183,24 @@ declare module 'tinode-sdk' {
getMeta(query: unknown): Promise<unknown>; getMeta(query: unknown): Promise<unknown>;
startMetaQuery(): MetaGetBuilder; startMetaQuery(): MetaGetBuilder;
/**
* Private/internal SDK method (underscore prefix, not part of the documented API) -- normally only
* ever called by the SDK itself when a real `{meta desc}` server response arrives. Declared here
* and used deliberately, NOT as a first choice: `getMeta(startMetaQuery().withDesc().build())` (the
* sanctioned public API for exactly this "re-pull desc from server" need) was live-instrumented
* against this backend and found to be genuinely unreliable for group topics -- consistently a
* 504 timeout (~5.4s, every attempt, including 10+s after the write) on a brand-new topic, and on
* an established topic a fast-but-STALE response whose `public.fn` still reflected the pre-rename
* name (confirmed by round-tripping a real REST rename immediately followed by this call). Calling
* this directly with data already known-fresh (from a REST response we trust) sidesteps that
* broken path entirely -- confirmed by reading the SDK's own source that it merges `e.public` onto
* the topic's existing `public` object (other fields like `version` survive) and, for a non-`me`
* topic, forwards the update to the `me` topic's own subscription cache via `onMetaSub`/
* `onSubsUpdated` -- i.e. the exact same fan-out a genuine server push would trigger. Live-confirmed
* this reaches `me.contacts()` (same object reference as `topic.public`, verified live).
*/
_processMetaDesc(desc: { public?: Partial<TopicDescPublic> }): void;
/** Builds the `{pub}` packet shape but does not send it -- pair with `publishDraft`/`publishMessage`. */ /** Builds the `{pub}` packet shape but does not send it -- pair with `publishDraft`/`publishMessage`. */
createMessage(content: DraftyContent | string, noEcho?: boolean): StoredMessage; createMessage(content: DraftyContent | string, noEcho?: boolean): StoredMessage;
/** /**
+34 -13
View File
@@ -12,6 +12,7 @@
// getParams -- so this always leaves first when already subscribed, then resubscribes. // getParams -- so this always leaves first when already subscribed, then resubscribes.
import type { Contact, StoredMessage, Topic } from 'tinode-sdk'; import type { Contact, StoredMessage, Topic } from 'tinode-sdk';
import { getGroup } from '@/lib/api/groups';
import { MESSAGES_PAGE } from '@/lib/config'; import { MESSAGES_PAGE } from '@/lib/config';
import { getTinode, onReauthenticated } from './connection'; import { getTinode, onReauthenticated } from './connection';
@@ -128,7 +129,22 @@ function notifyTopic(topicName: string) {
pendingAnyNotification = true; pendingAnyNotification = true;
if (!notifyFrameScheduled) { if (!notifyFrameScheduled) {
notifyFrameScheduled = true; notifyFrameScheduled = true;
requestAnimationFrame(flushTopicNotifications); // Live-confirmed real bug (surfaced while testing an unrelated group-rename fix, in a webview whose
// frame compositing was paused, i.e. genuinely not painting -- not just an ordinary backgrounded
// browser tab): a `requestAnimationFrame` scheduled while the page isn't compositing frames can go
// uncalled indefinitely rather than merely deferred-until-visible. Since `notifyFrameScheduled` only
// resets to false inside the callback, one dropped frame permanently wedges this guard, silently
// freezing EVERY future topic-list update (not just the group name) until a full page reload -- data
// kept arriving and updating correctly underneath (confirmed live: `me.contacts()` had the right
// data the whole time), only the UI never found out. `document.hidden` costs nothing to check and
// `setTimeout` is never silently dropped the way this rAF was, so route through it whenever the page
// isn't visible; keep `requestAnimationFrame` for the visible case since that's what actually gives
// the intended one-flush-per-paint batching this function exists for.
if (typeof document !== 'undefined' && document.hidden) {
setTimeout(flushTopicNotifications, 0);
} else {
requestAnimationFrame(flushTopicNotifications);
}
} }
} }
@@ -298,19 +314,24 @@ function wireTopicCallbacks(topic: Topic) {
topic.getMeta(topic.startMetaQuery().withData(seq, seq + 1, 1).build()).catch(() => {}); topic.getMeta(topic.startMetaQuery().withData(seq, seq + 1, 1).build()).catch(() => {});
} }
} }
// docs/IM-前后端对接文档.md:1542: `PATCH .../groups/{topic}` (rename/avatar/description) pushes a // docs/IM-前后端对接文档.md:1542 documents this push as `group.profile.updated`, but a real rename,
// custom `{info what:"group", event:"group.profile.updated"}` to every other online member -- same // live-instrumented (logging the raw `info` object as it arrived), showed the actual wire event is
// undocumented-to-the-SDK shape as `group.dissolved` above, so nothing here ever refreshed the // `{what:"group", event:"group.settings.updated"}` -- same doc-vs-wire drift already seen elsewhere
// topic's native `public` fields on receipt. Live-confirmed the actual gap: the sidebar/header name // in this backend. Matching the real event name fixes only half the bug: the sidebar/header name
// comes from `contact.public?.fn` (native SDK state, see ChatListColumn.tsx), which a REST-only // comes from `contact.public?.fn` (native SDK state, see ChatListColumn.tsx), which a REST-only
// mutation never touches for ANYONE -- not just other members, but the renamer's own other tabs/ // mutation never touches for anyone, and the "obvious" fix -- `getMeta(startMetaQuery().withDesc()
// views too -- and only ever got refreshed as a side effect of the unrelated focus-triggered // .build())`, the SDK's own sanctioned way to re-pull desc from the server -- was live-instrumented
// `reconcileMeSubs()`, which is why refocus "fixed" it for everyone. `withDesc()` is the SDK's own // too and found to be genuinely broken against this backend: a consistent 504 timeout (~5.4s, every
// sanctioned way to re-pull `public`/description from the server (contrast the abandoned approach // attempt, still failing 10+s after the write) on a brand-new topic, and on an established topic a
// of manually patching `Topic._processMetaDesc` tried earlier for a different bug -- that one was // fast-but-stale response whose `public.fn` still held the pre-rename name. Using the already-
// reverted for inconclusive verification; this one goes through a real server round-trip instead). // reliable REST `getGroup()` instead (confirmed live to return the fresh name immediately after a
if (groupEvent?.what === 'group' && groupEvent.event === 'group.profile.updated') { // rename, every time) and applying it locally via `_processMetaDesc` -- the same internal method the
topic.getMeta(topic.startMetaQuery().withDesc().build()).catch(() => {}); // SDK itself calls when a real `{meta desc}` response arrives, live-confirmed to correctly reach
// `me.contacts()` -- sidesteps the broken path entirely instead of routing through it.
if (groupEvent?.what === 'group' && groupEvent.event === 'group.settings.updated') {
getGroup(topic.name)
.then((data) => topic._processMetaDesc({ public: { fn: data.name, name: data.name } }))
.catch(() => {});
} }
}; };
topic.onMetaDesc = notify; topic.onMetaDesc = notify;