修复新会话首次打开时消息历史被静默截断为1条、对方已读回执永不刷新

根因:tinode-web.jsx的prefetchLatestMessagePreviews()(会话列表"最后一条消息"预览)
会在会话列表一渲染就对每个有动态但本地无缓存摘要的话题调用
topic.subscribe(startMetaQuery().withLaterData(1)...),只为预览目的抓取"最新1条"
消息——这常常发生在用户真正点开这个会话之前。这次订阅会把tinode-sdk的
Topic._attached置为true,且把_maxSeq设成那一条消息的seq。

用户随后点开会话时,messages-view.jsx自己的subscribe()发现topic.isSubscribed()
已经是true就直接return,从未真正发出过完整的{sub}请求。即使去掉这个短路,
SDK的Topic.subscribe()本身在_attached为true时也会无条件短路成
Promise.resolve(this),完全不看传入的getParams——所以无论如何都不会把
withLaterSub()/withAux()等真正需要的查询发给服务端。

现场复现:全新注册的两个互为好友账号之间发4条真实消息,全新session下第一次点开
该会话,只渲染出最新1条,其余3条静默丢失;对方的已读回执(应变蓝色双勾)永远
不刷新——这两个现象都会在离开会话再重新打开后自愈(因为离开会清掉_attached),
但用户在此之前看到的是一个残缺、状态错误的会话。

修复分两处:
1. subscribe()内,若topic已经isSubscribed(),先topic.leave(false)(只清客户端和
   服务端的订阅态,不清本地已缓存消息),再执行真正的subscribe(),确保这次一定
   是货真价实的、服务端会认真处理getParams的订阅请求。
2. 把消息分页查询从withLaterData(MESSAGES_PAGE)("给我比本地_maxSeq更新的消息")
   改成withData(undefined, undefined, MESSAGES_PAGE)("给我最新的N条消息",不依赖
   本地缓存游标)。前者在_maxSeq被上述预览订阅污染后会查询"since:被污染的seq+1",
   服务端自然返回空;后者是绝对查询,对全新话题(_maxSeq为0,两者退化成同一个
   since:undefined查询)行为不变,对被污染的话题能正确要回完整历史。

第14+轮最小粒度测试 Ch3-4 发现(P2P单聊消息状态机场景)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-06 13:46:58 +08:00
co-authored by Claude Sonnet 5
parent 8087fd7e97
commit ddf23d40b2
+38 -3
View File
@@ -665,7 +665,7 @@ class MessagesView extends React.Component {
}
subscribe(topic) {
if (topic.isSubscribed() || !this.props.ready) {
if (!this.props.ready) {
return;
}
@@ -676,7 +676,24 @@ class MessagesView extends React.Component {
let getQuery = topic.startMetaQuery().withLaterDesc().withLaterSub().withAux();
if (this.state.isReader || newTopic) {
// Reading is either permitted or we don't know because it's a new topic. Ask for messages.
getQuery = getQuery.withLaterData(MESSAGES_PAGE);
//
// withData(undefined, undefined, MESSAGES_PAGE) instead of the seemingly-more-natural
// withLaterData(MESSAGES_PAGE): the latter asks the server for "whatever is newer than
// topic._maxSeq", which is only correct when _maxSeq reflects a real, contiguous, locally-cached
// history. But tinode-web.jsx's prefetchLatestMessagePreviews() (conversation list "last
// message" preview) may have already called topic.subscribe(startMetaQuery().withLaterData(1))
// for this exact topic the moment the chat list rendered -- often *before* this view ever mounts,
// e.g. the very first time a brand-new conversation is opened in a fresh session -- which sets
// _maxSeq to that one prefetched message's seq without caching anything before it. From that
// point on withLaterData(MESSAGES_PAGE) asks for "later than the one message I already have" and
// gets back nothing: confirmed live, a brand-new 4-message P2P conversation opened this way
// rendered only the single latest message, silently missing the rest of the history.
// withData(undefined, undefined, limit) has no such since-cursor -- it always asks for "the
// latest `limit` messages" regardless of what the client thinks it already has, which is
// correct both in this poisoned-prefetch case and in the plain fresh-topic case (there
// topic._maxSeq is 0, so withLaterData(limit) already degrades to the exact same
// since:undefined query -- this is a strict fix, not a behavior change for the common path).
getQuery = getQuery.withData(undefined, undefined, MESSAGES_PAGE);
if (this.state.isReader) {
getQuery = getQuery.withLaterDel();
}
@@ -685,7 +702,7 @@ class MessagesView extends React.Component {
}
const setQuery = newTopic ? this.props.newTopicParams : undefined;
topic.subscribe(getQuery.build(), setQuery)
const doSubscribe = () => topic.subscribe(getQuery.build(), setQuery)
.then(ctrl => {
if (ctrl.code == 303) {
// Redirect to another topic requested.
@@ -726,6 +743,24 @@ class MessagesView extends React.Component {
blankState.title = this.props.intl.formatMessage(messages.not_found);
this.setState(blankState);
});
if (topic.isSubscribed()) {
// Same prefetch race as the comment on the data query above: the topic may already be
// "attached" server-side from prefetchLatestMessagePreviews' single-message subscribe, before
// this view ever asked for anything. The SDK's own Topic.subscribe() unconditionally
// short-circuits to `Promise.resolve(this)` once `_attached` is true (tinode.dev.js)
// *without even looking at the getParams passed in* -- calling topic.subscribe(getQuery...)
// directly in that state would silently no-op and this view would never receive the real
// history/pinned-messages/peer read-receipts it just asked for. leave(false) clears _attached
// (both client-side via _resetSub() and server-side, since it's a real round trip) without
// discarding already-cached messages (that only happens for leave(true), i.e. unsubscribe) --
// once it resolves, the follow-up doSubscribe() is guaranteed to be a genuine, un-shortcut
// subscribe that gets the server to actually answer getQuery instead of silently dropping it.
topic.leave(false).catch(_ => { /* best-effort; doSubscribe() below still needs to run */ }).then(doSubscribe);
return;
}
doSubscribe();
}
leave(oldTopicName) {