This commit is contained in:
Austin
2026-09-18 17:58:05 +08:00
5 changed files with 133 additions and 17 deletions
+75 -3
View File
@@ -86,10 +86,50 @@ export function markConfirmedRead(topicName: string, seq: number) {
if (seq > prev) confirmedReadSeq.set(topicName, seq);
}
// Live-confirmed root cause of a real reported bug ("聊天消息框滚动到顶部或底部时UI会抖动闪烁"):
// `topic.onData = notify` (wireTopicCallbacks below) fires once per INDIVIDUAL message, not once per
// batch -- confirmed by instrumenting a real `loadOlder()` page fetch (24 older messages): the DOM
// mutated in ~14 separate small steps over ~400ms, each one growing `scrollHeight` a little and each
// one racing the OTHER open effect that reads it (loadOlder's own scroll-compensation, and
// ConversationView's separate "autoscroll to bottom" effect) -- neither gets a stable, final
// `scrollHeight` to work with until the whole page has actually finished arriving, so each of the ~14
// intermediate re-renders nudges `scrollTop` again, which is what actually reads as "shaking" rather
// than one clean jump. This wasn't reproducible by reasoning about a single `getMessagesPage()` call
// in isolation -- it only showed up instrumenting `scrollTop` across a real fetch.
// Fix: coalesce any notifications that land within the same animation frame into one flush, so a
// burst of N `onData` calls (initial page load, `loadOlder()`, or a fast run of incoming messages)
// triggers at most one re-render per frame instead of N -- imperceptible as a delay, but it means
// `scrollHeight` is only ever read by a re-render once it reflects everything that arrived so far in
// that frame, not some in-between partial state. `messageSnapshots.delete` stays synchronous (it's
// pure cache invalidation, not a re-render) so a snapshot read between two notifications in the same
// frame still sees the latest data, only the LISTENER callbacks (which is what schedules React's own
// re-render) are what get batched.
const pendingTopicNotifications = new Set<string>();
let pendingAnyNotification = false;
let notifyFrameScheduled = false;
function flushTopicNotifications() {
notifyFrameScheduled = false;
const topics = [...pendingTopicNotifications];
pendingTopicNotifications.clear();
const flushAny = pendingAnyNotification;
pendingAnyNotification = false;
for (const topicName of topics) {
topicListeners.get(topicName)?.forEach((cb) => cb());
}
if (flushAny) {
anyTopicListeners.forEach((cb) => cb());
}
}
function notifyTopic(topicName: string) {
messageSnapshots.delete(topicName);
topicListeners.get(topicName)?.forEach((cb) => cb());
anyTopicListeners.forEach((cb) => cb());
pendingTopicNotifications.add(topicName);
pendingAnyNotification = true;
if (!notifyFrameScheduled) {
notifyFrameScheduled = true;
requestAnimationFrame(flushTopicNotifications);
}
}
export function subscribeTopicChanges(topicName: string, callback: () => void): () => void {
@@ -110,7 +150,7 @@ export function subscribeAnyTopicChange(callback: () => void): () => void {
}
export interface RawTopicInfo {
what: 'read' | 'recv' | 'kp' | 'call';
what: 'read' | 'recv' | 'kp' | 'call' | 'im';
seq?: number;
from?: string;
event?: string;
@@ -269,6 +309,38 @@ function wireTopicCallbacks(topic: Topic) {
if (groupEvent?.what === 'group' && groupEvent.event === 'group.dissolved') {
markTopicGoneAndNotify(topic.name);
}
// docs/IM-前后端对接文档.md:416,1256: recall/edit push a custom `{info what:"im",
// event:"message.recalled"|"message.edited", payload:{seq,version}}` to every other ONLINE
// topic member -- explicitly NOT a native Tinode mechanism (`_routeInfo`'s switch has no `"im"`
// case, confirmed reading tinode.dev.js the same way the group.dissolved gap above was), so
// nothing here ever picked it up before. `notify()` above still fired (it's unconditional), so a
// component watching this topic WOULD re-render -- but the SDK's own local message cache was
// never told the content changed, so it re-rendered the SAME stale pre-recall/pre-edit bubble.
// The doc's own instruction is explicit: "按 topic+seq+version 刷新本地消息,不要等待用户重新
// 进入会话才更新" -- live-confirmed this was exactly what was happening instead (recall/edit only
// ever showed up for the OTHER party after something unrelated caused a full resync, e.g.
// reopening the conversation). Reuses the exact `getMeta(...).withData(seq,seq+1,1)` targeted
// single-message refetch ConversationView's own jump-to-message feature already established.
if (info?.what === 'im' && (info.event === 'message.recalled' || info.event === 'message.edited')) {
const seq = info.payload?.seq;
if (typeof seq === 'number') {
topic.getMeta(topic.startMetaQuery().withData(seq, seq + 1, 1).build()).catch(() => {});
}
}
// docs/IM-前后端对接文档.md:1542: `PATCH .../groups/{topic}` (rename/avatar/description) pushes a
// custom `{info what:"group", event:"group.profile.updated"}` to every other online member -- same
// undocumented-to-the-SDK shape as `group.dissolved` above, so nothing here ever refreshed the
// topic's native `public` fields on receipt. Live-confirmed the actual gap: the sidebar/header name
// 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/
// views too -- and only ever got refreshed as a side effect of the unrelated focus-triggered
// `reconcileMeSubs()`, which is why refocus "fixed" it for everyone. `withDesc()` is the SDK's own
// sanctioned way to re-pull `public`/description from the server (contrast the abandoned approach
// of manually patching `Topic._processMetaDesc` tried earlier for a different bug -- that one was
// reverted for inconclusive verification; this one goes through a real server round-trip instead).
if (groupEvent?.what === 'group' && groupEvent.event === 'group.profile.updated') {
topic.getMeta(topic.startMetaQuery().withDesc().build()).catch(() => {});
}
};
topic.onMetaDesc = notify;
// Only ever actually invoked on the `me` topic (the SDK reads `me.onMetaSub`/`me.onSubsUpdated`