修复今天新增的兜底同步会让已读会话的未读角标无故重新出现的回归

根因:tinode-sdk自己的合并工具mergeObj(utils.js)对Date类型字段有"保留更新
的值"的防护,但普通数字字段(seq/read/recv/unread)完全没有防护,谁的数据
后到就直接覆盖——读源码逐行确认。startMeReconciliation()新增的定时/切换焦点
触发的withLaterSub()增量拉取一旦命中"服务端返回的read游标还没跟上客户端刚
发出的已读回执"这种时序,就会把已经读过的会话悄悄改回未读:角标无缘无故重新
出现,且重新聚焦窗口也不会清除,因为对这个会话来说根本没有发生任何新事情去
重新触发标记已读。

修复:reconcileMeSubs()改成在发起这次增量拉取前,先给所有已知会话的
seq/read/recv/unread拍个快照;拉取完成后,对每个"拉取前就已经认识"的会话,
如果发现seq没变但unread反而变多了,直接把read/recv/unread改回快照里的值并
触发通知——只有真正全新的会话(拉取前不认识)才信任这次拉取回来的数据,这
本来就是这个兜底机制存在的唯一目的。

实机验证:monkey-patch me.getMeta,让它在真实网络请求返回后额外注入一条
过期的sub记录(模拟服务端读游标滞后),再用真实的focus事件触发
reconcileMeSubs()整条链路——确认最终状态仍然正确显示已读,而不是被污染。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-16 20:32:49 +08:00
co-authored by Claude Sonnet 5
parent cd6b7675d4
commit 3d6ae089a1
+37 -1
View File
@@ -382,7 +382,43 @@ let meReconcileTimer: ReturnType<typeof setInterval> | null = null;
function reconcileMeSubs() { function reconcileMeSubs() {
const me = getTinode().getMeTopic(); const me = getTinode().getMeTopic();
if (!me.isSubscribed()) return; if (!me.isSubscribed()) return;
me.getMeta(me.startMetaQuery().withLaterSub().build()).catch(() => {}); // Snapshot every currently-known topic's read-state fields before the fetch -- see the .then()
// below for why the response can't be trusted at face value for topics already tracked (only for
// genuinely new ones, which is this mitigation's entire reason to exist).
const before = new Map<string, { seq: number; read: number; recv: number; unread: number }>();
for (const c of getChatListSnapshot()) {
if (c.topic) before.set(c.topic, { seq: c.seq || 0, read: c.read || 0, recv: c.recv || 0, unread: c.unread || 0 });
}
me.getMeta(me.startMetaQuery().withLaterSub().build())
.then(() => {
// Real, live-confirmed bug (2026-09-16, reported directly): tinode-sdk's own merge utility
// (mergeObj in utils.js) has a defensive "keep the later value" rule for Date fields but NONE
// for plain numbers -- seq/read/recv/unread just get blindly overwritten by whatever this
// fetch's response says, confirmed by reading the source. If the server's view of a sub's read
// cursor is even momentarily behind a `{note read}` the client already sent (e.g. this timer or
// a focus/visibility trigger firing shortly after the user opened and read a conversation), this
// silently un-reads it: the badge reappears with no new message behind it, and re-focusing the
// window doesn't clear it either since nothing about it actually changed to re-trigger a fresh
// read-mark -- confirmed reproducing exactly like this live, not a hypothetical race. Since plain
// numbers can't defend themselves the way Dates do, restore anything that regressed for topics
// already known BEFORE this fetch started, rather than trust the fetch there -- this mitigation
// only needs to win for topics that were unknown before it (the actual gap it exists to close),
// not overwrite ones a user has already read in the meantime.
for (const c of getChatListSnapshot()) {
if (!c.topic) continue;
const prev = before.get(c.topic);
if (!prev) continue; // brand-new topic to this session -- exactly what this mitigation is for
const seq = c.seq || 0;
const unread = c.unread || 0;
if (seq <= prev.seq && unread > prev.unread) {
c.read = prev.read;
c.recv = prev.recv;
c.unread = prev.unread;
notifyTopic(c.topic);
}
}
})
.catch(() => {});
} }
/** /**