fix(desktop): keep thread-summary badges mounted through scrollback prepends (#1533)

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
Tyler
2026-07-06 12:59:37 -04:00
committed by GitHub
co-authored by npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d Tyler Longwell
parent cfa2089831
commit 6abf614fd4
4 changed files with 113 additions and 2 deletions
@@ -611,6 +611,7 @@ export const ChannelPane = React.memo(function ChannelPane({
}
isLoading={isTimelineLoading}
mainEntries={mainTimelineEntries}
threadSummaries={threadSummaries}
messages={visibleMessages}
firstUnreadMessageId={firstUnreadMessageId}
unreadCount={unreadCount}
@@ -10,6 +10,7 @@ import {
import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay";
import type { TimelineMessage } from "@/features/messages/types";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ChannelType } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
@@ -37,6 +38,9 @@ type MessageTimelineProps = {
huddleMemberPubkeysPending?: boolean;
messages: TimelineMessage[];
mainEntries?: MainTimelineEntry[];
/** Relay thread summaries (root id summary) for the deferred-pass entry
* fallback, so badge rows survive while a scrollback page commits. */
threadSummaries?: ReadonlyMap<string, ChannelWindowThreadSummary>;
directMessageIntro?: {
displayName: string;
participants: DirectMessageIntroParticipant[];
@@ -143,6 +147,7 @@ const MessageTimelineBase = React.forwardRef<
directMessageIntro = null,
messages,
mainEntries,
threadSummaries,
isLoading = false,
emptyTitle = "No messages yet",
emptyDescription = "Send the first message to start the thread.",
@@ -592,6 +597,7 @@ const MessageTimelineBase = React.forwardRef<
mainEntries={
deferredMessages === messages ? mainEntries : undefined
}
threadSummaries={threadSummaries}
messages={deferredMessages}
onDelete={onDelete}
onEdit={onEdit}
@@ -11,6 +11,7 @@ import {
import { THREAD_REPLY_ROW_MARGIN_INLINE_REM } from "@/features/messages/lib/threadTreeLayout";
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore";
import {
buildVideoReviewCommentsByRootId,
buildVideoReviewContextForMessage,
@@ -45,6 +46,10 @@ type TimelineMessageListProps = {
/** Hoisted main-timeline entries (computed once in ChannelPane). Falls back
* to deriving them here when omitted (e.g. the deferred-render pass). */
mainEntries?: MainTimelineEntry[];
/** Relay thread summaries keyed by thread root id. Keeps badge rows alive on
* the deferred-render fallback replies usually are not local timeline
* rows, so without the relay map every summary row unmounts mid-scrollback. */
threadSummaries?: ReadonlyMap<string, ChannelWindowThreadSummary>;
messages: TimelineMessage[];
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
@@ -93,6 +98,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
isMessageUnreadById,
messageFooters,
mainEntries,
threadSummaries,
messages,
onDelete,
onEdit,
@@ -110,8 +116,10 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
unfollowThreadById,
}: TimelineMessageListProps) {
const entries = React.useMemo(
() => mainEntries ?? buildMainTimelineEntries(messages),
[mainEntries, messages],
() =>
mainEntries ??
buildMainTimelineEntries(messages, undefined, threadSummaries, profiles),
[mainEntries, messages, profiles, threadSummaries],
);
const reviewCommentsByRootId = React.useMemo(
() =>
+96
View File
@@ -1834,3 +1834,99 @@ test("older-history prepend keeps the reading row fixed (no jump to oldest)", as
"Jump to latest",
);
});
// Regression: thread-summary badges must not flash during a scrollback prepend.
// When an older page lands, the urgent render pass still paints the OLD
// deferred message snapshot, so MessageTimeline passes `mainEntries=undefined`
// and TimelineMessageList rebuilds entries itself. That fallback used to drop
// the relay summary map — and since thread replies are usually not local
// timeline rows, every relay-driven badge unmounted for the whole deferred
// window and remounted when the heavy render committed (the visible flash).
// A MutationObserver is commit-granular, so it catches even a single-frame
// unmount that a polling assertion would race past.
test("thread summary badge survives an older-history prepend without unmounting", async ({
page,
}, testInfo) => {
testInfo.setTimeout(60_000);
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
// Seed a reply to the NEWEST deep-history row before the channel is opened:
// no live subscription exists yet, so the reply lands only in the mock store.
// It is not a top-level timeline row, so the badge rendered for #599 is
// driven purely by the relay-shaped 39005 page summary — exactly the state
// the deferred-pass entry fallback used to drop.
await page.evaluate(() => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "deep-history",
content: "summary-only reply",
parentEventId: "mock-deep-history-599",
});
});
await page.getByTestId("channel-deep-history").click();
await expect(page.getByTestId("chat-title")).toHaveText("deep-history");
const timeline = page.getByTestId("message-timeline");
const badgeSelector =
'[data-testid="message-thread-summary"][data-thread-head-id="mock-deep-history-599"]';
await expect(timeline.locator(badgeSelector)).toBeVisible();
const oldestRenderedIndex = () =>
timeline.evaluate((element) => {
let min = Number.POSITIVE_INFINITY;
for (const row of (
element as HTMLDivElement
).querySelectorAll<HTMLElement>("[data-message-id]")) {
const match = row.textContent?.match(/#(\d+)/);
if (match) min = Math.min(min, Number(match[1]));
}
return Number.isFinite(min) ? min : null;
});
const oldestBefore = await oldestRenderedIndex();
expect(oldestBefore).not.toBeNull();
// Arm the observer AFTER the initial window has settled, so the only
// mutations it sees are the prepend landing and any (buggy) badge unmount.
await timeline.evaluate((element, selector) => {
const scroller = element as HTMLDivElement;
const win = window as typeof window & {
__SUMMARY_FLASH_PROBE__?: { missingCommits: number };
};
const probe = { missingCommits: 0 };
win.__SUMMARY_FLASH_PROBE__ = probe;
new MutationObserver(() => {
if (!scroller.querySelector(selector)) {
probe.missingCommits += 1;
}
}).observe(scroller, { childList: true, subtree: true });
}, badgeSelector);
// Scroll back until a genuinely older page has landed.
await timeline.hover();
await expect
.poll(
async () => {
await page.mouse.wheel(0, -4000);
await page.waitForTimeout(50);
return oldestRenderedIndex();
},
{ timeout: 20_000 },
)
.toBeLessThan(oldestBefore ?? Number.POSITIVE_INFINITY);
// The badge row must have stayed mounted through every DOM commit of the
// prepend — zero commits observed it missing — and still be attached now.
const missingCommits = await page.evaluate(
() =>
(
window as typeof window & {
__SUMMARY_FLASH_PROBE__?: { missingCommits: number };
}
).__SUMMARY_FLASH_PROBE__?.missingCommits,
);
expect(missingCommits).toBe(0);
await expect(timeline.locator(badgeSelector)).toHaveCount(1);
});