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); +});