From 77801a6efdbd18592dc9405906c8e213567216b9 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Mon, 6 Jul 2026 11:05:44 -0400 Subject: [PATCH] fix(desktop): keep thread-summary badges mounted through scrollback prepends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an older-history page lands, the urgent render pass still paints the previous deferred message snapshot, so MessageTimeline withholds the hoisted mainEntries and TimelineMessageList rebuilds entries from the raw messages. That fallback dropped the relay thread-summary map (and profiles), and since thread replies are usually not local timeline rows, every relay-driven badge row unmounted for the whole deferred window and remounted when the heavy render committed — a visible flash of the summaries on already-loaded rows during every scrollback page load. The summary map is keyed by thread root id and independent of which message snapshot is painted, so it is safe on both passes: thread it through to the deferred-pass fallback. e2e regression test arms a MutationObserver (commit- granular) around a badge whose replies exist only in the mock store, scrolls back a page, and asserts the badge row never left the DOM; verified red without the fix, green with it. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../src/features/channels/ui/ChannelPane.tsx | 1 + .../features/messages/ui/MessageTimeline.tsx | 6 ++ .../messages/ui/TimelineMessageList.tsx | 12 ++- desktop/tests/e2e/scroll-history.spec.ts | 96 +++++++++++++++++++ 4 files changed, 113 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 0ed81d1850..37474a2dee 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -611,6 +611,7 @@ export const ChannelPane = React.memo(function ChannelPane({ } isLoading={isTimelineLoading} mainEntries={mainTimelineEntries} + threadSummaries={threadSummaries} messages={visibleMessages} firstUnreadMessageId={firstUnreadMessageId} unreadCount={unreadCount} diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index ebb5b1ec0f..e007e3f915 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -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; 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} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 2590078e82..fdc9679104 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -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; 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( () => diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts index 181afa75fd..77043c8496 100644 --- a/desktop/tests/e2e/scroll-history.spec.ts +++ b/desktop/tests/e2e/scroll-history.spec.ts @@ -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("[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); +});