mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
Tyler Longwell
parent
cfa2089831
commit
6abf614fd4
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user