Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,7 @@ export const ChannelPane = React.memo(function ChannelPane({
}
isLoading={isTimelineLoading}
mainEntries={mainTimelineEntries}
threadSummaries={threadSummaries}
messages={visibleMessages}
firstUnreadMessageId={firstUnreadMessageId}
unreadCount={unreadCount}
Expand Down
6 changes: 6 additions & 0 deletions desktop/src/features/messages/ui/MessageTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -592,6 +597,7 @@ const MessageTimelineBase = React.forwardRef<
mainEntries={
deferredMessages === messages ? mainEntries : undefined
}
threadSummaries={threadSummaries}
messages={deferredMessages}
onDelete={onDelete}
onEdit={onEdit}
Expand Down
12 changes: 10 additions & 2 deletions desktop/src/features/messages/ui/TimelineMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -93,6 +98,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
isMessageUnreadById,
messageFooters,
mainEntries,
threadSummaries,
messages,
onDelete,
onEdit,
Expand All @@ -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(
() =>
Expand Down
96 changes: 96 additions & 0 deletions desktop/tests/e2e/scroll-history.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading