mirror of
https://github.com/block/buzz.git
synced 2026-11-07 01:41:06 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
176
desktop/src/features/messages/lib/renderScopedReactions.test.mjs
Normal file
176
desktop/src/features/messages/lib/renderScopedReactions.test.mjs
Normal 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],
|
||||
);
|
||||
});
|
||||
140
desktop/src/features/messages/lib/renderScopedReactions.ts
Normal file
140
desktop/src/features/messages/lib/renderScopedReactions.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
434
desktop/tests/e2e/reaction-coldload-repro.spec.ts
Normal file
434
desktop/tests/e2e/reaction-coldload-repro.spec.ts
Normal 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(() => {});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user