From 6b7dc42001c7dd52660a661df847881b4e55d3e6 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Tue, 28 Jul 2026 11:27:06 -0400 Subject: [PATCH] fix(desktop): paint own thread replies optimistically instead of waiting for the relay echo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thread pane renders the thread-replies query cache, whose only live writer was the relay's WebSocket echo (post-commit, fire-and-forget on the relay). useSendMessageMutation optimistically painted the channel window but never threadRepliesKey(channelId, rootId), so a user's own thread reply waited a full round-trip plus fanout lag to appear while the main pane painted instantly — the thread pane absorbed 100% of echo lag. Mirror the optimistic lifecycle into the thread cache: - onMutate inserts the pending reply under the resolved thread root (insertPendingThreadReply). Root resolution now searches cached thread subtrees too (collectReplyParentContext) — a nested reply's parent lives in the thread cache, not the channel timeline, and the old channel-only lookup would mis-root it. - onSuccess swaps pending → confirmed under the same localKey (reconcileSentThreadReply), so the row never remounts and a WS echo racing ahead of the REST response cannot duplicate (isMatchingPendingMessage already reconciles the echo in place). - onError removes only the failed pending entry (rollbackPendingThreadReply) — a snapshot restore would drop live replies that arrived during the in-flight send. - useThreadReplies' refetch snapshot now also preserves still-pending optimistic sends, which the server cannot return yet. The e2e mock relay previously echoed sends synchronously inside the send command — exactly why no test ever caught this. Add a sendEchoDelayMs knob that defers the echo like the real relay's post-commit fanout, plus a regression spec (proven red pre-fix) that asserts own-sends paint before the echo, the late echo reconciles without duplicating, and a thread switch mid-send never bleeds the pending reply into the wrong thread. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- desktop/playwright.config.ts | 1 + desktop/src/features/messages/hooks.ts | 145 +++++++++- .../messages/threadOptimisticReply.test.mjs | 249 ++++++++++++++++++ .../src/features/messages/useThreadReplies.ts | 6 +- desktop/src/testing/e2eBridge.ts | 25 +- .../e2e/thread-own-send-liveness.spec.ts | 135 ++++++++++ desktop/tests/helpers/bridge.ts | 3 + 7 files changed, 555 insertions(+), 9 deletions(-) create mode 100644 desktop/src/features/messages/threadOptimisticReply.test.mjs create mode 100644 desktop/tests/e2e/thread-own-send-liveness.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 40dd5dd1b1..da69b98c65 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -75,6 +75,7 @@ export default defineConfig({ "**/home-collapsed-top-chrome.spec.ts", "**/top-chrome-zoom-clearance.spec.ts", "**/thread-unread.spec.ts", + "**/thread-own-send-liveness.spec.ts", "**/workspace-rail.spec.ts", "**/community-rail.spec.ts", "**/boot-splash.spec.ts", diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 062b0ee40b..a6ea8fd28e 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -1,5 +1,10 @@ import { useEffect, useEffectEvent } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + type QueryClient, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { toast } from "sonner"; import { @@ -74,6 +79,9 @@ type MessageQueryContext = { previousWindow: ChannelWindowStore | undefined; channelId: string; queryKey: ReturnType; + /** Set only for thread replies: the thread-replies cache the optimistic + * message was inserted into, so onSuccess/onError can reconcile it. */ + threadKey?: ReturnType; }; const CHANNEL_TIMELINE_KINDS = new Set(CHANNEL_TIMELINE_CONTENT_KINDS); @@ -171,6 +179,102 @@ export function resolveSendChannel( ); } +/** + * Every cached event a reply's parent could live in: the flattened channel + * timeline plus each cached thread subtree for the channel. Thread replies + * are not timeline rows, so resolving a nested reply's root against the + * channel cache alone never finds the parent and silently mis-roots the + * reply at its parent id. + * + * Exported for unit testing. + */ +export function collectReplyParentContext( + queryClient: QueryClient, + channelId: string, +): RelayEvent[] { + const channelMessages = + queryClient.getQueryData(channelMessagesKey(channelId)) ?? []; + const threadCaches = queryClient.getQueriesData({ + queryKey: ["thread-replies", channelId], + }); + return [ + ...channelMessages, + ...threadCaches.flatMap(([, events]) => events ?? []), + ]; +} + +/** + * The echo-chamber fix (thread-pane liveness): the thread pane renders + * `threadRepliesKey(channelId, rootId)`, whose only live writer used to be + * the relay's WebSocket echo (`useChannelSubscription.appendMessage`). Your + * own reply therefore waited a full round-trip plus the relay's post-commit + * fanout before painting, while the channel pane painted optimistically — + * the thread pane absorbed 100% of echo lag. These three helpers mirror the + * channel window's optimistic lifecycle into the thread cache: + * + * onMutate → insertPendingThreadReply + * onSuccess → reconcileSentThreadReply + * onError → rollbackPendingThreadReply + * + * All transforms are id-stable: the pending entry's localKey survives + * reconciliation (here explicitly; in the echo path via + * `isMatchingPendingMessage`), so the rendered row never remounts mid-send. + * + * Exported for unit testing. + */ +export function insertPendingThreadReply( + queryClient: QueryClient, + channelId: string, + pendingMessage: RelayEvent, +): ReturnType | undefined { + const reference = getThreadReference(pendingMessage.tags); + if (reference.parentId == null || reference.rootId == null) { + return undefined; + } + const threadKey = threadRepliesKey(channelId, reference.rootId); + queryClient.setQueryData(threadKey, (current = []) => + mergeMessages(current, pendingMessage), + ); + return threadKey; +} + +/** See {@link insertPendingThreadReply}. Exported for unit testing. */ +export function reconcileSentThreadReply( + queryClient: QueryClient, + threadKey: ReturnType, + optimisticId: string, + message: RelayEvent, +): void { + // Drop the pending entry (a no-op when the WS echo already replaced it) + // and merge the confirmed event under the same localKey so the row's + // render identity is stable across the swap. mergeMessages dedupes by id, + // so an echo that raced ahead of the REST response cannot duplicate. + queryClient.setQueryData(threadKey, (current = []) => + mergeMessages( + current.filter((event) => event.id !== optimisticId), + { ...message, localKey: optimisticId }, + ), + ); +} + +/** + * See {@link insertPendingThreadReply}. Unlike the channel caches, the + * rollback is a targeted filter rather than a snapshot restore: restoring a + * pre-send snapshot would silently drop live replies that arrived over the + * subscription while the failed send was in flight. + * + * Exported for unit testing. + */ +export function rollbackPendingThreadReply( + queryClient: QueryClient, + threadKey: ReturnType, + optimisticId: string, +): void { + queryClient.setQueryData(threadKey, (current = []) => + current.filter((event) => event.id !== optimisticId), + ); +} + /** * Resolves the thread reply target from a submit-time captured context or, * for callers that predate the capture pattern, from live refs. @@ -469,10 +573,10 @@ export function useSendMessageMutation( // the relay's tag validation runs. The WebSocket path emits no extra // tags, so emoji-only messages would otherwise lose their emoji tag. if (parentEventId || imetaTags.length > 0 || emojiTags.length > 0) { - const cachedMessages = - queryClient.getQueryData( - channelMessagesKey(effectiveChannel.id), - ) ?? []; + const cachedMessages = collectReplyParentContext( + queryClient, + effectiveChannel.id, + ); const result = await sendChannelMessage( effectiveChannel.id, content, @@ -570,7 +674,10 @@ export function useSendMessageMutation( effectiveChannel.id, content.trim(), identity, - previousMessages, + // Include cached thread subtrees: a nested reply's parent lives in + // the thread cache, not the channel timeline, and mis-resolving the + // root here would strand the optimistic insert under the wrong key. + collectReplyParentContext(queryClient, effectiveChannel.id), mentionPubkeys ?? [], parentEventId ?? null, mediaTags ?? [], @@ -583,12 +690,23 @@ export function useSendMessageMutation( queryClient.setQueryData(windowKey, nextWindow); projectChannelWindowMessages(queryClient, effectiveChannel.id); + // Thread replies also render from the thread-replies cache, whose only + // other writer is the relay's WS echo — without this insert the thread + // pane waits a full round-trip to paint your own send (the + // echo-chamber bug). + const threadKey = insertPendingThreadReply( + queryClient, + effectiveChannel.id, + optimisticMessage, + ); + return { optimisticId: optimisticMessage.id, previousMessages, previousWindow, channelId: effectiveChannel.id, queryKey, + threadKey, }; }, onError: (error, _variables, context) => { @@ -605,6 +723,13 @@ export function useSendMessageMutation( channelWindowKey(context.channelId), context.previousWindow, ); + if (context.threadKey) { + rollbackPendingThreadReply( + queryClient, + context.threadKey, + context.optimisticId, + ); + } }, onSuccess: (message, _variables, context) => { // An accepted send proves the write-block is lifted; clear any recorded @@ -630,6 +755,14 @@ export function useSendMessageMutation( }); queryClient.setQueryData(windowKey, next); projectChannelWindowMessages(queryClient, context.channelId); + if (context.threadKey) { + reconcileSentThreadReply( + queryClient, + context.threadKey, + context.optimisticId, + message, + ); + } }, }); } diff --git a/desktop/src/features/messages/threadOptimisticReply.test.mjs b/desktop/src/features/messages/threadOptimisticReply.test.mjs new file mode 100644 index 0000000000..3eb4958610 --- /dev/null +++ b/desktop/src/features/messages/threadOptimisticReply.test.mjs @@ -0,0 +1,249 @@ +/** + * Regression tests for the thread-pane echo-chamber bug. + * + * The bug: `useSendMessageMutation` optimistically painted only the channel + * window, never `threadRepliesKey(channelId, rootId)` — the cache the thread + * pane renders. The only live writer into that cache was the relay's + * WebSocket echo (post-commit, fire-and-forget on the relay), so your own + * thread reply waited a full round-trip plus fanout lag to appear while the + * main pane painted instantly. + * + * Test coverage: + * 1. insertPendingThreadReply lands the pending reply in the thread cache + * keyed by the resolved root (direct reply: root === parent). + * 2. Top-level sends (no thread reference) leave every thread cache alone. + * 3. Nested replies whose parent lives only in the thread cache still + * resolve the true root (collectReplyParentContext), instead of + * mis-rooting at the parent id. + * 4. reconcileSentThreadReply swaps pending → confirmed under the same + * localKey so the rendered row never remounts. + * 5. A WS echo that races ahead of the REST response does not duplicate: + * the echo replaces the pending entry, and reconciliation stays + * single-entry. + * 6. rollbackPendingThreadReply removes only the failed pending entry, + * keeping live replies that arrived while the send was in flight. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { QueryClient } from "@tanstack/react-query"; + +import { + collectReplyParentContext, + createOptimisticMessage, + insertPendingThreadReply, + reconcileSentThreadReply, + rollbackPendingThreadReply, +} from "./hooks.ts"; +import { mergeMessages } from "./lib/messageMerge.ts"; +import { + channelMessagesKey, + threadRepliesKey, +} from "./lib/messageQueryKeys.ts"; + +const CHANNEL_ID = "channel-1"; +const IDENTITY = { + pubkey: "aaaa1111bbbb2222cccc3333dddd4444eeee5555ffff6666aaaa1111bbbb2222", +}; + +function makeEvent({ id, parentId, rootId, content, pubkey, createdAt }) { + const tags = [ + ["p", pubkey ?? IDENTITY.pubkey], + ["h", CHANNEL_ID], + ]; + if (parentId) { + if (rootId && rootId !== parentId) { + tags.push(["e", rootId, "", "root"]); + tags.push(["e", parentId, "", "reply"]); + } else { + tags.push(["e", parentId, "", "reply"]); + } + } + return { + id, + pubkey: pubkey ?? IDENTITY.pubkey, + created_at: createdAt ?? 1_700_000_000, + kind: 9, + tags, + content, + sig: "", + }; +} + +function makePendingReply(queryClient, parentId, content = "pending reply") { + return createOptimisticMessage( + CHANNEL_ID, + content, + IDENTITY, + collectReplyParentContext(queryClient, CHANNEL_ID), + [], + parentId, + [], + ); +} + +// --------------------------------------------------------------------------- +// 1. Pending insert lands in the thread cache under the resolved root +// --------------------------------------------------------------------------- + +test("insertPendingThreadReply_directReplyToRoot_insertsPendingUnderRootKey", () => { + const queryClient = new QueryClient(); + const root = makeEvent({ id: "root-1", content: "thread root" }); + queryClient.setQueryData(channelMessagesKey(CHANNEL_ID), [root]); + + const pending = makePendingReply(queryClient, "root-1"); + const threadKey = insertPendingThreadReply(queryClient, CHANNEL_ID, pending); + + assert.deepEqual(threadKey, threadRepliesKey(CHANNEL_ID, "root-1")); + const cached = queryClient.getQueryData(threadKey); + assert.equal(cached.length, 1); + assert.equal(cached[0].id, pending.id); + assert.equal(cached[0].pending, true); +}); + +// --------------------------------------------------------------------------- +// 2. Top-level sends never touch thread caches +// --------------------------------------------------------------------------- + +test("insertPendingThreadReply_topLevelMessage_returnsUndefinedWritesNothing", () => { + const queryClient = new QueryClient(); + const pending = makePendingReply(queryClient, null, "top-level post"); + + const threadKey = insertPendingThreadReply(queryClient, CHANNEL_ID, pending); + + assert.equal(threadKey, undefined); + assert.deepEqual( + queryClient.getQueriesData({ queryKey: ["thread-replies"] }), + [], + ); +}); + +// --------------------------------------------------------------------------- +// 3. Nested reply: parent only in the thread cache still resolves true root +// --------------------------------------------------------------------------- + +test("insertPendingThreadReply_nestedReplyParentOnlyInThreadCache_usesTrueRoot", () => { + const queryClient = new QueryClient(); + const root = makeEvent({ id: "root-1", content: "thread root" }); + const nestedParent = makeEvent({ + id: "reply-1", + parentId: "root-1", + rootId: "root-1", + content: "existing reply", + }); + // The nested parent is a thread reply — it lives in the thread cache, not + // the channel timeline. + queryClient.setQueryData(channelMessagesKey(CHANNEL_ID), [root]); + queryClient.setQueryData(threadRepliesKey(CHANNEL_ID, "root-1"), [ + nestedParent, + ]); + + const pending = makePendingReply(queryClient, "reply-1", "nested reply"); + const threadKey = insertPendingThreadReply(queryClient, CHANNEL_ID, pending); + + // Without collectReplyParentContext the root would mis-resolve to + // "reply-1" and the optimistic insert would strand under the wrong key. + assert.deepEqual(threadKey, threadRepliesKey(CHANNEL_ID, "root-1")); + const cached = queryClient.getQueryData(threadKey); + assert.equal(cached.length, 2); + assert.ok(cached.some((event) => event.id === pending.id)); +}); + +// --------------------------------------------------------------------------- +// 4. Success reconciliation: pending → confirmed, render identity stable +// --------------------------------------------------------------------------- + +test("reconcileSentThreadReply_swapsPendingForConfirmedKeepingLocalKey", () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(channelMessagesKey(CHANNEL_ID), [ + makeEvent({ id: "root-1", content: "thread root" }), + ]); + const pending = makePendingReply(queryClient, "root-1"); + const threadKey = insertPendingThreadReply(queryClient, CHANNEL_ID, pending); + + const confirmed = makeEvent({ + id: "real-1", + parentId: "root-1", + content: "pending reply", + }); + reconcileSentThreadReply(queryClient, threadKey, pending.id, confirmed); + + const cached = queryClient.getQueryData(threadKey); + assert.equal(cached.length, 1); + assert.equal(cached[0].id, "real-1"); + assert.equal(cached[0].pending ?? false, false); + assert.equal( + cached[0].localKey, + pending.id, + "confirmed event must keep the pending localKey so the row never remounts", + ); +}); + +// --------------------------------------------------------------------------- +// 5. WS echo racing ahead of the REST response cannot duplicate +// --------------------------------------------------------------------------- + +test("reconcileSentThreadReply_echoArrivedFirst_staysSingleEntry", () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(channelMessagesKey(CHANNEL_ID), [ + makeEvent({ id: "root-1", content: "thread root" }), + ]); + const pending = makePendingReply(queryClient, "root-1"); + const threadKey = insertPendingThreadReply(queryClient, CHANNEL_ID, pending); + + // The live subscription echo lands before onSuccess runs. mergeMessages + // must recognize the pending entry (content/kind/pubkey/thread refs) and + // replace it in place. + const echo = makeEvent({ + id: "real-1", + parentId: "root-1", + content: "pending reply", + }); + queryClient.setQueryData( + threadKey, + mergeMessages(queryClient.getQueryData(threadKey), echo), + ); + const afterEcho = queryClient.getQueryData(threadKey); + assert.equal(afterEcho.length, 1); + assert.equal(afterEcho[0].id, "real-1"); + assert.equal(afterEcho[0].localKey, pending.id); + + // onSuccess now reconciles the same confirmed event — still one entry. + reconcileSentThreadReply(queryClient, threadKey, pending.id, echo); + const cached = queryClient.getQueryData(threadKey); + assert.equal(cached.length, 1); + assert.equal(cached[0].id, "real-1"); + assert.equal(cached[0].localKey, pending.id); +}); + +// --------------------------------------------------------------------------- +// 6. Error rollback: failed pending goes, in-flight live replies stay +// --------------------------------------------------------------------------- + +test("rollbackPendingThreadReply_removesPendingKeepsInFlightLiveReplies", () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(channelMessagesKey(CHANNEL_ID), [ + makeEvent({ id: "root-1", content: "thread root" }), + ]); + const pending = makePendingReply(queryClient, "root-1"); + const threadKey = insertPendingThreadReply(queryClient, CHANNEL_ID, pending); + + // Someone else's reply arrives over the live subscription mid-send. + const liveReply = makeEvent({ + id: "other-1", + parentId: "root-1", + content: "someone else's reply", + pubkey: "bbbb1111bbbb2222cccc3333dddd4444eeee5555ffff6666aaaa1111bbbb2222", + }); + queryClient.setQueryData( + threadKey, + mergeMessages(queryClient.getQueryData(threadKey), liveReply), + ); + + rollbackPendingThreadReply(queryClient, threadKey, pending.id); + + const cached = queryClient.getQueryData(threadKey); + assert.equal(cached.length, 1); + assert.equal(cached[0].id, "other-1"); +}); diff --git a/desktop/src/features/messages/useThreadReplies.ts b/desktop/src/features/messages/useThreadReplies.ts index 813fdc6b8b..81d78c8ab7 100644 --- a/desktop/src/features/messages/useThreadReplies.ts +++ b/desktop/src/features/messages/useThreadReplies.ts @@ -107,8 +107,12 @@ export function useThreadReplies( ); const current = queryClient.getQueryData(queryKey) ?? []; + // Keep events that arrived over the live subscription while this + // fetch was in flight, and any still-pending optimistic sends: the + // server cannot return an unconfirmed send, so dropping it here + // would flash-remove the user's own reply until the relay echo. const receivedInFlight = current.filter( - (event) => !idsAtStart.has(event.id), + (event) => event.pending || !idsAtStart.has(event.id), ); return sortMessages([...fetched, ...receivedInFlight]); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 03c05fe877..1ee7f0c82f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -253,6 +253,12 @@ type E2eConfig = { applyCommunityDelayMs?: number; openDmDelayMs?: number; sendMessageDelayMs?: number; + /** Delay (ms) before the mock relay echoes a sent kind-9 back over the + * live subscription. The real relay's fanout is post-commit and + * fire-and-forget, so the echo always trails the send ack; the mock's + * default synchronous echo masks any UI that (incorrectly) waits for + * it — the thread-pane echo-chamber bug. 0/undefined = synchronous. */ + sendEchoDelayMs?: number; /** Close the first channel-window live REQ; its retry is accepted. */ closeChannelLiveSubscriptionOnce?: boolean; /** Reject successive kind-9 sends with these messages, then resume. */ @@ -8254,6 +8260,21 @@ async function handleSendChannelMessage( window.setTimeout(resolve, sendMessageDelayMs), ); } + // Mirror the real relay's post-commit fanout: the live-subscription echo + // trails the send ack. Synchronous echo (delay 0) is the legacy default, + // but note it hides own-send liveness bugs — the UI must not depend on the + // echo to paint a send it already knows about. + const sendEchoDelayMs = config?.mock?.sendEchoDelayMs ?? 0; + const echoLiveEvent = (channelId: string, event: RelayEvent) => { + if (sendEchoDelayMs > 0) { + window.setTimeout( + () => emitMockLiveEvent(channelId, event), + sendEchoDelayMs, + ); + return; + } + emitMockLiveEvent(channelId, event); + }; // NIP-92 imeta attachments. The real relay echoes these back on the stored // event; mirror that here so attachment renderers (FileCard, images, video) @@ -8280,7 +8301,7 @@ async function handleSendChannelMessage( ...extraTags, ]); recordMockMessage(args.channelId, event); - emitMockLiveEvent(args.channelId, event); + echoLiveEvent(args.channelId, event); return { event_id: event.id, @@ -8343,7 +8364,7 @@ async function handleSendChannelMessage( }; recordMockMessage(args.channelId, event); - emitMockLiveEvent(args.channelId, event); + echoLiveEvent(args.channelId, event); return { event_id: event.id, diff --git a/desktop/tests/e2e/thread-own-send-liveness.spec.ts b/desktop/tests/e2e/thread-own-send-liveness.spec.ts new file mode 100644 index 0000000000..430644c483 --- /dev/null +++ b/desktop/tests/e2e/thread-own-send-liveness.spec.ts @@ -0,0 +1,135 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +/** + * Regression tests for the thread-pane echo-chamber bug. + * + * The thread pane renders the thread-replies query cache, whose only live + * writer used to be the relay's WebSocket echo. The send mutation painted the + * channel window optimistically but never the thread cache, so your own + * thread reply waited a full round-trip plus the relay's post-commit fanout + * before appearing — the thread pane absorbed 100% of echo lag while the + * main pane painted instantly. + * + * These tests delay the mock relay echo (`sendEchoDelayMs`). The previous + * mock emitted the echo synchronously inside the send command, which is + * exactly why no test ever caught this: any UI that incorrectly waited for + * the echo still painted "immediately" under test. + */ + +// The echo lags far beyond the paint deadline; a paint before the deadline +// can only have come from the optimistic cache insert, not the echo. +const ECHO_DELAY_MS = 8_000; +const PAINT_DEADLINE_MS = 2_000; + +async function openThreadForMessage( + page: import("@playwright/test").Page, + content: string, +) { + const row = page + .getByTestId("message-timeline") + .getByTestId("message-row") + .filter({ hasText: content }) + .first(); + await row.hover(); + await row.getByRole("button", { name: "Reply" }).click(); + await expect(page.getByTestId("message-thread-panel")).toBeVisible(); +} + +test("own thread reply paints before the relay echo arrives", async ({ + page, +}) => { + await installMockBridge(page, { sendEchoDelayMs: ECHO_DELAY_MS }); + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect(page.getByTestId("message-timeline")).toContainText( + "Welcome to #general", + ); + + await openThreadForMessage(page, "Welcome to #general"); + const threadPanel = page.getByTestId("message-thread-panel"); + const threadReplies = threadPanel.getByTestId("message-thread-replies"); + + const reply = `Echo-chamber regression ${Date.now()}`; + await threadPanel.locator('[data-testid="message-input"]').fill(reply); + await threadPanel.getByTestId("send-message").click(); + + // Pre-fix this waited the full ECHO_DELAY_MS; the optimistic insert must + // paint the reply well before the echo can possibly arrive. + await expect(threadReplies).toContainText(reply, { + timeout: PAINT_DEADLINE_MS, + }); + + // The late echo must reconcile with the optimistic entry, not duplicate it. + await page.waitForTimeout(ECHO_DELAY_MS + 500); + await expect( + threadReplies.getByTestId("message-row").filter({ hasText: reply }), + ).toHaveCount(1); +}); + +test("reply sent while switching threads lands only in its own thread", async ({ + page, +}) => { + // Keep the REST send itself slow too, so the thread switch happens while + // the mutation is still in flight — the optimistic insert must be keyed by + // the compose-time thread root, not whichever pane is open. + await installMockBridge(page, { + sendEchoDelayMs: ECHO_DELAY_MS, + sendMessageDelayMs: 1_000, + }); + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect(page.getByTestId("message-timeline")).toContainText( + "Welcome to #general", + ); + + // Seed a second root so there are two distinct threads to flip between. + const secondRoot = `Second thread root ${Date.now()}`; + await expect + .poll(() => + page.evaluate( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ), + ) + .toBe(true); + await page.evaluate((content) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content, + }); + }, secondRoot); + await expect(page.getByTestId("message-timeline")).toContainText(secondRoot); + + await openThreadForMessage(page, "Welcome to #general"); + const threadPanel = page.getByTestId("message-thread-panel"); + const threadReplies = threadPanel.getByTestId("message-thread-replies"); + + const reply = `Thread-switch mid-send ${Date.now()}`; + await threadPanel.locator('[data-testid="message-input"]').fill(reply); + await threadPanel.getByTestId("send-message").click(); + + // Switch to the other thread while the send is still in flight. + await openThreadForMessage(page, secondRoot); + await expect(threadPanel.getByTestId("message-thread-head")).toContainText( + secondRoot, + ); + // The pending reply belongs to the first thread's cache; it must never + // bleed into this one — poll across the in-flight window to catch it. + for (let check = 0; check < 4; check += 1) { + await page.waitForTimeout(400); + await expect(threadReplies).not.toContainText(reply); + } + + // Back on the original thread the reply is there, exactly once. + await openThreadForMessage(page, "Welcome to #general"); + await expect(threadReplies).toContainText(reply); + await page.waitForTimeout(ECHO_DELAY_MS); + await expect( + threadReplies.getByTestId("message-row").filter({ hasText: reply }), + ).toHaveCount(1); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index d65a260f30..4f977bbe8f 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -246,6 +246,9 @@ type MockBridgeOptions = { applyCommunityDelayMs?: number; openDmDelayMs?: number; sendMessageDelayMs?: number; + /** Delay (ms) before the mock relay echoes a sent kind-9 back over the + * live subscription; see e2eBridge mock config. */ + sendEchoDelayMs?: number; /** Close the first channel-window live REQ; its retry is accepted. */ closeChannelLiveSubscriptionOnce?: boolean; /** Reject successive kind-9 sends with these messages, then resume. */