mirror of
https://github.com/block/buzz.git
synced 2026-11-10 11:21:05 +00:00
## Problem When you follow a link to a message, the channel timeline should scroll to it and flash the row for about 2 seconds. On `main` the flash often does not appear: | How you reach the message | Flash on `main` | |---|---| | Same channel, row already on screen | yes | | Same channel, row loaded but off screen | **no** | | Same channel, row in older history not loaded yet | yes | | From another channel, row near the bottom | **no** | | From another channel, row further up | **no**, and the timeline stays at the bottom | The main timeline is always virtualized. `useAnchoredScroll` had three ways to scroll to a target there. Only one of them, "the row is already fully on screen", fired the flash. In the other cases the scroll returned without the flash, and the follow-up target effect marked the target handled without checking the result, so the flash never fired. Opening a link from another channel had a second problem. The row could look on screen while the channel's first bottom pin was still in flight in Virtua. The shortcut used a direct DOM `scrollTo`, which does not cancel that pin, so Virtua kept going to the bottom and took the target with it. Top-level message links also open the thread panel, and that resize settled the timeline at the bottom again. A third problem also exists on `main`. Centering a row near the top of the loaded history loads an older page. Opening a channel also starts a refresh of the newest channel window, and that refresh then drops the older pages from the head of the list. A head trim was not passed to Virtua as a `shift`, so the rows above the reader vanished with no compensation and the view fell to the bottom about 0.3–0.8 s after the flash started. The deep-link e2e test stopped checking the flash in #5306, so CI did not catch this. ## Change - In virtualized mode, every target scroll now goes through Virtua's `scrollToIndex` (centered). That replaces any bottom pin still in flight. Smooth scrolling is kept only when the row is already in the viewport. - Every target scroll that Virtua accepts now fires the flash. The highlight is keyed by message id, so a row that Virtua mounts after the scroll still shows it. - The scroll returns `true` once Virtua accepts the target and `false` while the target is not loaded yet. The follow-up target effect and the search retry now just read that value. This removes the "phase 1" special case and the duplicate DOM lookup. - A target scroll also clears the virtualizer's at-bottom state, so a resize right after it (for example the thread panel opening) does not pin back to the bottom. - Only an accepted target scroll retires bottom intent. It also cancels the hook's queued second mount-to-bottom frame. A target that is not loaded yet leaves the opening bottom pin alone while it retries. - `didPrependVirtualizedTimeline` now also admits a pure head trim (the new keys are an exact suffix of the previous keys), so Virtua's `shift` keeps the reading row in place when a window refresh drops older pages. The oldest loaded day has no divider while more history exists, so a head trim stays an exact suffix even when it cuts inside a day. The non-virtualized path (thread panel) is unchanged. ## Screenshots Before and after, from another channel, for a link to a row further up: https://github.com/block/buzz/pull/8660#issuecomment-6481877304 ## Testing Evidence at head `653d89766`: - New `desktop/tests/e2e/message-target-highlight.spec.ts`: a 5-row table with the cases above. Each case asserts that the target row is on screen **and** has the flash class at the same time, and that the row is still on screen 2.2 s later, after the flash ends. 15/15 passed (3 runs each). - On unfixed `main`, 3 of the 5 cases fail the flash check. - With only the head-trim change reverted, "same channel, loaded off screen" and "other channel, further up" fail the 2.2 s check. - `useAnchoredScroll.lifecycle.test.mjs`: an on-screen virtual target scrolls through the virtualizer, makes no direct DOM write, retires bottom intent and does not re-pin later. New: a target that is not loaded yet keeps the bottom pin. It fails with the old cancel order. - `virtualizedTimelineItems.test.mjs`: new rows for a head trim inside the oldest day and at its boundary (admitted, with the size cache aligned), and for a trim plus append and a middle removal (rejected). - `pnpm test` (6837 + 161 pass), `pnpm check` (tsc via pre-push, biome, px-text, pubkey truncation). - These e2e specs, run together: message-target-highlight, scroll-history, virtualization, timeline-no-shift, unread-pill, overscroll-boundary, dm-history-live-regression, live-broadcast-reply-timeline, message-copy-link, thread-focus-mode, thread-follow, thread-reply-anchor-roleplay, thread-unread, channels, messaging. 257 passed, 0 failed. Later commits at `b8e172672` add no production behavior. They move one helper above `computeAnchor`'s doc comment, and add a lifecycle test (from Cerveau's probe): a target that loads before the queued second mount frame is not scrolled back to the bottom. That test fails without the mount-frame cancellation. Pre-push `pnpm test` passed at `b8e172672`. Adversarial review by Auto (virtualizer and callers) and Cerveau (lifecycle, tests and thread panel). Their must-fix findings are fixed above, and both approved `653d89766`. Follow-up, not in this PR: after a jump to a row at the very bottom, centering is clamped, so no scroll event fires and the at-bottom state stays false until the next scroll. `main` has the same gap for `isAtBottom`. Not tested yet in the native app. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Cerveau <fc91c5708d48aecea993d9219505dc841bc7b0222b7ed325b8edfd026bfb43b5@buzz.block.builderlab.xyz>
182 lines
6.3 KiB
TypeScript
182 lines
6.3 KiB
TypeScript
import { expect, type Page, test } from "@playwright/test";
|
|
|
|
import { installMockBridge } from "../helpers/bridge";
|
|
|
|
// A message target (deep link, link click, jump) must flash wherever the row
|
|
// starts relative to the viewport. The virtualized timeline scrolls by
|
|
// different strategies for each starting position; the flash must not depend
|
|
// on which one ran.
|
|
|
|
const HIGHLIGHT_CLASS = "route-target-highlight-fade";
|
|
|
|
type TargetCase = {
|
|
name: string;
|
|
/** Navigate from another channel, as a link click from elsewhere does. */
|
|
fromOtherChannel: boolean;
|
|
prependCount: number;
|
|
pick: (ids: { mounted: string[]; prepended: string[] }) => string;
|
|
};
|
|
|
|
const CASES: TargetCase[] = [
|
|
{
|
|
name: "same channel, row on screen",
|
|
fromOtherChannel: false,
|
|
prependCount: 50,
|
|
pick: ({ mounted }) => mounted[mounted.length - 3],
|
|
},
|
|
{
|
|
name: "same channel, row loaded off screen",
|
|
fromOtherChannel: false,
|
|
prependCount: 50,
|
|
pick: ({ mounted }) => mounted[2],
|
|
},
|
|
{
|
|
name: "same channel, row in unloaded older history",
|
|
fromOtherChannel: false,
|
|
prependCount: 600,
|
|
pick: ({ prepended }) => prepended[Math.floor(prepended.length / 8)],
|
|
},
|
|
{
|
|
name: "other channel, row near the bottom",
|
|
fromOtherChannel: true,
|
|
prependCount: 50,
|
|
pick: ({ mounted }) => mounted[mounted.length - 3],
|
|
},
|
|
{
|
|
name: "other channel, row further up",
|
|
fromOtherChannel: true,
|
|
prependCount: 50,
|
|
pick: ({ mounted }) => mounted[2],
|
|
},
|
|
];
|
|
|
|
async function seedGeneral(page: Page, prependCount: number) {
|
|
await installMockBridge(page);
|
|
await page.goto("/");
|
|
await page.waitForFunction(
|
|
() =>
|
|
typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
|
|
typeof window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ === "function",
|
|
);
|
|
return page.evaluate((count) => {
|
|
for (let index = 0; index < 40; index += 1) {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "general",
|
|
content: `visible current ${index}\nsecond line ${index}`,
|
|
createdAt: 1_700_000_000 + index,
|
|
});
|
|
}
|
|
const events = window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__?.({
|
|
channelName: "general",
|
|
count,
|
|
lineCount: 3,
|
|
});
|
|
return (events ?? []).map((event) => event.id);
|
|
}, prependCount);
|
|
}
|
|
|
|
for (const targetCase of CASES) {
|
|
test(`message target flashes: ${targetCase.name}`, async ({ page }) => {
|
|
const prepended = await seedGeneral(page, targetCase.prependCount);
|
|
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
const timeline = page.getByTestId("message-timeline");
|
|
await expect(timeline).toContainText("visible current 39");
|
|
// The second bottom-pin pass runs one frame after mount; let it land so
|
|
// "on screen" and "off screen" are measured against the settled view.
|
|
await page.evaluate(
|
|
() =>
|
|
new Promise((resolve) =>
|
|
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
|
),
|
|
);
|
|
const mounted = await timeline.evaluate((el) =>
|
|
[...el.querySelectorAll<HTMLElement>("[data-message-id]")].map(
|
|
(row) => row.dataset.messageId ?? "",
|
|
),
|
|
);
|
|
const targetId = targetCase.pick({ mounted, prepended });
|
|
expect(targetId).toBeTruthy();
|
|
const generalPath = await page.evaluate(
|
|
() => (window.location.hash.replace(/^#/, "") || "/").split("?")[0],
|
|
);
|
|
|
|
if (targetCase.fromOtherChannel) {
|
|
await page.getByTestId("channel-random").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
|
}
|
|
|
|
// The app uses hash history, so the target lives in the hash fragment.
|
|
// Watch from the same task that navigates so a short flash is not missed.
|
|
const flashedOnScreen = await page.evaluate(
|
|
([targetId, generalPath, highlightClass]) =>
|
|
new Promise<boolean>((resolve) => {
|
|
const deadline = performance.now() + 3_000;
|
|
const tick = () => {
|
|
const timelineEl = document.querySelector(
|
|
'[data-testid="message-timeline"]',
|
|
);
|
|
const row = timelineEl?.querySelector<HTMLElement>(
|
|
`[data-message-id="${CSS.escape(targetId)}"]`,
|
|
);
|
|
const flashing =
|
|
row?.closest(`[class*="${highlightClass}"]`) ??
|
|
row?.querySelector(`[class*="${highlightClass}"]`);
|
|
if (timelineEl && row && flashing) {
|
|
const viewport = timelineEl.getBoundingClientRect();
|
|
const rect = row.getBoundingClientRect();
|
|
if (rect.bottom > viewport.top && rect.top < viewport.bottom) {
|
|
resolve(true);
|
|
return;
|
|
}
|
|
}
|
|
if (performance.now() > deadline) {
|
|
resolve(false);
|
|
return;
|
|
}
|
|
requestAnimationFrame(tick);
|
|
};
|
|
const params = new URLSearchParams({ messageId: targetId });
|
|
window.history.pushState(
|
|
{},
|
|
"",
|
|
`${window.location.pathname}#${generalPath}?${params.toString()}`,
|
|
);
|
|
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
|
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
tick();
|
|
}),
|
|
[targetId, generalPath, HIGHLIGHT_CLASS] as const,
|
|
);
|
|
|
|
expect(flashedOnScreen).toBe(true);
|
|
|
|
// The target must still be on screen after the flash ends. Late layout
|
|
// passes (the thread panel opening, history loads, bottom settles) must not
|
|
// carry it away.
|
|
const stillOnScreen = await page.evaluate(
|
|
(targetId) =>
|
|
new Promise<boolean>((resolve) => {
|
|
setTimeout(() => {
|
|
const timelineEl = document.querySelector(
|
|
'[data-testid="message-timeline"]',
|
|
);
|
|
const row = timelineEl?.querySelector<HTMLElement>(
|
|
`[data-message-id="${CSS.escape(targetId)}"]`,
|
|
);
|
|
if (!timelineEl || !row) {
|
|
resolve(false);
|
|
return;
|
|
}
|
|
const viewport = timelineEl.getBoundingClientRect();
|
|
const rect = row.getBoundingClientRect();
|
|
resolve(rect.bottom > viewport.top && rect.top < viewport.bottom);
|
|
}, 2_200);
|
|
}),
|
|
targetId,
|
|
);
|
|
expect(stillOnScreen).toBe(true);
|
|
});
|
|
}
|