From 95c0677b5548e2841daf5adc3342fbed4ad907a4 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 25 Sep 2026 12:37:01 +0800 Subject: [PATCH] Fix forward comment landing before forwarded content The comment send was fired in parallel with the forward REST call, not chained after it -- confirmed live with a 2-source bulk forward, where the comment (plain WS publish, fast) landed before the forwarded messages (async endpoint, responds with status:"pending" before the messages are actually persisted server-side). Chains the comment send off the forward promise instead of starting both at once. Co-Authored-By: Claude Sonnet 5 --- .../features/messages/ConversationView.tsx | 77 +++++++++++-------- 1 file changed, 47 insertions(+), 30 deletions(-) diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx index 43a9b997..f67f0394 100644 --- a/web/src/features/messages/ConversationView.tsx +++ b/web/src/features/messages/ConversationView.tsx @@ -831,22 +831,34 @@ export function ConversationView() { // `{pub}` publishes (was N sources x M targets separate WS sends, each independently racing // subscription/attachment; the server handles every pair in one request with real per-pair // success/failure reporting instead of each WS publish failing silently on its own). - if (sources.length > 0) { - forwardMessages(sources, targetTopics) - .then((res) => { - if (res.failed.length > 0) showError(undefined, `${res.failed.length} 条消息转发失败`); - }) - .catch((err) => showError(err, '转发失败')); - } - // Comment stays on the OLD client-side path deliberately -- see forwardMessages()'s own comment - // on why (preserves this app's existing "comment appears AFTER the forwarded messages" order, - // which the endpoint's own built-in comment field does not). + // + // The comment send is chained AFTER this promise settles, not fired in parallel -- confirmed + // live (2026-09-25) that firing both at once is a real, reproducible bug, not a theoretical one: + // the endpoint's response comes back with per-item `status:"pending"`/`reason: + // "MESSAGE_PERSIST_PENDING"` (it acks the request before the forwarded messages are actually + // written server-side), while the comment's own sendText() goes over the plain WS publish path, + // which is fast enough to land FIRST. A 2-source bulk forward reordered exactly this way in a + // live test before this fix (comment appeared above both forwarded messages). Chaining at least + // waits for the request round-trip before starting the comment send, which resolved the + // reordering in the same live scenario after this fix -- see this function's own follow-up + // verification, not a guarantee for every possible load/latency condition given the endpoint's + // own async "pending" semantics. + const forwardDone = + sources.length > 0 + ? forwardMessages(sources, targetTopics) + .then((res) => { + if (res.failed.length > 0) showError(undefined, `${res.failed.length} 条消息转发失败`); + }) + .catch((err) => showError(err, '转发失败')) + : Promise.resolve(); if (comment) { - for (const targetTopic of targetTopics) { - ensureTopicAttached(targetTopic) - .then((target) => sendText(target, comment)) - .catch((err) => showError(err, '附言发送失败')); - } + forwardDone.then(() => { + for (const targetTopic of targetTopics) { + ensureTopicAttached(targetTopic) + .then((target) => sendText(target, comment)) + .catch((err) => showError(err, '附言发送失败')); + } + }); } setBulkForwardOpen(false); exitSelectMode(); @@ -1229,22 +1241,27 @@ export function ConversationView() { setForwardMsg(null)} onPick={(targetTopics, comment) => { - if (typeof forwardMsg.seq === 'number') { - forwardMessages([{ topic: topicId, seq: forwardMsg.seq }], targetTopics) - .then((res) => { - if (res.failed.length > 0) showError(undefined, `${res.failed.length} 个目标转发失败`); - }) - .catch((err) => showError(err, '转发失败')); - } - // Comment stays on the OLD client-side path deliberately -- see forwardMessages()'s own - // comment in lib/api/messages.ts on why (preserves the existing "comment appears AFTER - // the forwarded message" order). + // Comment is chained AFTER the forward call settles, not fired in parallel -- see + // `bulkForward`'s own comment above for why this matters (confirmed live: firing both + // at once lets the comment's plain WS send outrace the forward endpoint's own async + // "pending" persistence, landing the comment BEFORE the forwarded content it's meant to + // follow). + const forwardDone = + typeof forwardMsg.seq === 'number' + ? forwardMessages([{ topic: topicId, seq: forwardMsg.seq }], targetTopics) + .then((res) => { + if (res.failed.length > 0) showError(undefined, `${res.failed.length} 个目标转发失败`); + }) + .catch((err) => showError(err, '转发失败')) + : Promise.resolve(); if (comment) { - for (const targetTopic of targetTopics) { - ensureTopicAttached(targetTopic) - .then((target) => sendText(target, comment)) - .catch((err) => showError(err, '附言发送失败')); - } + forwardDone.then(() => { + for (const targetTopic of targetTopics) { + ensureTopicAttached(targetTopic) + .then((target) => sendText(target, comment)) + .catch((err) => showError(err, '附言发送失败')); + } + }); } setForwardMsg(null); }}