From 031df3930d84da949a6a61c6822d2e103cbc0874 Mon Sep 17 00:00:00 2001 From: "John R. Enders" <92937551+JohnRichardEnders@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:12:18 +0200 Subject: [PATCH] fix(desktop): hydrate notification replies before selecting threads Signed-off-by: John R. Enders <92937551+JohnRichardEnders@users.noreply.github.com> --- desktop/src/app/routes/ChannelRouteScreen.tsx | 27 +++----- desktop/tests/e2e/navigation.spec.ts | 65 +++++++++++++++++++ 2 files changed, 74 insertions(+), 18 deletions(-) diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index 50371bc369f..5463fa8fcfb 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -161,10 +161,7 @@ export function ChannelRouteScreen({ enumeratedProjectHome ?? projectHomeLookupQuery.data ?? null; const [targetMessageEvents, setTargetMessageEvents] = React.useState< RelayEvent[] - >(() => { - const cachedTarget = getCachedSearchHitEvent(targetMessageId); - return cachedTarget ? [cachedTarget] : []; - }); + >([]); const [activeSearchHighlight, setActiveSearchHighlight] = React.useState(searchHighlight ?? null); const appliedSearchActivationIdRef = React.useRef( @@ -211,13 +208,8 @@ export function ChannelRouteScreen({ targetReplyId, targetThreadRootId, ]); - - // Reset spliced target events when the channel changes. Tied to channel - // identity rather than the route target so clearing the `messageId` param - // mid-channel keeps the deep-linked row in view. Seeded with the mount key so - // the initial cache-seeded events survive first commit; only a genuine - // channel change clears them. Declared before the fetch effect so a channel - // switch clears stale events before the new target is fetched. + // Keep spliced events after the route target clears so a deep-linked row + // remains visible, but discard them when switching channels. const previousResetKeyRef = React.useRef(channelId); React.useEffect(() => { if (previousResetKeyRef.current === channelId) return; @@ -243,13 +235,9 @@ export function ChannelRouteScreen({ } const cachedTarget = getCachedSearchHitEvent(targetMessageId); - if (cachedTarget) { - setTargetMessageEvents((currentEvents) => - currentEvents.some((event) => event.id === cachedTarget.id) - ? currentEvents - : [...currentEvents, cachedTarget], - ); - } + // Search/notification projections have no reply tags. Inserting one before + // hydration can select the reply as a root and consume the route target. + // Retain it only as a fallback after the authoritative lookup completes. const eventIds = [ targetMessageId, @@ -269,6 +257,9 @@ export function ChannelRouteScreen({ for (const event of [...currentEvents, ...events]) { eventsById.set(event.id, event); } + if (cachedTarget && !eventsById.has(cachedTarget.id)) { + eventsById.set(cachedTarget.id, cachedTarget); + } return Array.from(eventsById.values()); }); } diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index d19849ea17c..ba8867bfb06 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -1058,3 +1058,68 @@ test("cold-start message deep link preserves its thread target", async ({ await expect(page).toHaveURL(/messageId=mock-forum-release-reply/); await expect(page).toHaveURL(/threadRootId=mock-forum-release-thread/); }); + +test("reply notification hydration opens the full thread instead of the cached reply", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("message-input")).toBeVisible(); + const targetId = await page.evaluate(() => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock message fixture unavailable"); + const input = { + channelName: "engineering", + pubkey: + "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f", + }; + const root = emit({ ...input, content: "Notification context root" }); + emit({ + ...input, + parentEventId: root.id, + content: "Sibling reply retains context", + }); + const parent = emit({ + ...input, + parentEventId: root.id, + content: "Parent reply retains context", + }); + const target = emit({ + ...input, + parentEventId: parent.id, + content: "Nested notification reply", + }); + window.__BUZZ_E2E_DEFER_GET_EVENT__ = target.id; + window.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?.({ + category: "mention", + channel_id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9", + channel_name: "engineering", + content: target.content, + created_at: Math.floor(Date.now() / 1000) + 5, + id: target.id, + kind: target.kind, + pubkey: target.pubkey, + tags: target.tags.concat([["p", "deadbeef".repeat(8)]]), + }); + return target.id; + }); + await expect + .poll(() => + page.evaluate(() => window.__BUZZ_E2E_NOTIFICATIONS__?.length ?? 0), + ) + .toBe(1); + await page.evaluate(() => window.__BUZZ_E2E_CLICK_NOTIFICATION__?.(0)); + await expect(page.getByTestId("chat-title")).toHaveText("engineering"); + // Hold the authoritative lookup until navigation has committed. The cached + // notification has no reply tags and must not become the selected thread. + await page.evaluate(() => window.__BUZZ_E2E_RELEASE_GET_EVENT__?.()); + const thread = page.getByTestId("message-thread-panel"); + await expect(thread.getByTestId("message-thread-head")).toContainText( + "Notification context root", + ); + await expect(thread).toContainText("Sibling reply retains context"); + await expect(thread).toContainText("Parent reply retains context"); + await expect(thread.locator(`[data-message-id="${targetId}"]`)).toContainText( + "Nested notification reply", + ); +});