From ace12f7b0764b71f58c3be3608faadc52adf6e29 Mon Sep 17 00:00:00 2001
From: Eva
 <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Date: Sun, 21 Jun 2026 09:22:09 -0400
Subject: [PATCH 1/7] desktop: regression test for old reaction outside history
 window
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

A reaction created before the content-kinds history window did not render
on cold-load: the pre-#1153 single-filter history fetch (all
CHANNEL_EVENT_KINDS under one limit) let reactions/deletions evict older
reactions from the window, so the GUI showed no reaction and re-reacting
hit the relay's "duplicate: reaction already exists" guard.

#1153 fixed this by fetching history with content kinds only and
backfilling reactions/edits/deletions by #e reference over the loaded
message ids — recency-independent. This pins that contract end-to-end:
a loaded message whose only reaction is outside the history window still
renders the reaction (attributed to the current user) after the #e
backfill. The aux filter must carry no since/until, or an old reaction
becomes unreachable again; the test asserts that too.

Fails if the aux backfill regains a recency cap or history starts
competing reactions against message-depth budget again.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
---
 .../messages/lib/auxBackfill.test.mjs         | 81 +++++++++++++++++++
 1 file changed, 81 insertions(+)

diff --git a/desktop/src/features/messages/lib/auxBackfill.test.mjs b/desktop/src/features/messages/lib/auxBackfill.test.mjs
index 18cfc5538f..9851a1c002 100644
--- a/desktop/src/features/messages/lib/auxBackfill.test.mjs
+++ b/desktop/src/features/messages/lib/auxBackfill.test.mjs
@@ -6,6 +6,8 @@ import {
   collectMessageIdsForAuxBackfill,
   mergeAuxEventsWithDeletionBackfill,
 } from "./auxBackfill.ts";
+import { formatTimelineMessages } from "./formatTimelineMessages.ts";
+import { buildChannelAuxFilter } from "@/shared/api/relayChannelFilters.ts";
 
 const CHANNEL_ID = "36411e44-0e2d-4cfe-bd6e-567eb169db9f";
 
@@ -126,3 +128,82 @@ test("merges deletion markers that target cached or fetched auxiliary event ids"
     [fetchedReactionId, cachedReactionDeletionId, fetchedReactionDeletionId],
   );
 });
+
+// Regression for the "duplicate: reaction already exists" report: a reaction
+// older than the content-kinds history window never rendered, because the old
+// single-filter history fetch (all CHANNEL_EVENT_KINDS under one `limit`) let
+// reactions/deletions evict older reactions from the window. The fix (#1153)
+// fetches history with content kinds only and backfills reactions by `#e`
+// reference over the loaded message ids — recency-independent. This test pins
+// that path end-to-end: a loaded message whose only reaction is NOT in the
+// history window still renders that reaction after the `#e` backfill.
+//
+// Would fail pre-#1153: the reaction was never fetched, so the message
+// rendered with no reactions and re-reacting hit the relay's duplicate guard.
+test("old reaction outside the history window is backfilled by #e and renders", async () => {
+  const messageId = hex("1");
+  const reactionId = hex("2");
+  const currentUser = hex("c");
+
+  // The cold-load history window: the message, but NOT its (older) reaction.
+  const history = [
+    event(messageId, 9, {
+      pubkey: hex("a"),
+      content: "ship it?",
+      created_at: 1_700_001_000,
+    }),
+  ];
+
+  // Step 1: backfill keys off the loaded content message ids.
+  const messageIds = collectMessageIdsForAuxBackfill(history);
+  assert.deepEqual(messageIds, [messageId]);
+
+  // Step 2: the aux filter references those ids by `#e`, with no time window —
+  // so an old reaction is reachable regardless of when it was created.
+  const auxFilter = buildChannelAuxFilter(CHANNEL_ID, messageIds);
+  assert.deepEqual(auxFilter["#e"], [messageId]);
+  assert.equal("since" in auxFilter, false);
+  assert.equal("until" in auxFilter, false);
+
+  // Step 3: the relay returns the old reaction for that `#e` filter. Its
+  // created_at predates the loaded message — the exact case the old window
+  // dropped.
+  const oldReaction = event(reactionId, 7, {
+    pubkey: currentUser,
+    content: "✅",
+    created_at: 1_700_000_000,
+    tags: [["e", messageId]],
+  });
+
+  const merged = await mergeAuxEventsWithDeletionBackfill({
+    channelId: CHANNEL_ID,
+    cachedEvents: history,
+    fetchedAuxEvents: [oldReaction],
+    // No deletions target the reaction.
+    fetchAuxEventsForMessages: async () => [],
+  });
+  assert.deepEqual(
+    merged.map((e) => e.id),
+    [reactionId],
+  );
+
+  // Step 4: the reaction merged into the timeline renders on its target
+  // message, attributed to the current user (so the UI shows it as already
+  // reacted and won't prompt a duplicate add).
+  const timeline = formatTimelineMessages(
+    [...history, ...merged],
+    null,
+    currentUser,
+    null,
+  );
+  const row = timeline.find((m) => m.id === messageId);
+  assert.ok(row, "loaded message should render a timeline row");
+  assert.deepEqual(
+    row.reactions?.map((r) => ({
+      emoji: r.emoji,
+      count: r.count,
+      mine: r.reactedByCurrentUser,
+    })),
+    [{ emoji: "✅", count: 1, mine: true }],
+  );
+});

From 87e7f3578c0020679eab4f1998c31f3190ef4868 Mon Sep 17 00:00:00 2001
From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
 <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Date: Sun, 21 Jun 2026 09:54:55 -0400
Subject: [PATCH 2/7] desktop: render the reactor's own reaction optimistically
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Clicking a reaction put the kind:7 on the relay but it never rendered:
the GUI showed nothing and re-clicking hit "duplicate: reaction already
exists". Root cause is a missing optimistic cache write, not a fetch
window or transient timeout.

useToggleReactionMutation fired the reaction and did nothing locally —
unlike useDeleteMessageMutation and useEditMessageMutation right below
it, which both apply the change to the channel cache on success (their
comments note the relay round-trip "can lag perceptibly"). For a
reaction the round-trip never arrives at all: kind:7 carries only an `e`
tag, no `h`, so the `#h`-scoped live subscription (buildChannelFilter)
misses it — reactions only enter the cache via the cold-load `#e` aux
backfill. So a freshly-clicked reaction stayed invisible until the next
channel switch, and re-clicking just re-hit the relay's duplicate guard.

Give the reaction mutation the same treatment as edit/delete: an
optimistic onMutate that synthesizes the kind:7 (target `e` tag, emoji
content, NIP-30 emoji tag for custom emoji, actor pubkey) and writes it
into the channel cache, with onError rollback. Render dedupes reactions
by target:actor:emoji, so when the genuine event later loads via
backfill it collapses onto the optimistic one — no double count. Removal
drops the matching optimistic (or backfilled) reaction.

Scoped via an optional `channelId` in the mutate variables: channel
surfaces (ChannelScreen, Home inbox) pass it; Pulse omits it and keeps
its own reaction cache, so a note is never counted in two caches.

createOptimisticReaction is a pure module so the contract is unit-tested
without React: optimistic reaction renders reactedByCurrentUser, the
real backfilled event collapses onto it without double-counting, and a
custom-emoji reaction carries its NIP-30 tag.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
---
 .../features/channels/ui/ChannelScreen.tsx    |   1 +
 .../channels/useChannelPaneHandlers.ts        |   6 +
 desktop/src/features/home/ui/HomeView.tsx     |   1 +
 desktop/src/features/messages/hooks.ts        |  74 ++++++++++++-
 .../messages/lib/optimisticReaction.test.mjs  | 103 ++++++++++++++++++
 .../messages/lib/optimisticReaction.ts        |  41 +++++++
 6 files changed, 222 insertions(+), 4 deletions(-)
 create mode 100644 desktop/src/features/messages/lib/optimisticReaction.test.mjs
 create mode 100644 desktop/src/features/messages/lib/optimisticReaction.ts

diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index 14ae4203ea..7eacfaa700 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -426,6 +426,7 @@ export function ChannelScreen({
     handleSelectThreadReplyTarget,
     handleToggleReaction,
   } = useChannelPaneHandlers({
+    channelId: activeChannelId,
     deleteMessageMutation,
     editMessageMutation,
     editTargetId,
diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts
index 5b4cdeb026..5cb825511f 100644
--- a/desktop/src/features/channels/useChannelPaneHandlers.ts
+++ b/desktop/src/features/channels/useChannelPaneHandlers.ts
@@ -18,6 +18,7 @@ import type {
  * rather than listing the whole mutation as a dependency.
  */
 export function useChannelPaneHandlers({
+  channelId,
   deleteMessageMutation,
   editMessageMutation,
   editTargetId,
@@ -37,6 +38,7 @@ export function useChannelPaneHandlers({
   threadReplyTargetId,
   toggleReactionMutation,
 }: {
+  channelId: string | null;
   deleteMessageMutation: ReturnType<typeof useDeleteMessageMutation>;
   editMessageMutation: ReturnType<typeof useEditMessageMutation>;
   editTargetId: string | null;
@@ -83,6 +85,9 @@ export function useChannelPaneHandlers({
   const toggleMutateRef = React.useRef(toggleReactionMutation.mutateAsync);
   toggleMutateRef.current = toggleReactionMutation.mutateAsync;
 
+  const channelIdRef = React.useRef(channelId);
+  channelIdRef.current = channelId;
+
   const handleCancelThreadReply = React.useCallback(() => {
     setThreadReplyTargetId(openThreadHeadIdRef.current);
   }, [setThreadReplyTargetId]);
@@ -290,6 +295,7 @@ export function useChannelPaneHandlers({
         emoji,
         eventId: message.id,
         remove,
+        channelId: channelIdRef.current ?? undefined,
       });
     },
     [],
diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx
index 8d79cc3c53..e448bd1efb 100644
--- a/desktop/src/features/home/ui/HomeView.tsx
+++ b/desktop/src/features/home/ui/HomeView.tsx
@@ -573,6 +573,7 @@ export function HomeView({
                       emoji,
                       eventId: message.id,
                       remove,
+                      channelId: selectedChannel?.id,
                     });
                     await channelMessagesQuery.refetch();
                     onRefresh();
diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts
index 4151b1b0b9..411d476cf5 100644
--- a/desktop/src/features/messages/hooks.ts
+++ b/desktop/src/features/messages/hooks.ts
@@ -32,15 +32,18 @@ import type { Channel, Identity, RelayEvent } from "@/shared/api/types";
 // from the on-render overlay.
 import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay.mjs";
 import { backfillAuxForMessages } from "@/features/messages/lib/auxBackfill";
+import { createOptimisticReaction } from "@/features/messages/lib/optimisticReaction";
 import { countTopLevelTimelineRows } from "@/features/messages/lib/formatTimelineMessages";
 import {
   MIN_TOP_LEVEL_ROWS_PER_FETCH,
   pageOlderMessagesUntilRowFloor,
 } from "@/features/messages/lib/pageOlderMessages";
 import {
+  KIND_REACTION,
   KIND_STREAM_MESSAGE,
   KIND_SYSTEM_MESSAGE,
 } from "@/shared/constants/kinds";
+import { useIdentityQuery } from "@/shared/api/hooks";
 
 type MessageQueryContext = {
   optimisticId: string;
@@ -491,6 +494,7 @@ export function useSendMessageMutation(
 
 export function useToggleReactionMutation() {
   const queryClient = useQueryClient();
+  const identityQuery = useIdentityQuery();
   return useMutation<
     void,
     Error,
@@ -498,23 +502,85 @@ export function useToggleReactionMutation() {
       eventId: string;
       emoji: string;
       remove: boolean;
-    }
+      // The channel whose timeline cache to optimistically update. Omit for
+      // surfaces that own their own reaction cache (e.g. Pulse notes), so they
+      // are not double-counted in two caches.
+      channelId?: string;
+    },
+    | {
+        queryKey: ReturnType<typeof channelMessagesKey>;
+        previous: RelayEvent[];
+      }
+    | undefined
   >({
+    // Reactions never round-trip back to the client (kind:7 carries only an
+    // `e` tag, so the `#h`-scoped live subscription misses them — they only
+    // arrive via the cold-load `#e` aux backfill). So apply the change to the
+    // channel cache ourselves, optimistically, or the reactor's own reaction
+    // stays invisible until the next channel switch. Mirrors the apply-on-
+    // success cache writes in the edit/delete mutations below.
+    onMutate: ({ eventId, emoji, remove, channelId }) => {
+      const pubkey = identityQuery.data?.pubkey;
+      if (!channelId || !pubkey) {
+        return undefined;
+      }
+
+      const queryKey = channelMessagesKey(channelId);
+      const previous = queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
+      const pubkeyLower = pubkey.toLowerCase();
+      const trimmedEmoji = emoji.trim();
+
+      const isOwnReactionForTarget = (event: RelayEvent) =>
+        event.kind === KIND_REACTION &&
+        event.pubkey.toLowerCase() === pubkeyLower &&
+        event.content.trim() === trimmedEmoji &&
+        event.tags.some((tag) => tag[0] === "e" && tag[1] === eventId);
+
+      if (remove) {
+        queryClient.setQueryData<RelayEvent[]>(queryKey, (current = []) =>
+          current.filter((event) => !isOwnReactionForTarget(event)),
+        );
+      } else {
+        // Custom-emoji reaction: emoji is `:shortcode:`. Resolve its image URL
+        // from the cached workspace palette so the optimistic kind:7 renders
+        // the right glyph. Unicode reactions resolve to no URL.
+        const emojiUrl = reactionEmojiUrl(
+          emoji,
+          queryClient.getQueryData<CustomEmoji[]>(customEmojiQueryKey),
+        );
+        const optimistic = createOptimisticReaction(
+          eventId,
+          emoji,
+          emojiUrl,
+          pubkey,
+        );
+        queryClient.setQueryData<RelayEvent[]>(queryKey, (current = []) =>
+          sortMessages([...current, optimistic]),
+        );
+      }
+
+      return { queryKey, previous };
+    },
     mutationFn: async ({ eventId, emoji, remove }) => {
       if (remove) {
         await removeReaction(eventId, emoji);
         return;
       }
 
-      // Custom-emoji reaction: emoji is `:shortcode:`. Resolve its image URL
-      // from the cached workspace palette so the kind:7 carries the NIP-30
-      // `["emoji", shortcode, url]` tag. Unicode reactions resolve to no URL.
       const emojiUrl = reactionEmojiUrl(
         emoji,
         queryClient.getQueryData<CustomEmoji[]>(customEmojiQueryKey),
       );
       await addReaction(eventId, emoji, emojiUrl);
     },
+    onError: (_error, _variables, context) => {
+      if (context) {
+        queryClient.setQueryData<RelayEvent[]>(
+          context.queryKey,
+          context.previous,
+        );
+      }
+    },
   });
 }
 
diff --git a/desktop/src/features/messages/lib/optimisticReaction.test.mjs b/desktop/src/features/messages/lib/optimisticReaction.test.mjs
new file mode 100644
index 0000000000..a974f31dbd
--- /dev/null
+++ b/desktop/src/features/messages/lib/optimisticReaction.test.mjs
@@ -0,0 +1,103 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { createOptimisticReaction } from "./optimisticReaction.ts";
+import { formatTimelineMessages } from "./formatTimelineMessages.ts";
+import { sortMessages } from "./messageQueryKeys.ts";
+import { KIND_REACTION } from "@/shared/constants/kinds";
+
+const CHANNEL_ID = "36411e44-0e2d-4cfe-bd6e-567eb169db9f";
+const MESSAGE_ID =
+  "08212d19af68b69e17ecfa4e2d4477a03f6aff70a3c3e2cf106b389a5a8f3515";
+const ME = "1111111111111111111111111111111111111111111111111111111111111111";
+
+function streamMessage() {
+  return {
+    id: MESSAGE_ID,
+    pubkey: "2222222222222222222222222222222222222222222222222222222222222222",
+    kind: 9,
+    created_at: 1_700_000_000,
+    content: "hello world",
+    tags: [["h", CHANNEL_ID]],
+    sig: "sig",
+  };
+}
+
+function render(events) {
+  return formatTimelineMessages(events, null, ME, null, {});
+}
+
+function reactionRow(rows) {
+  const row = rows.find((r) => r.id === MESSAGE_ID) ?? rows[0];
+  return row.reactions ?? [];
+}
+
+test("optimistic reaction renders the reactor's own reaction with no relay round-trip", () => {
+  // Reactions carry only an `e` tag, so the #h-scoped live subscription never
+  // delivers a freshly-clicked reaction back — the optimistic cache write is
+  // the only thing that makes the reactor's own ✅ appear. Without the fix the
+  // reaction is invisible and re-clicking hits the relay's duplicate guard.
+  const optimistic = createOptimisticReaction(MESSAGE_ID, "✅", undefined, ME);
+
+  assert.equal(optimistic.kind, KIND_REACTION);
+  assert.deepEqual(
+    optimistic.tags.find((t) => t[0] === "e"),
+    ["e", MESSAGE_ID],
+  );
+
+  const rows = render(sortMessages([streamMessage(), optimistic]));
+  const reactions = reactionRow(rows);
+
+  assert.equal(reactions.length, 1, "one reaction should render");
+  assert.equal(reactions[0].emoji, "✅");
+  assert.equal(reactions[0].count, 1);
+  assert.equal(
+    reactions[0].reactedByCurrentUser,
+    true,
+    "the optimistic reaction must be attributed to the current user",
+  );
+});
+
+test("the real backfilled reaction collapses onto the optimistic one — no double count", () => {
+  // When the genuine kind:7 later loads via the #e aux backfill it has a
+  // different event id, but render dedupes by target:actor:emoji, so the two
+  // must not stack into a count of 2.
+  const optimistic = createOptimisticReaction(MESSAGE_ID, "✅", undefined, ME);
+  const realReaction = {
+    id: "0f11e83b0f11e83b0f11e83b0f11e83b0f11e83b0f11e83b0f11e83b0f11e83b",
+    pubkey: ME,
+    kind: KIND_REACTION,
+    created_at: 1_700_000_005,
+    content: "✅",
+    tags: [["e", MESSAGE_ID]],
+    sig: "realsig",
+  };
+
+  const rows = render(
+    sortMessages([streamMessage(), optimistic, realReaction]),
+  );
+  const reactions = reactionRow(rows);
+
+  assert.equal(reactions.length, 1);
+  assert.equal(
+    reactions[0].count,
+    1,
+    "must not double-count optimistic + real",
+  );
+  assert.equal(reactions[0].reactedByCurrentUser, true);
+});
+
+test("custom-emoji optimistic reaction carries the NIP-30 emoji tag", () => {
+  const optimistic = createOptimisticReaction(
+    MESSAGE_ID,
+    ":party:",
+    "https://example.test/party.png",
+    ME,
+  );
+
+  assert.deepEqual(
+    optimistic.tags.find((t) => t[0] === "emoji"),
+    ["emoji", "party", "https://example.test/party.png"],
+  );
+  assert.equal(optimistic.content, ":party:");
+});
diff --git a/desktop/src/features/messages/lib/optimisticReaction.ts b/desktop/src/features/messages/lib/optimisticReaction.ts
new file mode 100644
index 0000000000..cedba5f150
--- /dev/null
+++ b/desktop/src/features/messages/lib/optimisticReaction.ts
@@ -0,0 +1,41 @@
+import type { RelayEvent } from "@/shared/api/types";
+import { KIND_REACTION } from "@/shared/constants/kinds";
+
+/**
+ * Synthesize the kind:7 reaction event a freshly-clicked reaction produces, so
+ * it can be written into the channel cache immediately rather than waiting for
+ * a relay round-trip that never comes: reactions carry only an `e` tag (no
+ * `h`), so the `#h`-scoped live subscription (`buildChannelFilter`) never
+ * delivers them back — they only arrive via the cold-load `#e` aux backfill.
+ * Without this the reactor's own reaction stays invisible until the next
+ * channel switch, and re-clicking just re-hits the relay's duplicate guard.
+ *
+ * The shape mirrors what `formatTimelineMessages` reads: the target `e` tag,
+ * the emoji as content, the optional NIP-30 `["emoji", shortcode, url]` tag for
+ * custom emoji, and the actor's pubkey. Reaction render dedupes by
+ * `targetId:actorPubkey:emoji`, so when the real event later loads via backfill
+ * it collapses onto this one — no double count.
+ */
+export function createOptimisticReaction(
+  targetEventId: string,
+  emoji: string,
+  emojiUrl: string | undefined,
+  pubkey: string,
+): RelayEvent {
+  const tags: string[][] = [["e", targetEventId]];
+  // Custom-emoji reaction (NIP-30): content is `:shortcode:` with the image URL
+  // on a matching `emoji` tag.
+  if (emojiUrl && emoji.startsWith(":") && emoji.endsWith(":")) {
+    tags.push(["emoji", emoji.slice(1, -1), emojiUrl]);
+  }
+
+  return {
+    id: `optimistic-reaction-${crypto.randomUUID()}`,
+    pubkey,
+    created_at: Math.floor(Date.now() / 1_000),
+    kind: KIND_REACTION,
+    tags,
+    content: emoji,
+    sig: "",
+  };
+}

From 1e6e0eefb85ccc5dcbf884aa1eb75c09eb10f068 Mon Sep 17 00:00:00 2001
From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
 <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Date: Sun, 21 Jun 2026 09:58:24 -0400
Subject: [PATCH 3/7] desktop: cancel in-flight channel fetch before optimistic
 reaction write
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The reaction onMutate snapshotted the cache and wrote the optimistic
kind:7 synchronously, but did not cancel an in-flight useChannelMessages
fetch first. That queryFn ends with setQueryData(queryKey, mergedHistory)
built from a pre-click snapshot, so a fetch resolving just after the
click would clobber the optimistic reaction until the next backfill —
the same race the sibling send-message onMutate already guards against.

Make onMutate async and await queryClient.cancelQueries({ queryKey })
before snapshotting, matching the send-message mutation. Narrow but real;
no behavior change when no fetch is in flight (cancelQueries is a no-op).

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
---
 desktop/src/features/messages/hooks.ts | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts
index 411d476cf5..3d07f40834 100644
--- a/desktop/src/features/messages/hooks.ts
+++ b/desktop/src/features/messages/hooks.ts
@@ -519,13 +519,17 @@ export function useToggleReactionMutation() {
     // channel cache ourselves, optimistically, or the reactor's own reaction
     // stays invisible until the next channel switch. Mirrors the apply-on-
     // success cache writes in the edit/delete mutations below.
-    onMutate: ({ eventId, emoji, remove, channelId }) => {
+    onMutate: async ({ eventId, emoji, remove, channelId }) => {
       const pubkey = identityQuery.data?.pubkey;
       if (!channelId || !pubkey) {
         return undefined;
       }
 
       const queryKey = channelMessagesKey(channelId);
+      // Cancel any in-flight channel fetch so its post-write setQueryData
+      // can't clobber the optimistic reaction with a pre-write snapshot
+      // (matches the send-message onMutate below).
+      await queryClient.cancelQueries({ queryKey });
       const previous = queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
       const pubkeyLower = pubkey.toLowerCase();
       const trimmedEmoji = emoji.trim();

From d93c7d78f835024dd726cebc2a6efbc5b52fd15a Mon Sep 17 00:00:00 2001
From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
 <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Date: Sun, 21 Jun 2026 10:01:52 -0400
Subject: [PATCH 4/7] desktop: treat duplicate-on-add as idempotent success for
 reactions

Perci caught a blocking gap on Tyler's exact repro: the relay already
holds the reactor's kind:7 (it never came back over the #h live sub, so
the cache didn't render it). Clicking again ran onMutate (optimistic
write) -> addReaction rejected with "duplicate: reaction already
exists" -> onError rolled the optimistic reaction back -> we were
straight back to "duplicate but nothing visible". The optimistic write
was being undone on precisely the path it was meant to fix.

Swallow duplicate-on-add in mutationFn (reusing the existing
isDuplicateReactionError predicate that Pulse already uses) so onError
never runs and the optimistic reaction survives. Other failures and the
remove path still propagate and roll back. Adds a regression test pinning
the relay error string the swallow depends on.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
---
 desktop/src/features/messages/hooks.ts        | 14 +++++++++++-
 .../messages/lib/optimisticReaction.test.mjs  | 22 +++++++++++++++++++
 2 files changed, 35 insertions(+), 1 deletion(-)

diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts
index 3d07f40834..478878b939 100644
--- a/desktop/src/features/messages/hooks.ts
+++ b/desktop/src/features/messages/hooks.ts
@@ -38,6 +38,7 @@ import {
   MIN_TOP_LEVEL_ROWS_PER_FETCH,
   pageOlderMessagesUntilRowFloor,
 } from "@/features/messages/lib/pageOlderMessages";
+import { isDuplicateReactionError } from "@/features/pulse/lib/noteActions";
 import {
   KIND_REACTION,
   KIND_STREAM_MESSAGE,
@@ -575,7 +576,18 @@ export function useToggleReactionMutation() {
         emoji,
         queryClient.getQueryData<CustomEmoji[]>(customEmojiQueryKey),
       );
-      await addReaction(eventId, emoji, emojiUrl);
+      try {
+        await addReaction(eventId, emoji, emojiUrl);
+      } catch (error) {
+        // The relay already has this reaction (its `e`-only kind:7 never came
+        // back over the `#h` live sub, so the cache didn't render it and the
+        // user clicked again — Tyler's exact repro). That's idempotent
+        // success: swallow it so onError doesn't roll back the optimistic
+        // write and leave us back at "duplicate but nothing visible".
+        if (!isDuplicateReactionError(error)) {
+          throw error;
+        }
+      }
     },
     onError: (_error, _variables, context) => {
       if (context) {
diff --git a/desktop/src/features/messages/lib/optimisticReaction.test.mjs b/desktop/src/features/messages/lib/optimisticReaction.test.mjs
index a974f31dbd..d4d19b5e4e 100644
--- a/desktop/src/features/messages/lib/optimisticReaction.test.mjs
+++ b/desktop/src/features/messages/lib/optimisticReaction.test.mjs
@@ -4,6 +4,7 @@ import test from "node:test";
 import { createOptimisticReaction } from "./optimisticReaction.ts";
 import { formatTimelineMessages } from "./formatTimelineMessages.ts";
 import { sortMessages } from "./messageQueryKeys.ts";
+import { isDuplicateReactionError } from "@/features/pulse/lib/noteActions";
 import { KIND_REACTION } from "@/shared/constants/kinds";
 
 const CHANNEL_ID = "36411e44-0e2d-4cfe-bd6e-567eb169db9f";
@@ -101,3 +102,24 @@ test("custom-emoji optimistic reaction carries the NIP-30 emoji tag", () => {
   );
   assert.equal(optimistic.content, ":party:");
 });
+
+test("duplicate-on-add is recognized so the optimistic write is kept, not rolled back", () => {
+  // Tyler's exact repro: the relay already holds the reactor's ✅ (it never
+  // came back over the `#h` live sub, so the cache didn't render it). Clicking
+  // again writes the optimistic reaction, then `addReaction` rejects with the
+  // relay's duplicate message. useToggleReactionMutation swallows precisely
+  // this error so onError never runs and the optimistic reaction survives —
+  // turning "duplicate but nothing visible" into a rendered ✅. This pins the
+  // relay error string that swallow depends on; if it drifts, the bug returns.
+  const relayDuplicate = new Error(
+    "relay rejected event: duplicate: reaction already exists",
+  );
+  assert.equal(isDuplicateReactionError(relayDuplicate), true);
+
+  // Any other failure must still propagate (onError rolls back) — we only
+  // treat duplicate-on-add as idempotent success.
+  assert.equal(
+    isDuplicateReactionError(new Error("relay rejected event: rate-limited")),
+    false,
+  );
+});

From b089618dd07993b862e13134c69a73efa78c2e5b Mon Sep 17 00:00:00 2001
From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
 <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Date: Sun, 21 Jun 2026 10:04:25 -0400
Subject: [PATCH 5/7] desktop: document the reaction-rollback race as an
 accepted edge
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Sami flagged in review that onError rolls back to the onMutate snapshot,
so a real event backfilled between onMutate and onError is dropped by the
rollback. It needs a relay error racing an in-flight backfill and
self-heals on the next backfill — vanishingly rare, not worth guarding.
Comment-only, documents the known edge.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
---
 desktop/src/features/messages/hooks.ts | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts
index 478878b939..4f1288c9b5 100644
--- a/desktop/src/features/messages/hooks.ts
+++ b/desktop/src/features/messages/hooks.ts
@@ -591,6 +591,10 @@ export function useToggleReactionMutation() {
     },
     onError: (_error, _variables, context) => {
       if (context) {
+        // Rollback restores the pre-click snapshot. Narrow accepted edge: if a
+        // backfill landed a real event between onMutate and onError, this drops
+        // it too — but that needs a relay error racing an in-flight backfill,
+        // and the next backfill self-heals it. Not worth guarding.
         queryClient.setQueryData<RelayEvent[]>(
           context.queryKey,
           context.previous,

From c1c3d675d259e12fd5c149363dd9873782625ba4 Mon Sep 17 00:00:00 2001
From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
 <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Date: Sun, 21 Jun 2026 11:17:26 -0400
Subject: [PATCH 6/7] desktop: split reaction backfill from slow deletion scan;
 make chunks resilient
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Cold-load into a busy channel showed no reactions on first paint, even for
messages known to carry them — a deterministic GUI symptom, not flaky latency.

Root cause, measured against staging: the cold-load aux backfill bundled all
four aux kinds ([5,7,9005,40003]) into one #e REQ per chunk. Kind:5 (deletions)
has no GIN index on tags and scans ~3.3s/chunk, dragging the whole bundled REQ
past the history-load timeout. The chunk loop awaited sequentially and rejected
the entire batch on the first failure, so one slow chunk dropped *every*
reaction in the view until the user switched channels and back.

Fix, two independent parts:
- Kind-split the backfill. Reactions (kind:7, ~5x faster alone) ride their own
  REQ and commit first; the edit/deletion overlay (5/9005/40003) is fetched and
  committed separately with its own try/catch. A slow deletion scan can no
  longer strand reactions — proven in the e2e WS trace: kind:7 REQs land at
  ~2-4s, the structural REQs fire later at 6-14s.
- Make fetchChunkedAuxEvents per-chunk resilient: chunks run in parallel and
  Promise.allSettled keeps the fulfilled ones, so one chunk timing out no longer
  blanks the batch.

Collapsed the three aux-fetch wrappers on RelayClient into one generic
fetchAuxEventsByReference(channelId, ids, buildFilter); callers supply the kind
filter. Removed the orphaned buildChannelAuxFilter builder.

Tests: split-filter coverage in relayChannelFilters.test.mjs; two isolation
tests in auxBackfill.test.mjs (structural failure doesn't strand reactions;
reaction failure doesn't block the structural overlay). Suite 1054/1054,
typecheck + biome + file-sizes + px all green.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
---
 .../messages/lib/auxBackfill.test.mjs         | 96 ++++++++++++++++++-
 .../src/features/messages/lib/auxBackfill.ts  | 90 ++++++++++++++---
 .../shared/api/relayChannelFilters.test.mjs   | 19 +++-
 desktop/src/shared/api/relayChannelFilters.ts | 37 +++++--
 desktop/src/shared/api/relayClientSession.ts  | 47 ++++-----
 5 files changed, 239 insertions(+), 50 deletions(-)

diff --git a/desktop/src/features/messages/lib/auxBackfill.test.mjs b/desktop/src/features/messages/lib/auxBackfill.test.mjs
index 9851a1c002..afd1e15f5d 100644
--- a/desktop/src/features/messages/lib/auxBackfill.test.mjs
+++ b/desktop/src/features/messages/lib/auxBackfill.test.mjs
@@ -2,12 +2,14 @@ import assert from "node:assert/strict";
 import test from "node:test";
 
 import {
+  backfillAuxForMessages,
   collectAuxEventIdsForDeletionBackfill,
   collectMessageIdsForAuxBackfill,
   mergeAuxEventsWithDeletionBackfill,
 } from "./auxBackfill.ts";
 import { formatTimelineMessages } from "./formatTimelineMessages.ts";
-import { buildChannelAuxFilter } from "@/shared/api/relayChannelFilters.ts";
+import { buildChannelReactionAuxFilter } from "@/shared/api/relayChannelFilters.ts";
+import { channelMessagesKey } from "./messageQueryKeys.ts";
 
 const CHANNEL_ID = "36411e44-0e2d-4cfe-bd6e-567eb169db9f";
 
@@ -158,9 +160,9 @@ test("old reaction outside the history window is backfilled by #e and renders",
   const messageIds = collectMessageIdsForAuxBackfill(history);
   assert.deepEqual(messageIds, [messageId]);
 
-  // Step 2: the aux filter references those ids by `#e`, with no time window —
-  // so an old reaction is reachable regardless of when it was created.
-  const auxFilter = buildChannelAuxFilter(CHANNEL_ID, messageIds);
+  // Step 2: the reaction aux filter references those ids by `#e`, with no time
+  // window — so an old reaction is reachable regardless of when it was created.
+  const auxFilter = buildChannelReactionAuxFilter(CHANNEL_ID, messageIds);
   assert.deepEqual(auxFilter["#e"], [messageId]);
   assert.equal("since" in auxFilter, false);
   assert.equal("until" in auxFilter, false);
@@ -207,3 +209,89 @@ test("old reaction outside the history window is backfilled by #e and renders",
     [{ emoji: "✅", count: 1, mine: true }],
   );
 });
+
+// Minimal in-memory stand-in for the React-Query client: only the two methods
+// backfillAuxForMessages touches. `setQueryData` mirrors React-Query's updater
+// contract (receives current value, defaulted to [] by the caller).
+function makeQueryClientStub() {
+  const store = new Map();
+  return {
+    getQueryData(key) {
+      return store.get(JSON.stringify(key));
+    },
+    setQueryData(key, updater) {
+      const k = JSON.stringify(key);
+      const next =
+        typeof updater === "function" ? updater(store.get(k) ?? []) : updater;
+      store.set(k, next);
+      return next;
+    },
+  };
+}
+
+function reactionAux(id, messageId, emoji = "✅") {
+  return event(id, 7, {
+    pubkey: hex("c"),
+    content: emoji,
+    tags: [["e", messageId]],
+  });
+}
+
+// The whole point of the kind-split: a slow/failed structural (kind:5/9005/
+// 40003) fetch must NOT blank reactions. Reactions are committed first on their
+// own REQ; the structural overlay's failure is caught and logged, leaving the
+// reactions in cache. Pre-fix, both rode one bundled REQ under one try/catch,
+// so a structural timeout dropped every reaction in the view.
+test("structural-overlay failure does not strand already-committed reactions", async () => {
+  const messageId = hex("1");
+  const queryClient = makeQueryClientStub();
+  // Seed the content message into cache, as the cold-load history fetch would.
+  queryClient.setQueryData(channelMessagesKey(CHANNEL_ID), [
+    event(messageId, 9, { pubkey: hex("a"), content: "ship it?" }),
+  ]);
+
+  await backfillAuxForMessages(queryClient, CHANNEL_ID, [event(messageId, 9)], {
+    fetchReactionAuxEventsForMessages: async () => [
+      reactionAux(hex("2"), messageId),
+    ],
+    // The slow half blows up — exactly the cold-load kind:5 timeout.
+    fetchStructuralAuxEventsForMessages: async () => {
+      throw new Error("Timed out while loading channel history.");
+    },
+    fetchAuxDeletionEventsForAuxEvents: async () => [],
+  });
+
+  const cached = queryClient.getQueryData(channelMessagesKey(CHANNEL_ID));
+  assert.ok(
+    cached.some((e) => e.id === hex("2") && e.kind === 7),
+    "reaction must survive a structural-overlay fetch failure",
+  );
+});
+
+// Symmetric guarantee: a reaction-fetch failure must not abort the structural
+// overlay. Each half owns its try/catch, so an edit/deletion still applies even
+// if reactions couldn't be fetched this pass (they self-heal next backfill).
+test("reaction-fetch failure does not block the structural overlay", async () => {
+  const messageId = hex("1");
+  const editId = hex("3");
+  const queryClient = makeQueryClientStub();
+  queryClient.setQueryData(channelMessagesKey(CHANNEL_ID), [
+    event(messageId, 9, { pubkey: hex("a"), content: "original" }),
+  ]);
+
+  await backfillAuxForMessages(queryClient, CHANNEL_ID, [event(messageId, 9)], {
+    fetchReactionAuxEventsForMessages: async () => {
+      throw new Error("Timed out while loading channel history.");
+    },
+    fetchStructuralAuxEventsForMessages: async () => [
+      event(editId, 40003, { tags: [["e", messageId]] }),
+    ],
+    fetchAuxDeletionEventsForAuxEvents: async () => [],
+  });
+
+  const cached = queryClient.getQueryData(channelMessagesKey(CHANNEL_ID));
+  assert.ok(
+    cached.some((e) => e.id === editId && e.kind === 40003),
+    "edit must apply even when the reaction fetch failed",
+  );
+});
diff --git a/desktop/src/features/messages/lib/auxBackfill.ts b/desktop/src/features/messages/lib/auxBackfill.ts
index cac1eec44d..7e210dda14 100644
--- a/desktop/src/features/messages/lib/auxBackfill.ts
+++ b/desktop/src/features/messages/lib/auxBackfill.ts
@@ -5,6 +5,11 @@ import {
   sortMessages,
 } from "@/features/messages/lib/messageQueryKeys";
 import { relayClient } from "@/shared/api/relayClient";
+import {
+  buildChannelAuxDeletionFilter,
+  buildChannelReactionAuxFilter,
+  buildChannelStructuralAuxFilter,
+} from "@/shared/api/relayChannelFilters";
 import type { RelayEvent } from "@/shared/api/types";
 import {
   CHANNEL_TIMELINE_CONTENT_KINDS,
@@ -64,6 +69,42 @@ export async function mergeAuxEventsWithDeletionBackfill(input: {
   return [...input.fetchedAuxEvents, ...auxDeletionEvents];
 }
 
+export interface AuxBackfillDeps {
+  fetchReactionAuxEventsForMessages: (
+    channelId: string,
+    messageIds: string[],
+  ) => Promise<RelayEvent[]>;
+  fetchStructuralAuxEventsForMessages: (
+    channelId: string,
+    messageIds: string[],
+  ) => Promise<RelayEvent[]>;
+  fetchAuxDeletionEventsForAuxEvents: (
+    channelId: string,
+    auxEventIds: string[],
+  ) => Promise<RelayEvent[]>;
+}
+
+const defaultAuxBackfillDeps: AuxBackfillDeps = {
+  fetchReactionAuxEventsForMessages: (channelId, messageIds) =>
+    relayClient.fetchAuxEventsByReference(
+      channelId,
+      messageIds,
+      buildChannelReactionAuxFilter,
+    ),
+  fetchStructuralAuxEventsForMessages: (channelId, messageIds) =>
+    relayClient.fetchAuxEventsByReference(
+      channelId,
+      messageIds,
+      buildChannelStructuralAuxFilter,
+    ),
+  fetchAuxDeletionEventsForAuxEvents: (channelId, auxEventIds) =>
+    relayClient.fetchAuxEventsByReference(
+      channelId,
+      auxEventIds,
+      buildChannelAuxDeletionFilter,
+    ),
+};
+
 /**
  * After a content-kinds-only history fetch, pull the auxiliary events
  * (reactions, edits, deletions) that reference the loaded messages — keyed by
@@ -76,40 +117,61 @@ export async function mergeAuxEventsWithDeletionBackfill(input: {
  * outside any fetched time window — so aux must be pulled by reference, or a
  * visible message renders stale (un-edited / not-deleted).
  *
- * Best-effort: failures are logged but never reject, so a flaky overlay fetch
- * can't blank the freshly-loaded messages.
+ * Reactions and the structural overlay (edits/deletions) are fetched and
+ * committed *independently*: reactions ride their own fast kind:7 REQ and land
+ * first, so the slow kind:5 deletion scan — which on a busy workspace queued
+ * past the history-load timeout — can no longer strand them. Each half has its
+ * own try/catch; a failure in one is logged but never blanks the other.
  */
 export async function backfillAuxForMessages(
   queryClient: QueryClient,
   channelId: string,
   historyEvents: RelayEvent[],
+  deps: AuxBackfillDeps = defaultAuxBackfillDeps,
 ): Promise<void> {
   const messageIds = collectMessageIdsForAuxBackfill(historyEvents);
   if (messageIds.length === 0) {
     return;
   }
 
+  const cacheKey = channelMessagesKey(channelId);
+  const commit = (events: RelayEvent[]) => {
+    if (events.length === 0) {
+      return;
+    }
+    queryClient.setQueryData<RelayEvent[]>(cacheKey, (current = []) =>
+      sortMessages([...current, ...events]),
+    );
+  };
+
+  // Reactions first, on their own fast REQ — the half that actually shows up
+  // as data loss when it's dropped. Commit as soon as they land.
+  try {
+    const reactionEvents = await deps.fetchReactionAuxEventsForMessages(
+      channelId,
+      messageIds,
+    );
+    commit(reactionEvents);
+  } catch (error) {
+    console.error("Failed to backfill reactions for channel", channelId, error);
+  }
+
+  // Structural overlay (edits/deletions) second, isolated. A failure here only
+  // leaves a message un-edited / not-deleted until the next backfill — never
+  // blanks the reactions already committed above.
   try {
-    const cacheKey = channelMessagesKey(channelId);
     const cachedEvents = queryClient.getQueryData<RelayEvent[]>(cacheKey) ?? [];
-    const auxEvents = await relayClient.fetchAuxEventsForMessages(
+    const structuralEvents = await deps.fetchStructuralAuxEventsForMessages(
       channelId,
       messageIds,
     );
     const mergedAuxEvents = await mergeAuxEventsWithDeletionBackfill({
       channelId,
       cachedEvents,
-      fetchedAuxEvents: auxEvents,
-      fetchAuxEventsForMessages: (...args) =>
-        relayClient.fetchAuxDeletionEventsForAuxEvents(...args),
+      fetchedAuxEvents: structuralEvents,
+      fetchAuxEventsForMessages: deps.fetchAuxDeletionEventsForAuxEvents,
     });
-    if (mergedAuxEvents.length === 0) {
-      return;
-    }
-
-    queryClient.setQueryData<RelayEvent[]>(cacheKey, (current = []) =>
-      sortMessages([...current, ...mergedAuxEvents]),
-    );
+    commit(mergedAuxEvents);
   } catch (error) {
     console.error(
       "Failed to backfill auxiliary events for channel",
diff --git a/desktop/src/shared/api/relayChannelFilters.test.mjs b/desktop/src/shared/api/relayChannelFilters.test.mjs
index 76380a708e..c60a978f55 100644
--- a/desktop/src/shared/api/relayChannelFilters.test.mjs
+++ b/desktop/src/shared/api/relayChannelFilters.test.mjs
@@ -3,7 +3,8 @@ import test from "node:test";
 
 import {
   buildChannelAuxDeletionFilter,
-  buildChannelAuxFilter,
+  buildChannelReactionAuxFilter,
+  buildChannelStructuralAuxFilter,
 } from "./relayChannelFilters.ts";
 
 const CHANNEL = "36411e44-0e2d-4cfe-bd6e-567eb169db9f";
@@ -16,9 +17,21 @@ const IDS = [
 // an `e` tag, no channel `h` tag. An `#h`-scoped aux query never matches them,
 // so removed historical reactions reappear. The aux filters must key on `#e`
 // only.
-test("buildChannelAuxFilter keys on #e only, no #h", () => {
-  const filter = buildChannelAuxFilter(CHANNEL, IDS);
+test("buildChannelReactionAuxFilter keys on #e only, kind:7, no #h", () => {
+  const filter = buildChannelReactionAuxFilter(CHANNEL, IDS);
   assert.deepEqual(filter["#e"], IDS);
+  assert.deepEqual(filter.kinds, [7]);
+  assert.equal("#h" in filter, false);
+});
+
+// The structural overlay (edits/deletions) is the slow half — fetched on its
+// own REQ so a stale kind:5 scan can't strand reactions. It must NOT include
+// kind:7, or it would re-bundle reactions back into the slow query.
+test("buildChannelStructuralAuxFilter keys on #e only, edits+deletions, no kind:7", () => {
+  const filter = buildChannelStructuralAuxFilter(CHANNEL, IDS);
+  assert.deepEqual(filter["#e"], IDS);
+  assert.equal(filter.kinds.includes(7), false);
+  assert.deepEqual(new Set(filter.kinds), new Set([40003, 5, 9005]));
   assert.equal("#h" in filter, false);
 });
 
diff --git a/desktop/src/shared/api/relayChannelFilters.ts b/desktop/src/shared/api/relayChannelFilters.ts
index 922c934398..ead762c999 100644
--- a/desktop/src/shared/api/relayChannelFilters.ts
+++ b/desktop/src/shared/api/relayChannelFilters.ts
@@ -1,10 +1,11 @@
 import {
-  CHANNEL_AUX_EVENT_KINDS,
   CHANNEL_EVENT_KINDS,
   CHANNEL_TIMELINE_CONTENT_KINDS,
   HOME_MENTION_EVENT_KINDS,
   KIND_DELETION,
   KIND_NIP29_DELETE_EVENT,
+  KIND_REACTION,
+  KIND_STREAM_MESSAGE_EDIT,
 } from "@/shared/constants/kinds";
 import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared";
 
@@ -42,7 +43,8 @@ export function buildChannelFilter(
  * History filter for cold-load and scrollback: message kinds *only*, so the
  * `limit` budget buys visible message depth. Auxiliary events (reactions,
  * edits, deletions) are backfilled separately by `#e` reference via
- * {@link buildChannelAuxFilter}, and arrive for future messages through the
+ * {@link buildChannelReactionAuxFilter} and
+ * {@link buildChannelStructuralAuxFilter}, and arrive for future messages through the
  * live subscription ({@link buildChannelFilter}, which keeps the broad
  * {@link CHANNEL_EVENT_KINDS} set).
  */
@@ -65,16 +67,37 @@ export function buildChannelHistoryFilter(
 }
 
 /**
- * Aux-backfill filter for one chunk of loaded message ids: pulls reactions/
- * edits/deletions ({@link CHANNEL_AUX_EVENT_KINDS}) that reference those ids
- * by `#e`. Keyed by reference, not time, so a late edit/deletion for an old
+ * Reactions-only aux filter (kind:7 by `#e`). Reactions load on their own fast
+ * REQ, isolated from the slow kind:5 deletion scan they were previously bundled
+ * with. On a busy workspace the bundled query queued past the history-load
+ * timeout and the all-or-nothing catch dropped every reaction on cold-load;
+ * kind:7 alone is ~5x faster (measured against staging), so it reliably beats
+ * the timeout. Keyed by `#e` reference, not time, so an old reaction on a
  * visible message still applies — see {@link buildChannelHistoryFilter}.
  */
-export function buildChannelAuxFilter(
+export function buildChannelReactionAuxFilter(
   _channelId: string,
   messageIds: string[],
 ): RelaySubscriptionFilter {
-  return buildChannelAuxKindFilter(messageIds, [...CHANNEL_AUX_EVENT_KINDS]);
+  return buildChannelAuxKindFilter(messageIds, [KIND_REACTION]);
+}
+
+/**
+ * Structural aux overlay filter: edits + deletions ({@link KIND_STREAM_MESSAGE_EDIT},
+ * {@link KIND_DELETION}, {@link KIND_NIP29_DELETE_EVENT}) by `#e`. The slow
+ * half of the old bundle — fetched separately so a stale/slow deletion scan
+ * can't strand reactions. A missed edit/deletion only renders a message
+ * un-edited until the next backfill; a missed reaction looks like data loss.
+ */
+export function buildChannelStructuralAuxFilter(
+  _channelId: string,
+  messageIds: string[],
+): RelaySubscriptionFilter {
+  return buildChannelAuxKindFilter(messageIds, [
+    KIND_STREAM_MESSAGE_EDIT,
+    KIND_DELETION,
+    KIND_NIP29_DELETE_EVENT,
+  ]);
 }
 
 export function buildChannelAuxDeletionFilter(
diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts
index 8db85397b7..5eb6573992 100644
--- a/desktop/src/shared/api/relayClientSession.ts
+++ b/desktop/src/shared/api/relayClientSession.ts
@@ -21,8 +21,6 @@ import {
 } from "@/shared/api/relayClientShared";
 import {
   AUX_BACKFILL_CHUNK_SIZE,
-  buildChannelAuxDeletionFilter,
-  buildChannelAuxFilter,
   buildChannelFilter,
   buildChannelHistoryFilter,
   buildChannelMentionFilter,
@@ -168,25 +166,23 @@ export class RelayClient {
     );
   }
 
-  async fetchAuxEventsForMessages(
-    channelId: string,
-    messageIds: string[],
-  ): Promise<RelayEvent[]> {
-    return this.fetchChunkedAuxEvents(
-      channelId,
-      messageIds,
-      buildChannelAuxFilter,
-    );
-  }
-
-  async fetchAuxDeletionEventsForAuxEvents(
+  /**
+   * Fetch auxiliary events referencing the given ids by `#e`, chunked and
+   * per-chunk resilient. Caller picks the kind filter so reactions ride their
+   * own REQ, isolated from the slow kind:5 scan — see {@link backfillAuxForMessages}.
+   */
+  async fetchAuxEventsByReference(
     channelId: string,
-    auxEventIds: string[],
+    referencedEventIds: string[],
+    buildFilter: (
+      channelId: string,
+      eventIds: string[],
+    ) => RelaySubscriptionFilter,
   ): Promise<RelayEvent[]> {
     return this.fetchChunkedAuxEvents(
       channelId,
-      auxEventIds,
-      buildChannelAuxDeletionFilter,
+      referencedEventIds,
+      buildFilter,
     );
   }
 
@@ -213,12 +209,19 @@ export class RelayClient {
       chunks.push(eventIds.slice(i, i + AUX_BACKFILL_CHUNK_SIZE));
     }
 
-    const batches: RelayEvent[][] = [];
-    for (const ids of chunks) {
-      batches.push(await this.requestHistory(buildFilter(channelId, ids)));
-    }
+    // Per-chunk resilient: a single chunk that times out or errors must not
+    // strand the reactions that other chunks already returned. Chunks run in
+    // parallel (independent REQs) and `allSettled` keeps the fulfilled ones —
+    // the prior sequential `await` loop both serialized latency and rejected
+    // the whole batch on the first failure, so one slow chunk blanked every
+    // reaction in the view.
+    const settled = await Promise.allSettled(
+      chunks.map((ids) => this.requestHistory(buildFilter(channelId, ids))),
+    );
 
-    return batches.flat();
+    return settled.flatMap((result) =>
+      result.status === "fulfilled" ? result.value : [],
+    );
   }
 
   private async fetchHistory(filter: RelaySubscriptionFilter) {

From 2d04a70af4c54c370e263387e46acfa0f9756ab5 Mon Sep 17 00:00:00 2001
From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
 <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Date: Sun, 21 Jun 2026 11:17:44 -0400
Subject: [PATCH 7/7] desktop: e2e repro harness for cold-load reaction render
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Drives the real relay-mode GUI against the staging relay, cold-loads
#buzz-bugs, and asserts reactions render on first paint — the exact symptom
Tyler reported. Bridges the page WebSocket and HTTP /query through Node so the
shipped data path runs from the test browser despite the staging WAF's
Mozilla-UA block and missing CORS headers (both harness artifacts, not the bug).

The aux-REQ detector recognizes the split REQs (REACTION-AUX / STRUCTURAL-AUX /
BUNDLED-AUX) so the WS trace shows reactions firing first. Hard assertions:
cold-load reaction count > 0 (pre-fix this was 0 on a busy workspace), and
cold-load >= the after-switch ground-truth count.

Not a CI test — points at a live external relay and a real member identity.
Run explicitly: pnpm exec playwright test reaction-coldload-repro

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
---
 desktop/playwright.config.ts                  |   1 +
 .../tests/e2e/reaction-coldload-repro.spec.ts | 361 ++++++++++++++++++
 2 files changed, 362 insertions(+)
 create mode 100644 desktop/tests/e2e/reaction-coldload-repro.spec.ts

diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index 7f7ccce4fd..b3ddaa35b7 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -70,6 +70,7 @@ export default defineConfig({
         "**/tokens.spec.ts",
         "**/persona-env-vars.spec.ts",
         "**/mesh-compute.spec.ts",
+        "**/reaction-coldload-repro.spec.ts",
       ],
       use: {
         ...devices["Desktop Chrome"],
diff --git a/desktop/tests/e2e/reaction-coldload-repro.spec.ts b/desktop/tests/e2e/reaction-coldload-repro.spec.ts
new file mode 100644
index 0000000000..32646ede27
--- /dev/null
+++ b/desktop/tests/e2e/reaction-coldload-repro.spec.ts
@@ -0,0 +1,361 @@
+import { expect, test } from "@playwright/test";
+
+// Ground-truth reproduction for Tyler's "reactions don't show on first load"
+// bug. Drives the REAL GUI (relay-mode e2e bridge) against the staging relay
+// with Eva's real key, cold-loads #buzz-bugs, and observes whether messages
+// known to carry reactions render their reaction pills on first paint vs only
+// after a channel switch-away-and-back.
+//
+// NOT a CI test — points at a live external relay and a real member identity.
+// Run explicitly: pnpm exec playwright test reaction-coldload-repro
+
+const RELAY_WS = "wss://sprout-oss.stage.blox.sqprod.co";
+const RELAY_HTTP = "https://sprout-oss.stage.blox.sqprod.co";
+const BUZZ_BUGS_NAME = "buzz-bugs";
+
+const EVA = {
+  privateKey:
+    "10d3c4de9637f8c7467fb9dbe1da301f22db01ac5e45617e70e3d4538b35a5a9",
+  pubkey: "011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd",
+  username: "Eva",
+};
+
+test("cold-load buzz-bugs and observe reaction render", async ({ page }) => {
+  test.setTimeout(180_000);
+  // Seed workspace + onboarding for Eva's pubkey so the app skips WelcomeSetup
+  // and boots straight into the workspace pointed at staging.
+  await page.addInitScript(
+    ({ relayUrl, pubkey }) => {
+      const workspaceId = "e2e-repro-workspace";
+      window.localStorage.setItem(
+        "buzz-workspaces",
+        JSON.stringify([
+          {
+            id: workspaceId,
+            name: "Staging Repro",
+            relayUrl,
+            addedAt: new Date().toISOString(),
+          },
+        ]),
+      );
+      window.localStorage.setItem("buzz-active-workspace-id", workspaceId);
+      const scope = encodeURIComponent(relayUrl);
+      window.localStorage.setItem(
+        `buzz-onboarding-complete.v1:${pubkey}`,
+        "true",
+      );
+      window.localStorage.setItem(
+        `buzz-welcome-channel-ensured.v1:${scope}:${pubkey}`,
+        "true",
+      );
+    },
+    { relayUrl: RELAY_WS, pubkey: EVA.pubkey },
+  );
+
+  await page.addInitScript(
+    ({ identity, relayHttpUrl, relayWsUrl }) => {
+      (window as unknown as { __BUZZ_E2E__: unknown }).__BUZZ_E2E__ = {
+        mode: "relay",
+        identity,
+        relayHttpUrl,
+        relayWsUrl,
+      };
+    },
+    { identity: EVA, relayHttpUrl: RELAY_HTTP, relayWsUrl: RELAY_WS },
+  );
+
+  const consoleLines: string[] = [];
+  page.on("console", (msg) => {
+    consoleLines.push(`[${msg.type()}] ${msg.text()}`);
+  });
+  page.on("pageerror", (err) => {
+    consoleLines.push(`[pageerror] ${err.message}`);
+  });
+
+  // The staging relay's HTTP API does not return Access-Control-Allow-Origin,
+  // so a browser fetch from the test origin (127.0.0.1:4173) is blocked by
+  // CORS — every get_channels / backfill /query fails with "Failed to fetch".
+  // That's a harness artifact, NOT Tyler's bug. Proxy the relay's HTTP origin
+  // through Node fetch (no CORS) and inject the missing CORS header so the
+  // real GUI data path runs exactly as shipped, just reachable from the test.
+  const relayQueryBodies: string[] = [];
+  await page.route(`${RELAY_HTTP}/**`, async (route) => {
+    const req = route.request();
+    const url = req.url();
+    const postData = req.postData() ?? undefined;
+    if (url.endsWith("/query") && postData) {
+      relayQueryBodies.push(postData.slice(0, 400));
+    }
+    const headers = { ...req.headers() };
+    delete headers.origin;
+    // The staging relay sits behind a WAF that 403s any request whose
+    // User-Agent contains the `Mozilla/` token (confirmed: `Mozilla/5.0` ->
+    // 403, `curl`/`buzz-desktop`/empty -> 200). Chromium always sends a
+    // Mozilla UA, so the browser data path is blocked at infra — a harness
+    // artifact, NOT Tyler's bug (the real Tauri app queries via the Rust
+    // reqwest client, which is not browser-UA-shaped). Rewrite the UA so the
+    // shipped data path can run from the test browser.
+    headers["user-agent"] = "buzz-desktop-e2e";
+    const upstream = await fetch(url, {
+      method: req.method(),
+      headers,
+      body:
+        req.method() === "GET" || req.method() === "HEAD"
+          ? undefined
+          : postData,
+    });
+    const bodyBuf = Buffer.from(await upstream.arrayBuffer());
+    const respHeaders: Record<string, string> = {};
+    upstream.headers.forEach((v, k) => {
+      respHeaders[k] = v;
+    });
+    respHeaders["access-control-allow-origin"] = "*";
+    respHeaders["access-control-allow-headers"] = "*";
+    respHeaders["access-control-allow-methods"] = "*";
+    await route.fulfill({
+      status: upstream.status,
+      headers: respHeaders,
+      body: bodyBuf,
+    });
+  });
+
+  // Capture the aux backfill REQ + EVENT traffic over the websocket.
+  const wsFrames: string[] = [];
+  // The same `Mozilla/`-UA WAF that 403s the HTTP path also 403s the browser's
+  // WS upgrade (confirmed by probe), and message history + reaction backfill
+  // both ride the WebSocket, not the HTTP /query bridge. Playwright's
+  // `connectToServer()` is ALSO WAF-blocked (its upstream handshake closes
+  // 1006), and it exposes no way to set the UA. Node's built-in global
+  // `WebSocket` (undici) connects fine — its UA is not `Mozilla/`-shaped — so
+  // bridge the page's WS to a Node-side undici WebSocket by hand. The shipped
+  // WS data path (auth, history, aux reaction backfill) then runs as-is, just
+  // reachable from the test browser.
+  // Per-REQ latency tracking: maps a subscription id to the wall-clock time
+  // its REQ was sent, so we can measure REQ -> EOSE latency for each aux
+  // backfill chunk. This is what separates "deterministic >8s timeout" (a
+  // consistent GUI bug, as Tyler insists) from "random latency".
+  const reqSentAt = new Map<string, number>();
+  const reqFilters = new Map<string, string>();
+  // Aux-backfill subscription tracking + a bridge-throughput counter, to
+  // distinguish "relay never sent the EOSE" from "bridge stalled and dropped
+  // it" (harness artifact). auxSubIds holds the aux REQ ids (kinds 5,7,9005,
+  // 40003 keyed by #e); auxFrameLog records every upstream frame for them.
+  const auxSubIds = new Set<string>();
+  const auxFrameLog: string[] = [];
+  let upstreamFrameCount = 0;
+  const t0 = Date.now();
+  const rel = () => `${((Date.now() - t0) / 1000).toFixed(2)}s`;
+  await page.routeWebSocket(/sprout-oss/, (ws) => {
+    wsFrames.push(`ROUTE HIT url=${ws.url()}`);
+    const upstream = new WebSocket(ws.url());
+    const pageQueue: (string | Buffer)[] = [];
+    let upstreamOpen = false;
+    upstream.addEventListener("open", () => {
+      upstreamOpen = true;
+      for (const m of pageQueue) upstream.send(m);
+      pageQueue.length = 0;
+    });
+    upstream.addEventListener("message", (ev: MessageEvent) => {
+      const m = ev.data as string;
+      upstreamFrameCount++;
+      if (typeof m === "string" && m.startsWith("[")) {
+        try {
+          const arr = JSON.parse(m) as unknown[];
+          const verb = arr[0];
+          const sid = arr[1];
+          // Track every frame for the aux-backfill sub specifically, so we can
+          // tell whether the relay delivered its EVENTs/EOSE to the bridge even
+          // if the page never rendered them (= harness bottleneck, not the bug).
+          if (typeof sid === "string" && auxSubIds.has(sid)) {
+            auxFrameLog.push(
+              `[${rel()}] AUX-FRAME #${upstreamFrameCount} verb=${verb} sid=${sid.slice(0, 20)}`,
+            );
+          }
+          if (verb === "EOSE" && typeof sid === "string") {
+            const sent = reqSentAt.get(sid);
+            const filt = reqFilters.get(sid) ?? "";
+            if (sent !== undefined) {
+              wsFrames.push(
+                `EOSE sid=${sid} latency=${((Date.now() - sent) / 1000).toFixed(2)}s filter=${filt}`,
+              );
+              reqSentAt.delete(sid);
+            }
+          }
+        } catch {
+          /* not JSON array */
+        }
+      }
+      if (
+        typeof m === "string" &&
+        (m.includes('"kind":7') ||
+          m.includes("EOSE") ||
+          m.includes("OK") ||
+          m.includes("AUTH"))
+      )
+        wsFrames.push(`[${rel()}] << ${m.slice(0, 200)}`);
+      ws.send(m);
+    });
+    upstream.addEventListener("close", (ev: CloseEvent) =>
+      wsFrames.push(`UPSTREAM CLOSE code=${ev.code}`),
+    );
+    upstream.addEventListener("error", () => wsFrames.push(`UPSTREAM ERROR`));
+    ws.onMessage((m) => {
+      const p = typeof m === "string" ? m : "<binary>";
+      if (typeof m === "string" && m.startsWith("[")) {
+        try {
+          const arr = JSON.parse(m) as unknown[];
+          if (arr[0] === "REQ" && typeof arr[1] === "string") {
+            const sid = arr[1];
+            reqSentAt.set(sid, Date.now());
+            // Record the kinds + which #e tag count so we can attribute the
+            // latency to aux-backfill (kinds 5,7,9005,40003 + #e) vs other REQs.
+            const filterObj = arr[2] as
+              | { kinds?: number[]; "#e"?: string[] }
+              | undefined;
+            const kinds = filterObj?.kinds?.join(",") ?? "?";
+            const eCount = filterObj?.["#e"]?.length ?? 0;
+            reqFilters.set(sid, `kinds=[${kinds}] #e=${eCount}`);
+            // Aux backfill REQs are keyed by #e. Post kind-split (the fix),
+            // reactions ride a kind:7-only REQ and the structural overlay rides
+            // a 5/9005/40003 REQ; pre-fix they were one bundled 5+7+... REQ.
+            // Track all aux REQs so we can measure each one's REQ->EOSE latency
+            // and prove the reaction REQ now beats the 8s timeout.
+            const kindSet = new Set(filterObj?.kinds ?? []);
+            const isReactionReq =
+              eCount > 0 && kindSet.has(7) && !kindSet.has(5);
+            const isStructuralReq =
+              eCount > 0 && kindSet.has(5) && !kindSet.has(7);
+            const isBundledReq = eCount > 0 && kindSet.has(7) && kindSet.has(5);
+            if (isReactionReq || isStructuralReq || isBundledReq) {
+              auxSubIds.add(sid);
+            }
+            wsFrames.push(
+              `[${rel()}] REQ sid=${sid} kinds=[${kinds}] #e=${eCount}${
+                isReactionReq
+                  ? " [REACTION-AUX]"
+                  : isStructuralReq
+                    ? " [STRUCTURAL-AUX]"
+                    : isBundledReq
+                      ? " [BUNDLED-AUX]"
+                      : ""
+              }`,
+            );
+          }
+        } catch {
+          /* not JSON array */
+        }
+      }
+      if (p.includes("REQ") || p.includes("kinds") || p.includes("AUTH"))
+        wsFrames.push(`[${rel()}] >> ${p.slice(0, 300)}`);
+      if (upstreamOpen) upstream.send(m);
+      else pageQueue.push(m);
+    });
+    ws.onClose(() => {
+      try {
+        upstream.close();
+      } catch {
+        /* noop */
+      }
+    });
+  });
+
+  await page.goto("/");
+
+  try {
+    // Wait for the sidebar + buzz-bugs channel entry to materialize from the
+    // relay (kind:39002 membership -> kind:39000 metadata).
+    const channelEntry = page.getByTestId(`channel-${BUZZ_BUGS_NAME}`);
+    await channelEntry.waitFor({ state: "visible", timeout: 60_000 });
+
+    // ---- COLD LOAD: first entry into the channel ----
+    await channelEntry.click();
+    await expect(page.getByTestId("chat-title")).toHaveText(BUZZ_BUGS_NAME, {
+      timeout: 20_000,
+    });
+    // Let messages render.
+    await page.getByTestId("message-row").first().waitFor({ timeout: 20_000 });
+
+    // Give the cold-load aux backfill a generous window to commit.
+    await page.waitForTimeout(6_000);
+
+    const coldLoadReactionCount = await page
+      .getByTestId("message-reactions")
+      .count();
+    const coldLoadMessageCount = await page.getByTestId("message-row").count();
+    await page.screenshot({
+      path: "test-results/reaction-coldload/01-coldload.png",
+      fullPage: true,
+    });
+
+    // ---- DISAMBIGUATE: switch away then back ----
+    // Click any other channel, then return to buzz-bugs.
+    const otherChannel = page
+      .locator('[data-testid^="channel-"]')
+      .filter({ hasNot: channelEntry })
+      .first();
+    await otherChannel.click();
+    await page.waitForTimeout(1_500);
+    await channelEntry.click();
+    await expect(page.getByTestId("chat-title")).toHaveText(BUZZ_BUGS_NAME);
+    await page.getByTestId("message-row").first().waitFor({ timeout: 20_000 });
+    await page.waitForTimeout(6_000);
+
+    const afterSwitchReactionCount = await page
+      .getByTestId("message-reactions")
+      .count();
+    await page.screenshot({
+      path: "test-results/reaction-coldload/02-afterswitch.png",
+      fullPage: true,
+    });
+
+    console.log("=== REPRO RESULT ===");
+    console.log("cold-load message rows:", coldLoadMessageCount);
+    console.log("cold-load reaction containers:", coldLoadReactionCount);
+    console.log("after-switch reaction containers:", afterSwitchReactionCount);
+
+    // The proof: switching away-and-back is what "fixes" the symptom for the
+    // user today, so after-switch is the ground-truth count of reactions that
+    // exist for the loaded window. The fix means the FIRST cold-load paint must
+    // already show them — not zero, and not far short of after-switch. Pre-fix
+    // this was 0 on a busy workspace (all-or-nothing drop on the bundled REQ
+    // timeout); post-fix the kind:7 REQ lands well inside the 8s budget.
+    expect(
+      coldLoadReactionCount,
+      "cold-load must render reactions on first paint (pre-fix: 0)",
+    ).toBeGreaterThan(0);
+    if (afterSwitchReactionCount > 0) {
+      expect(
+        coldLoadReactionCount,
+        "cold-load reaction count must reach the after-switch ground truth",
+      ).toBeGreaterThanOrEqual(afterSwitchReactionCount);
+    }
+  } finally {
+    // Always dump diagnostics, even if a waitFor above timed out — this is the
+    // whole point of the harness. Without the finally, a timeout fires before
+    // the log block and we learn nothing.
+    const channelTestIds = await page
+      .locator('[data-testid^="channel-"]')
+      .evaluateAll((els) =>
+        els.map((e) => (e as HTMLElement).dataset.testid ?? ""),
+      )
+      .catch(() => [] as string[]);
+    console.log("=== CHANNELS RENDERED IN SIDEBAR ===");
+    console.log(JSON.stringify(channelTestIds));
+    console.log("=== WS FRAMES (reaction/REQ) ===");
+    for (const f of wsFrames) console.log(f);
+    console.log("=== AUX-BACKFILL SUB FRAMES (relay -> bridge) ===");
+    console.log("total upstream frames bridged:", upstreamFrameCount);
+    console.log("aux sub ids tracked:", [...auxSubIds].join(", "));
+    for (const f of auxFrameLog) console.log(f);
+    console.log("=== RELAY /query BODIES ===");
+    for (const b of relayQueryBodies.slice(0, 40)) console.log(b);
+    console.log("=== CONSOLE (last 40) ===");
+    for (const l of consoleLines.slice(-40)) console.log(l);
+    await page
+      .screenshot({
+        path: "test-results/reaction-coldload/99-final.png",
+        fullPage: true,
+      })
+      .catch(() => {});
+  }
+});
