From ddf23d40b258187adcae95349f484ecd45fe31c3 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 6 Sep 2026 13:46:58 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=96=B0=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E9=A6=96=E6=AC=A1=E6=89=93=E5=BC=80=E6=97=B6=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E8=A2=AB=E9=9D=99=E9=BB=98=E6=88=AA=E6=96=AD?= =?UTF-8?q?=E4=B8=BA1=E6=9D=A1=E3=80=81=E5=AF=B9=E6=96=B9=E5=B7=B2?= =?UTF-8?q?=E8=AF=BB=E5=9B=9E=E6=89=A7=E6=B0=B8=E4=B8=8D=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因: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 --- src/views/messages-view.jsx | 41 ++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/src/views/messages-view.jsx b/src/views/messages-view.jsx index 2fc40dde..d4b919dd 100644 --- a/src/views/messages-view.jsx +++ b/src/views/messages-view.jsx @@ -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) {