Hydrate reactions for rendered messages (#1205)

Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tyler
2026-06-23 13:39:01 -04:00
committed by GitHub
parent 40070a5855
commit ed556f3deb
12 changed files with 909 additions and 26 deletions

View File

@@ -50,7 +50,11 @@ import {
import type { ChannelAgentSessionAgent } from "@/features/channels/ui/useChannelAgentSessions";
import { Button } from "@/shared/ui/button";
import type { useChannelFind } from "@/features/search/useChannelFind";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import {
buildMainTimelineEntries,
type MainTimelineEntry,
} from "@/features/messages/lib/threadPanel";
import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration";
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { isWelcomeChannel } from "@/features/onboarding/welcome";
@@ -563,6 +567,16 @@ export const ChannelPane = React.memo(function ChannelPane({
return messages.filter((message) => !isWelcomeSetupSystemMessage(message));
}, [activeChannel, messages]);
const mainTimelineEntries = React.useMemo(
() => buildMainTimelineEntries(visibleMessages),
[visibleMessages],
);
useRenderScopedReactionHydration({
activeChannel,
mainTimelineEntries,
threadHeadMessage,
threadMessages,
});
const videoReviewCommentsByRootId = React.useMemo(
() => buildVideoReviewCommentsByRootId(messages),
[messages],
@@ -677,6 +691,7 @@ export const ChannelPane = React.memo(function ChannelPane({
: "No channel selected"
}
isLoading={isTimelineLoading}
mainEntries={mainTimelineEntries}
messages={visibleMessages}
firstUnreadMessageId={firstUnreadMessageId}
unreadCount={unreadCount}

View File

@@ -5,6 +5,7 @@ import {
sortMessages,
} from "@/features/messages/lib/messageQueryKeys";
import { relayClient } from "@/shared/api/relayClient";
import { buildChannelStructuralAuxFilter } from "@/shared/api/relayChannelFilters";
import type { RelayEvent } from "@/shared/api/types";
import {
CHANNEL_TIMELINE_CONTENT_KINDS,
@@ -65,16 +66,16 @@ export async function mergeAuxEventsWithDeletionBackfill(input: {
}
/**
* After a content-kinds-only history fetch, pull the auxiliary events
* (reactions, edits, deletions) that reference the loaded messages — keyed by
* `#e` over their ids, not by a time window — and merge them into the same
* channel cache.
* After a content-kinds-only history fetch, pull structural auxiliary events
* (edits/deletions) that reference the loaded messages — keyed by `#e` over
* their ids, not by a time window — and merge them into the same channel cache.
* Reactions are hydrated separately for the rows the GUI renders.
*
* History fetches request content kinds only so the `limit` budget buys
* visible message depth (a reaction-heavy 200-event window was only ~136
* messages). The cost is that an edit/deletion for a visible message can fall
* outside any fetched time window — so aux must be pulled by reference, or a
* visible message renders stale (un-edited / not-deleted).
* visible message depth. The cost is that an edit/deletion for a visible
* message can fall outside any fetched time window — so structural 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.
@@ -92,9 +93,10 @@ export async function backfillAuxForMessages(
try {
const cacheKey = channelMessagesKey(channelId);
const cachedEvents = queryClient.getQueryData<RelayEvent[]>(cacheKey) ?? [];
const auxEvents = await relayClient.fetchAuxEventsForMessages(
const auxEvents = await relayClient.fetchAuxEventsByReference(
channelId,
messageIds,
buildChannelStructuralAuxFilter,
);
const mergedAuxEvents = await mergeAuxEventsWithDeletionBackfill({
channelId,

View File

@@ -0,0 +1,176 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
claimUnhydratedRenderScopedReactionIds,
collectRenderScopedReactionMessageIds,
hydrateRenderScopedReactions,
releaseRenderScopedReactionIds,
resetRenderScopedReactionHydration,
} from "./renderScopedReactions.ts";
import { formatTimelineMessages } from "./formatTimelineMessages.ts";
import { channelMessagesKey } from "./messageQueryKeys.ts";
const CHANNEL_ID = "36411e44-0e2d-4cfe-bd6e-567eb169db9f";
function hex(char) {
return char.repeat(64);
}
function event(id, kind, overrides = {}) {
return {
id,
pubkey: hex("a"),
kind,
created_at: 1_700_000_000,
content: "",
tags: [["h", CHANNEL_ID]],
sig: "sig",
...overrides,
};
}
function entry(message) {
return { message, summary: null };
}
function makeQueryClientStub(initialEvents = []) {
const store = new Map([
[JSON.stringify(channelMessagesKey(CHANNEL_ID)), initialEvents],
]);
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;
},
};
}
test.afterEach(() => {
resetRenderScopedReactionHydration();
});
test("collects main timeline and open thread messages without hidden replies", () => {
const main = event(hex("1"), 9);
const hiddenCollapsedReply = event(hex("2"), 9, {
tags: [["e", main.id]],
});
const threadHead = event(hex("3"), 9);
const visibleThreadReply = event(hex("4"), 9, {
tags: [["e", threadHead.id]],
});
assert.deepEqual(
collectRenderScopedReactionMessageIds({
mainEntries: [entry(main), entry(threadHead)],
threadHeadMessage: threadHead,
threadEntries: [entry(visibleThreadReply)],
}),
[main.id, threadHead.id, visibleThreadReply.id],
);
assert.ok(
!collectRenderScopedReactionMessageIds({
mainEntries: [entry(main), entry(threadHead)],
threadHeadMessage: threadHead,
threadEntries: [entry(visibleThreadReply)],
}).includes(hiddenCollapsedReply.id),
);
});
test("claims each rendered message id once per channel and can retry released ids", () => {
assert.deepEqual(
claimUnhydratedRenderScopedReactionIds(CHANNEL_ID, [
hex("1"),
hex("2"),
hex("1"),
]),
[hex("1"), hex("2")],
);
assert.deepEqual(
claimUnhydratedRenderScopedReactionIds(CHANNEL_ID, [hex("1"), hex("2")]),
[],
);
assert.deepEqual(
claimUnhydratedRenderScopedReactionIds("other-channel", [hex("1")]),
[hex("1")],
);
releaseRenderScopedReactionIds(CHANNEL_ID, [hex("2")]);
assert.deepEqual(
claimUnhydratedRenderScopedReactionIds(CHANNEL_ID, [hex("1"), hex("2")]),
[hex("2")],
);
});
test("hydrates visible reactions into the channel timeline cache", async () => {
const messageId = hex("1");
const reactionId = hex("2");
const currentUser = hex("c");
const message = event(messageId, 9, {
pubkey: hex("a"),
content: "ship it?",
});
const reaction = event(reactionId, 7, {
pubkey: currentUser,
content: "✅",
tags: [["e", messageId]],
});
const queryClient = makeQueryClientStub([message]);
const calls = [];
await hydrateRenderScopedReactions({
channelId: CHANNEL_ID,
messageIds: [messageId],
queryClient,
deps: {
fetchReactionEventsForMessages: async (channelId, messageIds) => {
calls.push({ channelId, messageIds });
return [reaction];
},
},
});
assert.deepEqual(calls, [{ channelId: CHANNEL_ID, messageIds: [messageId] }]);
const cached = queryClient.getQueryData(channelMessagesKey(CHANNEL_ID));
assert.ok(cached.some((e) => e.id === reactionId));
const timeline = formatTimelineMessages(cached, null, currentUser, null);
assert.deepEqual(
timeline
.find((m) => m.id === messageId)
?.reactions?.map((r) => ({
count: r.count,
emoji: r.emoji,
mine: r.reactedByCurrentUser,
})),
[{ count: 1, emoji: "✅", mine: true }],
);
});
test("failed hydration releases ids so the next render can retry", async () => {
const messageId = hex("1");
const queryClient = makeQueryClientStub([event(messageId, 9)]);
await hydrateRenderScopedReactions({
channelId: CHANNEL_ID,
messageIds: [messageId],
queryClient,
deps: {
fetchReactionEventsForMessages: async () => {
throw new Error("relay timeout");
},
},
});
assert.deepEqual(
claimUnhydratedRenderScopedReactionIds(CHANNEL_ID, [messageId]),
[messageId],
);
});

View File

@@ -0,0 +1,140 @@
import type { QueryClient } from "@tanstack/react-query";
import { channelMessagesKey, sortMessages } from "./messageQueryKeys";
import type { MainTimelineEntry } from "./threadPanel";
import type { TimelineMessage } from "../types";
import { relayClient } from "@/shared/api/relayClient";
import { buildChannelReactionAuxFilter } from "@/shared/api/relayChannelFilters";
import type { RelayEvent } from "@/shared/api/types";
export type RenderScopedReactionDeps = {
fetchReactionEventsForMessages: (
channelId: string,
messageIds: string[],
) => Promise<RelayEvent[]>;
};
const defaultDeps: RenderScopedReactionDeps = {
fetchReactionEventsForMessages: (channelId, messageIds) =>
relayClient.fetchAuxEventsByReference(
channelId,
messageIds,
buildChannelReactionAuxFilter,
),
};
const hydratedMessageIdsByChannel = new Map<string, Set<string>>();
export function resetRenderScopedReactionHydration() {
hydratedMessageIdsByChannel.clear();
}
function hydratedSetForChannel(channelId: string): Set<string> {
let hydrated = hydratedMessageIdsByChannel.get(channelId);
if (!hydrated) {
hydrated = new Set();
hydratedMessageIdsByChannel.set(channelId, hydrated);
}
return hydrated;
}
function pushUniqueMessageId(ids: string[], seen: Set<string>, id?: string) {
if (!id || seen.has(id)) {
return;
}
seen.add(id);
ids.push(id);
}
export function collectRenderScopedReactionMessageIds(input: {
mainEntries: readonly MainTimelineEntry[];
threadHeadMessage?: TimelineMessage | null;
threadEntries?: readonly MainTimelineEntry[];
}): string[] {
const ids: string[] = [];
const seen = new Set<string>();
for (const entry of input.mainEntries) {
pushUniqueMessageId(ids, seen, entry.message.id);
}
pushUniqueMessageId(ids, seen, input.threadHeadMessage?.id);
for (const entry of input.threadEntries ?? []) {
pushUniqueMessageId(ids, seen, entry.message.id);
}
return ids;
}
export function claimUnhydratedRenderScopedReactionIds(
channelId: string,
messageIds: readonly string[],
): string[] {
const hydrated = hydratedSetForChannel(channelId);
const claimed: string[] = [];
for (const id of messageIds) {
if (hydrated.has(id)) {
continue;
}
hydrated.add(id);
claimed.push(id);
}
return claimed;
}
export function releaseRenderScopedReactionIds(
channelId: string,
messageIds: readonly string[],
) {
const hydrated = hydratedMessageIdsByChannel.get(channelId);
if (!hydrated) {
return;
}
for (const id of messageIds) {
hydrated.delete(id);
}
if (hydrated.size === 0) {
hydratedMessageIdsByChannel.delete(channelId);
}
}
export async function hydrateRenderScopedReactions(input: {
channelId: string;
messageIds: readonly string[];
queryClient: QueryClient;
deps?: RenderScopedReactionDeps;
}): Promise<void> {
const messageIds = claimUnhydratedRenderScopedReactionIds(
input.channelId,
input.messageIds,
);
if (messageIds.length === 0) {
return;
}
try {
const reactionEvents = await (
input.deps ?? defaultDeps
).fetchReactionEventsForMessages(input.channelId, messageIds);
if (reactionEvents.length === 0) {
return;
}
input.queryClient.setQueryData<RelayEvent[]>(
channelMessagesKey(input.channelId),
(current = []) => sortMessages([...current, ...reactionEvents]),
);
} catch (error) {
releaseRenderScopedReactionIds(input.channelId, messageIds);
console.error(
"Failed to hydrate visible reactions for channel",
input.channelId,
error,
);
}
}

View File

@@ -0,0 +1,49 @@
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { MainTimelineEntry } from "./threadPanel";
import type { TimelineMessage } from "../types";
import type { Channel } from "@/shared/api/types";
import {
collectRenderScopedReactionMessageIds,
hydrateRenderScopedReactions,
} from "./renderScopedReactions";
export function useRenderScopedReactionHydration(input: {
activeChannel: Channel | null;
mainTimelineEntries: MainTimelineEntry[];
threadHeadMessage: TimelineMessage | null;
threadMessages: MainTimelineEntry[];
}) {
const queryClient = useQueryClient();
React.useEffect(() => {
const channelId = input.activeChannel?.id;
if (!channelId || input.activeChannel?.channelType === "forum") {
return;
}
const messageIds = collectRenderScopedReactionMessageIds({
mainEntries: input.mainTimelineEntries,
threadHeadMessage: input.threadHeadMessage,
threadEntries: input.threadMessages,
});
if (messageIds.length === 0) return;
const timeout = window.setTimeout(() => {
void hydrateRenderScopedReactions({
channelId,
messageIds,
queryClient,
});
}, 0);
return () => window.clearTimeout(timeout);
}, [
input.activeChannel,
input.mainTimelineEntries,
input.threadHeadMessage,
input.threadMessages,
queryClient,
]);
}

View File

@@ -9,6 +9,7 @@ import {
} from "@/features/messages/lib/timelineSnapshot";
import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay";
import type { TimelineMessage } from "@/features/messages/types";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ChannelType } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
@@ -32,6 +33,7 @@ type MessageTimelineProps = {
channelName?: string;
channelType?: ChannelType | null;
messages: TimelineMessage[];
mainEntries?: MainTimelineEntry[];
directMessageIntro?: {
displayName: string;
participants: DirectMessageIntroParticipant[];
@@ -137,6 +139,7 @@ const MessageTimelineBase = React.forwardRef<
channelIntro = null,
directMessageIntro = null,
messages,
mainEntries,
isLoading = false,
emptyTitle = "No messages yet",
emptyDescription = "Send the first message to start the thread.",
@@ -539,6 +542,9 @@ const MessageTimelineBase = React.forwardRef<
isFollowingThreadById={isFollowingThreadById}
isMessageUnreadById={isMessageUnreadById}
messageFooters={messageFooters}
mainEntries={
deferredMessages === messages ? mainEntries : undefined
}
messages={deferredMessages}
onDelete={onDelete}
onEdit={onEdit}

View File

@@ -38,6 +38,7 @@ type TimelineMessageListProps = {
isFollowingThreadById?: (rootId: string) => boolean;
isMessageUnreadById?: (messageId: string) => boolean;
messageFooters?: Record<string, React.ReactNode>;
mainEntries?: ReturnType<typeof buildMainTimelineEntries>;
messages: TimelineMessage[];
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
@@ -103,13 +104,15 @@ type TimelineDayGroup = {
};
function buildTimelineRenderRows({
entries,
firstUnreadMessageId,
messages,
}: {
entries?: ReturnType<typeof buildMainTimelineEntries>;
firstUnreadMessageId: string | null;
messages: TimelineMessage[];
}): TimelineRenderRow[] {
const entries = buildMainTimelineEntries(messages);
entries ??= buildMainTimelineEntries(messages);
const rows: TimelineRenderRow[] = [];
let previousMessage: TimelineMessage | null = null;
@@ -191,6 +194,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
isFollowingThreadById,
isMessageUnreadById,
messageFooters,
mainEntries,
messages,
onDelete,
onEdit,
@@ -208,8 +212,13 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
unfollowThreadById,
}: TimelineMessageListProps) {
const rows = React.useMemo(
() => buildTimelineRenderRows({ firstUnreadMessageId, messages }),
[firstUnreadMessageId, messages],
() =>
buildTimelineRenderRows({
entries: mainEntries,
firstUnreadMessageId,
messages,
}),
[firstUnreadMessageId, mainEntries, messages],
);
const dayGroups = React.useMemo(() => buildTimelineDayGroups(rows), [rows]);

View File

@@ -9,6 +9,7 @@ import {
import { resetMediaCaches } from "@/shared/lib/mediaUrl";
import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache";
import { clearAllDrafts } from "@/features/messages/lib/useDrafts";
import { resetRenderScopedReactionHydration } from "@/features/messages/lib/renderScopedReactions";
import { resetAgentObserverStore } from "@/features/agents/observerRelayStore";
import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState";
@@ -29,6 +30,7 @@ function resetWorkspaceState(): void {
resetSidebarRelayConnectionCardState();
resetMediaCaches();
resetVideoPlayerState();
resetRenderScopedReactionHydration();
clearSearchHitEventCache();
clearAllDrafts();
}

View File

@@ -4,6 +4,8 @@ import test from "node:test";
import {
buildChannelAuxDeletionFilter,
buildChannelAuxFilter,
buildChannelReactionAuxFilter,
buildChannelStructuralAuxFilter,
} from "./relayChannelFilters.ts";
const CHANNEL = "36411e44-0e2d-4cfe-bd6e-567eb169db9f";
@@ -27,3 +29,17 @@ test("buildChannelAuxDeletionFilter keys on #e only, no #h", () => {
assert.deepEqual(filter["#e"], IDS);
assert.equal("#h" in filter, false);
});
test("buildChannelReactionAuxFilter fetches only kind:7 by #e", () => {
const filter = buildChannelReactionAuxFilter(CHANNEL, IDS);
assert.deepEqual(filter.kinds, [7]);
assert.deepEqual(filter["#e"], IDS);
assert.equal("#h" in filter, false);
});
test("buildChannelStructuralAuxFilter excludes reactions", () => {
const filter = buildChannelStructuralAuxFilter(CHANNEL, IDS);
assert.deepEqual(filter.kinds, [5, 9005, 40003]);
assert.deepEqual(filter["#e"], IDS);
assert.equal("#h" in filter, false);
});

View File

@@ -5,6 +5,8 @@ import {
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,9 +44,10 @@ 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
* live subscription ({@link buildChannelFilter}, which keeps the broad
* {@link CHANNEL_EVENT_KINDS} set).
* {@link buildChannelStructuralAuxFilter} and
* {@link buildChannelReactionAuxFilter}, and arrive for future messages
* through the live subscription ({@link buildChannelFilter}, which keeps the
* broad {@link CHANNEL_EVENT_KINDS} set).
*/
export function buildChannelHistoryFilter(
channelId: string,
@@ -65,10 +68,10 @@ 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
* visible message still applies — see {@link buildChannelHistoryFilter}.
* Aux-backfill filter for one chunk of loaded message ids: pulls auxiliary
* events ({@link CHANNEL_AUX_EVENT_KINDS}) that reference those ids by `#e`.
* Keyed by reference, not time, so a late edit/deletion for an old visible
* message still applies — see {@link buildChannelHistoryFilter}.
*/
export function buildChannelAuxFilter(
_channelId: string,
@@ -77,6 +80,34 @@ export function buildChannelAuxFilter(
return buildChannelAuxKindFilter(messageIds, [...CHANNEL_AUX_EVENT_KINDS]);
}
/**
* Structural aux filter for history backfill: edits/deletions only. Reactions
* are hydrated from the rows the GUI actually renders, so the slow kind:5 scan
* never shares a request with first-paint reaction pills.
*/
export function buildChannelStructuralAuxFilter(
_channelId: string,
messageIds: string[],
): RelaySubscriptionFilter {
return buildChannelAuxKindFilter(messageIds, [
KIND_DELETION,
KIND_NIP29_DELETE_EVENT,
KIND_STREAM_MESSAGE_EDIT,
]);
}
/**
* Reactions-only filter for the message rows the GUI is currently rendering.
* Keep this separate from structural aux backfill so the slow kind:5 deletion
* scan cannot delay reaction pills that affect visible pixels right now.
*/
export function buildChannelReactionAuxFilter(
_channelId: string,
messageIds: string[],
): RelaySubscriptionFilter {
return buildChannelAuxKindFilter(messageIds, [KIND_REACTION]);
}
export function buildChannelAuxDeletionFilter(
_channelId: string,
auxEventIds: string[],

View File

@@ -22,7 +22,6 @@ import {
import {
AUX_BACKFILL_CHUNK_SIZE,
buildChannelAuxDeletionFilter,
buildChannelAuxFilter,
buildChannelFilter,
buildChannelHistoryFilter,
buildChannelMentionFilter,
@@ -168,14 +167,18 @@ export class RelayClient {
);
}
async fetchAuxEventsForMessages(
async fetchAuxEventsByReference(
channelId: string,
messageIds: string[],
): Promise<RelayEvent[]> {
referencedEventIds: string[],
buildFilter: (
channelId: string,
eventIds: string[],
) => RelaySubscriptionFilter,
) {
return this.fetchChunkedAuxEvents(
channelId,
messageIds,
buildChannelAuxFilter,
referencedEventIds,
buildFilter,
);
}

View File

@@ -0,0 +1,434 @@
import { expect, type Page, test } from "@playwright/test";
import { decode } from "nostr-tools/nip19";
import { getPublicKey } from "nostr-tools/pure";
import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
// 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";
function loadTestIdentity() {
const rawPrivateKey = process.env.BUZZ_PRIVATE_KEY ?? "";
if (!rawPrivateKey) {
throw new Error("BUZZ_PRIVATE_KEY is required for the live staging repro");
}
const privateKey = rawPrivateKey.startsWith("nsec")
? bytesToHex(decode(rawPrivateKey).data)
: bytesToHex(hexToBytes(rawPrivateKey));
return {
privateKey,
pubkey: getPublicKey(hexToBytes(privateKey)),
username: "Max",
};
}
const TEST_IDENTITY = loadTestIdentity();
async function collectRenderedReactionState(page: Page) {
return page.locator("[data-message-id]").evaluateAll((rows) =>
rows.map((row) => {
const el = row as HTMLElement;
const reactions = Array.from(
el.querySelectorAll<HTMLElement>(
'[data-testid="message-reactions"] button',
),
).map(
(button) =>
button.getAttribute("aria-label") ?? button.textContent?.trim() ?? "",
);
return {
id: el.dataset.messageId ?? "",
hasReactions: reactions.length > 0,
reactions,
text: el.innerText.replace(/\s+/g, " ").slice(0, 120),
};
}),
);
}
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: TEST_IDENTITY.pubkey },
);
await page.addInitScript(
({ identity, relayHttpUrl, relayWsUrl }) => {
(window as unknown as { __BUZZ_E2E__: unknown }).__BUZZ_E2E__ = {
mode: "relay",
identity,
relayHttpUrl,
relayWsUrl,
};
},
{ identity: TEST_IDENTITY, 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}`);
void ws
.close({ code: ev.code || 1000, reason: "upstream closed" })
.catch(() => {});
});
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 coldLoadState = await collectRenderedReactionState(page);
const coldLoadMessageCount = coldLoadState.length;
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();
const afterSwitchState = await collectRenderedReactionState(page);
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("after-switch message rows:", afterSwitchState.length);
console.log("cold-load reaction containers:", coldLoadReactionCount);
console.log("after-switch reaction containers:", afterSwitchReactionCount);
const coldReactionIds = new Set(
coldLoadState.filter((r) => r.hasReactions).map((r) => r.id),
);
const afterReactionIds = new Set(
afterSwitchState.filter((r) => r.hasReactions).map((r) => r.id),
);
const afterSwitchOnlyReactionRows = afterSwitchState.filter(
(r) => r.hasReactions && !coldReactionIds.has(r.id),
);
const coldLoadOnlyReactionRows = coldLoadState.filter(
(r) => r.hasReactions && !afterReactionIds.has(r.id),
);
console.log(
"cold-load reaction row ids:",
JSON.stringify([...coldReactionIds]),
);
console.log(
"after-switch reaction row ids:",
JSON.stringify([...afterReactionIds]),
);
console.log(
"after-switch-only reaction rows:",
JSON.stringify(afterSwitchOnlyReactionRows),
);
console.log(
"cold-load-only reaction rows:",
JSON.stringify(coldLoadOnlyReactionRows),
);
// 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);
expect(
coldLoadState.map((row) => row.id),
"cold-load and after-switch must compare the same rendered message rows",
).toEqual(afterSwitchState.map((row) => row.id));
expect(
afterSwitchOnlyReactionRows,
"cold-load must hydrate every reaction-bearing row visible after switch-back",
).toEqual([]);
} 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(() => {});
}
});