mirror of
https://github.com/block/buzz.git
synced 2026-11-01 06:51:17 +00:00
Scrollback paging felt slow even with a fast relay because a fetched page could sit unpainted for seconds, and one gesture often pulled several pages. useSettleGatedPrependMessages held a landed page on ANY wheel/scroll input with a 4s deadline, and assumed motion at hold start — so even a scroller at rest paid a full quiet window, and a reader keeping fingers on the trackpad waited seconds for a 450ms page. Track motion for the scroller's whole life (lastMotionTsRef) so an at-rest hold admits after the stable-frame count, and cut SETTLE_HOLD_DEADLINE_MS to 1500: trackpad momentum — the only phase with the dropped-write hazard — decays well inside that bound. Retiring the hold directly on wheel was tried and rejected: it drifted the anchor ~30px under live input. loadOlderViaVirtualizer refused to re-fire only while isFetchingOlder or the settle hold was set. Between the fetch resolving and the prepend committing, WebKit's scroll events at the boundary started the next page, cascading one gesture into many. Add isRenderedTimelineBehindHistoryPrepend to the refuse predicate — the same signal the spinner already uses. Tests (each fails on the previous code): - useSettleGatedPrependMessages.test.mjs: at-rest hold admits without a charged quiet window; motion before the hold still charges it. - virtualization 09: commit under 60 wheel bursts is bounded (gapSinceInput < 80ms, sinceSpinner < deadline+slack) and the anchor holds within 5px at commit. - scroll-history "one gesture pages once": emulate WebKit's at-rest scroll events and tighten the ceiling from <=4 pages to <=1. Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
921 lines
36 KiB
TypeScript
921 lines
36 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import type { Locator, Page } from "@playwright/test";
|
|
|
|
import { installMockBridge } from "../helpers/bridge";
|
|
|
|
const WATERCOOLER_CHANNEL_ID = "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11";
|
|
const FORUM_THREAD_ID = "mock-forum-release-thread";
|
|
const FORUM_DEEPLINK_REPLY_ID = "mock-forum-release-deeplink";
|
|
|
|
// Mock-mode current-user pubkey (DEFAULT_MOCK_IDENTITY). Custom channel
|
|
// sections persist under buzz-channel-sections.v1:<pubkey>, so shot 6 seeds two
|
|
// sections for this key before the app boots.
|
|
const MOCK_PUBKEY = "deadbeef".repeat(8);
|
|
const SECTION_TOP = { id: "sec-top", name: "Priority", order: 0 };
|
|
const SECTION_BOTTOM = { id: "sec-bottom", name: "Archive", order: 1 };
|
|
|
|
async function seedChannelSections(page: Page) {
|
|
await page.addInitScript(
|
|
({ pubkey, sections }) => {
|
|
window.localStorage.setItem(
|
|
`buzz-channel-sections.v1:${pubkey}`,
|
|
JSON.stringify({ version: 1, sections, assignments: {} }),
|
|
);
|
|
},
|
|
{ pubkey: MOCK_PUBKEY, sections: [SECTION_TOP, SECTION_BOTTOM] },
|
|
);
|
|
}
|
|
|
|
// dnd-kit's PointerSensor activates only after the pointer travels past its
|
|
// 6px distance constraint, so a single move never starts a drag. This walks the
|
|
// pointer down, past the activation threshold, onto the target, then releases —
|
|
// the sequence dnd-kit needs to fire onDragEnd and commit the reorder.
|
|
async function dragOver(page: Page, source: Locator, target: Locator) {
|
|
const from = await source.boundingBox();
|
|
if (!from) throw new Error("drag source not laid out");
|
|
const pointer = {
|
|
x: from.x + from.width / 2,
|
|
y: from.y + from.height / 2,
|
|
};
|
|
await source.dispatchEvent("pointerdown", {
|
|
button: 0,
|
|
buttons: 1,
|
|
clientX: pointer.x,
|
|
clientY: pointer.y,
|
|
isPrimary: true,
|
|
pointerId: 1,
|
|
pointerType: "mouse",
|
|
});
|
|
await page.evaluate(({ x, y }) => {
|
|
document.dispatchEvent(
|
|
new PointerEvent("pointermove", {
|
|
bubbles: true,
|
|
buttons: 1,
|
|
clientX: x,
|
|
clientY: y + 8,
|
|
isPrimary: true,
|
|
pointerId: 1,
|
|
pointerType: "mouse",
|
|
}),
|
|
);
|
|
}, pointer);
|
|
await expect(page.getByTestId("sidebar-section-drag-overlay")).toBeVisible();
|
|
|
|
const to = await target.boundingBox();
|
|
if (!to) throw new Error("drag target not laid out");
|
|
const destination = {
|
|
x: to.x + to.width / 2,
|
|
y: to.y + to.height - 2,
|
|
};
|
|
await page.evaluate(async ({ x, y }) => {
|
|
document.dispatchEvent(
|
|
new PointerEvent("pointermove", {
|
|
bubbles: true,
|
|
buttons: 1,
|
|
clientX: x,
|
|
clientY: y,
|
|
isPrimary: true,
|
|
pointerId: 1,
|
|
pointerType: "mouse",
|
|
}),
|
|
);
|
|
await new Promise<void>((resolve) => {
|
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
|
});
|
|
document.dispatchEvent(
|
|
new PointerEvent("pointerup", {
|
|
bubbles: true,
|
|
button: 0,
|
|
buttons: 0,
|
|
clientX: x,
|
|
clientY: y,
|
|
isPrimary: true,
|
|
pointerId: 1,
|
|
pointerType: "mouse",
|
|
}),
|
|
);
|
|
}, destination);
|
|
}
|
|
|
|
test.describe("list virtualization", () => {
|
|
test("01 — Pulse windowed feed with sticky composer pinned mid-scroll", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page);
|
|
await page.goto("/");
|
|
await page.getByTestId("open-pulse-view").click();
|
|
|
|
// The seeded feed overflows the viewport (30 notes), so the windowed list
|
|
// renders a subset and the composer stays pinned. Wait for virtual rows.
|
|
const rows = page.locator("[data-index]");
|
|
await expect(rows.first()).toBeVisible();
|
|
const composer = page.locator(".pulse-composer");
|
|
await expect(composer).toBeVisible();
|
|
|
|
// Scroll the feed mid-list, then prove the sticky composer is still pinned
|
|
// at the top of its scroll container — this exercises the
|
|
// translateY(start - scrollMargin) offset under a non-zero scrollTop.
|
|
const scroller = composer.locator(
|
|
"xpath=ancestor::*[contains(@class,'overflow-y-auto')][1]",
|
|
);
|
|
await scroller.evaluate((el) => {
|
|
el.scrollTop = 600;
|
|
});
|
|
await expect
|
|
.poll(async () =>
|
|
composer.evaluate(
|
|
(el, scrollEl) => {
|
|
const composerTop = el.getBoundingClientRect().top;
|
|
const scrollTop = (scrollEl as HTMLElement).getBoundingClientRect()
|
|
.top;
|
|
return Math.abs(composerTop - scrollTop);
|
|
},
|
|
await scroller.elementHandle(),
|
|
),
|
|
)
|
|
.toBeLessThan(80);
|
|
});
|
|
|
|
test("02 — forum deep-link lands on an offscreen reply", async ({ page }) => {
|
|
await installMockBridge(page);
|
|
await page.goto(
|
|
`/#/channels/${WATERCOOLER_CHANNEL_ID}/posts/${FORUM_THREAD_ID}?replyId=${FORUM_DEEPLINK_REPLY_ID}`,
|
|
);
|
|
|
|
// The deep-link target is the last of 25 replies — offscreen at open. Under
|
|
// content-visibility the row stays queryable, so scrollIntoView lands it.
|
|
const target = page.locator(
|
|
`[data-forum-event-id="${FORUM_DEEPLINK_REPLY_ID}"]`,
|
|
);
|
|
await expect(target).toBeVisible();
|
|
await expect(target).toContainText("Deep-link target");
|
|
// Assert the row sits within the viewport vertically — proves the scroll
|
|
// actually moved to it rather than leaving it below the fold.
|
|
await expect
|
|
.poll(async () =>
|
|
target.evaluate((el) => {
|
|
const rect = el.getBoundingClientRect();
|
|
return rect.top >= 0 && rect.bottom <= window.innerHeight;
|
|
}),
|
|
)
|
|
.toBe(true);
|
|
});
|
|
|
|
test("03 — members search shows both sticky titles under content-visibility", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
managedAgents: [
|
|
{
|
|
pubkey:
|
|
"554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea",
|
|
name: "charlie",
|
|
status: "stopped",
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await page.getByTestId("channel-members-trigger").click();
|
|
await expect(page.getByTestId("members-sidebar")).toBeVisible();
|
|
|
|
// "a" matches member `alice` (Members section) and non-member `charlie`
|
|
// (Not in this channel section) — both heterogeneous lists + both sticky
|
|
// titles must stay alive under content-visibility.
|
|
await page.getByTestId("channel-management-search-users").fill("a");
|
|
await expect(page.getByText("Members", { exact: true })).toBeVisible();
|
|
await expect(
|
|
page.getByText("Not in this channel", { exact: true }),
|
|
).toBeVisible();
|
|
// A member row and an add-search (non-member) row both rendered.
|
|
await expect(
|
|
page.getByTestId("members-sidebar-people").getByText("alice"),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.locator('[data-testid^="channel-user-search-result-"]').first(),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("06 — custom-section dnd reorder commits under content-visibility", async ({
|
|
page,
|
|
}) => {
|
|
await seedChannelSections(page);
|
|
await installMockBridge(page);
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
// dnd-kit marks each section's wrapping row with role="button" +
|
|
// aria-roledescription="sortable" and spreads the drag listeners there, so
|
|
// the row itself is the handle. Scoping to that attribute reads the live
|
|
// section order and excludes the inner disclosure button and the (hidden)
|
|
// assign-to-section context-menu items that reuse the same names.
|
|
const headers = page.locator('[aria-roledescription="sortable"]');
|
|
const topHeader = headers.filter({ hasText: "Priority" });
|
|
const bottomHeader = headers.filter({ hasText: "Archive" });
|
|
const topHeaderButton = topHeader.getByRole("button", {
|
|
name: "Priority",
|
|
exact: true,
|
|
});
|
|
await expect(topHeader).toBeVisible();
|
|
await expect(bottomHeader).toBeVisible();
|
|
await expect(headers).toHaveCount(2);
|
|
|
|
const sectionOrder = async () =>
|
|
headers.evaluateAll((rows) =>
|
|
rows.map((row) =>
|
|
row.textContent?.trim().startsWith("Priority")
|
|
? "Priority"
|
|
: "Archive",
|
|
),
|
|
);
|
|
expect(await sectionOrder()).toEqual(["Priority", "Archive"]);
|
|
|
|
// Drag "Priority" past "Archive" — onDragEnd commits arrayMove and persists
|
|
// the new order. The drop must land for the order to flip.
|
|
await dragOver(page, topHeaderButton, bottomHeader);
|
|
|
|
// The drop landed: order flipped. A no-op drag would leave it unchanged.
|
|
await expect.poll(sectionOrder).toEqual(["Archive", "Priority"]);
|
|
// Both section rows stayed committed in the DOM across the reorder — the
|
|
// content-visibility invariant the divergence rests on (no unmount).
|
|
await expect(headers).toHaveCount(2);
|
|
});
|
|
|
|
test("08 — cascading older pages never snap the viewport toward newest", async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
// A real CDP wheel burst is required here: assigning scrollTop does not
|
|
// reproduce Chromium/WebKit's native wheel → scroll callback ordering. The
|
|
// old boundary rollback moved the viewport back down before the fetch
|
|
// committed; keep that pre-prepend reversal below the same 5px frame bar.
|
|
// A 300ms relay delay leaves the input boundary and prepend commit as two
|
|
// distinct phases so this assertion cannot accidentally measure only the
|
|
// later anchor correction.
|
|
await installMockBridge(page, {
|
|
deepHistoryMessageCount: 1_800,
|
|
channelWindowDelayMs: 300,
|
|
});
|
|
await page.goto("/#/channels/feedf00d-0000-4000-8000-000000000007");
|
|
const timeline = page.getByTestId("message-timeline");
|
|
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
|
|
// Initial bottom positioning can momentarily cross the start threshold. Let
|
|
// any resulting page transaction settle before driving explicit crossings.
|
|
await page.waitForTimeout(1_000);
|
|
|
|
const sampleVisibleAnchor = (expectedId?: string) =>
|
|
timeline.evaluate(async (scroller, anchorId) => {
|
|
const s = scroller as HTMLElement;
|
|
for (let frame = 0; frame < 60; frame += 1) {
|
|
const scrollerTop = s.getBoundingClientRect().top;
|
|
const rows = Array.from(
|
|
s.querySelectorAll<HTMLElement>("[data-message-id]"),
|
|
);
|
|
const row = anchorId
|
|
? rows.find((candidate) => candidate.dataset.messageId === anchorId)
|
|
: rows.find(
|
|
(candidate) =>
|
|
candidate.getBoundingClientRect().top >= scrollerTop,
|
|
);
|
|
if (row) {
|
|
return {
|
|
id: row.dataset.messageId ?? "",
|
|
top: row.getBoundingClientRect().top - scrollerTop,
|
|
scrollHeight: s.scrollHeight,
|
|
bottomDistance: s.scrollHeight - s.clientHeight - s.scrollTop,
|
|
};
|
|
}
|
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
}
|
|
throw new Error(
|
|
`anchor row ${anchorId ?? "at viewport top"} not mounted`,
|
|
);
|
|
}, expectedId);
|
|
|
|
// Load fifteen consecutive server pages in one mounted virtualizer. This
|
|
// is the production shape that exposed the intermittent end-cache snap:
|
|
// variable-height rows and repeated front insertions exercise the full
|
|
// index-shift path rather than allowing a single lucky pass.
|
|
for (let pageIndex = 0; pageIndex < 15; pageIndex += 1) {
|
|
// Leave the threshold first so Virtua emits a fresh start-edge crossing;
|
|
// initial positioning can briefly report offset 0 while mounting.
|
|
await timeline.evaluate((element) => {
|
|
element.scrollTop = 4000;
|
|
});
|
|
await page.waitForTimeout(300);
|
|
await timeline.evaluate((element) => {
|
|
element.scrollTop = 180;
|
|
});
|
|
await page.waitForTimeout(150);
|
|
const before = await sampleVisibleAnchor();
|
|
const ctrlWheelPromise =
|
|
pageIndex === 0
|
|
? timeline.evaluate(
|
|
(scroller) =>
|
|
new Promise<boolean>((resolve) => {
|
|
const s = scroller as HTMLElement;
|
|
const observer = new MutationObserver(() => {
|
|
observer.disconnect();
|
|
// Ctrl+wheel is browser zoom, not reader scroll intent. Fire
|
|
// it synchronously with the prepend DOM commit, before the
|
|
// ResizeObserver measurement batch reconciles estimated rows.
|
|
s.dispatchEvent(
|
|
new WheelEvent("wheel", { ctrlKey: true, deltaY: -100 }),
|
|
);
|
|
resolve(true);
|
|
});
|
|
observer.observe(s.firstElementChild ?? s, {
|
|
childList: true,
|
|
subtree: true,
|
|
});
|
|
}),
|
|
)
|
|
: Promise.resolve(false);
|
|
const wheelTracePromise = timeline.evaluate(async (scroller) => {
|
|
const s = scroller as HTMLElement;
|
|
let previousScrollTop = s.scrollTop;
|
|
let maxBoundaryRollback = 0;
|
|
let minScrollTop = s.scrollTop;
|
|
const deadline = performance.now() + 120;
|
|
while (performance.now() < deadline) {
|
|
maxBoundaryRollback = Math.max(
|
|
maxBoundaryRollback,
|
|
s.scrollTop - previousScrollTop,
|
|
);
|
|
previousScrollTop = s.scrollTop;
|
|
minScrollTop = Math.min(minScrollTop, s.scrollTop);
|
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
}
|
|
return { maxBoundaryRollback, minScrollTop };
|
|
});
|
|
const box = await timeline.boundingBox();
|
|
if (!box) throw new Error("timeline has no bounding box");
|
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
|
for (const deltaY of [-60, -30, -20, -15]) {
|
|
await page.mouse.wheel(0, deltaY);
|
|
await page.waitForTimeout(12);
|
|
}
|
|
const wheelTrace = await wheelTracePromise;
|
|
expect(wheelTrace.minScrollTop).toBeLessThanOrEqual(350);
|
|
expect(wheelTrace.maxBoundaryRollback).toBeLessThan(5);
|
|
// Linux Chromium delivers CDP wheel input with more latency than macOS,
|
|
// so the burst's final delta can land AFTER the anchor baseline sample
|
|
// below. maxDrift then reports the reader's own last wheel event as
|
|
// anchor drift (measured exactly 15 = the -15 delta; changing the delta
|
|
// to -17 made the failure read 17). Gate the baseline on input settle:
|
|
// two consecutive frames with identical scrollTop. This tightens the
|
|
// assertion rather than diluting it — the baseline becomes honest and
|
|
// genuine post-prepend drift still reads as drift.
|
|
await timeline.evaluate(async (element) => {
|
|
let prior = element.scrollTop;
|
|
for (let frame = 0; frame < 30; frame += 1) {
|
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
if (element.scrollTop === prior) break;
|
|
prior = element.scrollTop;
|
|
}
|
|
});
|
|
const committedAnchor = await sampleVisibleAnchor(before.id);
|
|
const motion = await timeline.evaluate(
|
|
async (scroller, { anchorId, anchorTop, oldHeight }) => {
|
|
const s = scroller as HTMLElement;
|
|
let maxDrift = 0;
|
|
let sawPrepend = false;
|
|
let sawAnchorAfterPrepend = false;
|
|
let finalDrift = 0;
|
|
let stableFrames = 0;
|
|
for (let frame = 0; frame < 180; frame += 1) {
|
|
const row = Array.from(
|
|
s.querySelectorAll<HTMLElement>("[data-message-id]"),
|
|
).find((candidate) => candidate.dataset.messageId === anchorId);
|
|
if (s.scrollHeight > oldHeight + 800 && !sawPrepend) {
|
|
sawPrepend = true;
|
|
}
|
|
if (row) {
|
|
const top =
|
|
row.getBoundingClientRect().top - s.getBoundingClientRect().top;
|
|
const drift = Math.abs(top - anchorTop);
|
|
if (sawPrepend) {
|
|
maxDrift = Math.max(maxDrift, drift);
|
|
sawAnchorAfterPrepend = true;
|
|
stableFrames =
|
|
Math.abs(drift - finalDrift) < 0.5 ? stableFrames + 1 : 0;
|
|
finalDrift = drift;
|
|
}
|
|
}
|
|
if (sawAnchorAfterPrepend && stableFrames >= 8) break;
|
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
}
|
|
return { maxDrift, sawPrepend };
|
|
},
|
|
{
|
|
anchorId: committedAnchor.id,
|
|
anchorTop: committedAnchor.top,
|
|
oldHeight: before.scrollHeight,
|
|
},
|
|
);
|
|
expect(motion.sawPrepend).toBe(true);
|
|
if (pageIndex === 0) expect(await ctrlWheelPromise).toBe(true);
|
|
expect(motion.maxDrift).toBeLessThan(5);
|
|
|
|
await expect
|
|
.poll(
|
|
async () => timeline.evaluate((element) => element.scrollHeight),
|
|
{
|
|
timeout: 10_000,
|
|
},
|
|
)
|
|
.toBeGreaterThan(before.scrollHeight + 800);
|
|
|
|
// A snap to newest leaves this near zero. Keep a full viewport of history
|
|
// below the reader after every prepend, rather than checking only the
|
|
// final page and missing a transient cascade failure.
|
|
const bottomDistance = await timeline.evaluate(
|
|
(element) =>
|
|
element.scrollHeight - element.clientHeight - element.scrollTop,
|
|
);
|
|
expect(bottomDistance).toBeGreaterThan(
|
|
await timeline.evaluate((element) => element.clientHeight),
|
|
);
|
|
|
|
// Leave the boundary with real downward wheel input after this prepend.
|
|
// That reader intent retires Virtua's active prepend reconciliation, so
|
|
// later row measurements must not pull the viewport back toward the
|
|
// completed prepend before the next upward load.
|
|
const exitTracePromise = timeline.evaluate(async (scroller) => {
|
|
const s = scroller as HTMLElement;
|
|
const startScrollTop = s.scrollTop;
|
|
let previousScrollTop = startScrollTop;
|
|
let maxForwardTravel = 0;
|
|
let maxRollback = 0;
|
|
const deadline = performance.now() + 400;
|
|
while (performance.now() < deadline) {
|
|
const travel = s.scrollTop - startScrollTop;
|
|
maxForwardTravel = Math.max(maxForwardTravel, travel);
|
|
maxRollback = Math.max(maxRollback, previousScrollTop - s.scrollTop);
|
|
previousScrollTop = s.scrollTop;
|
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
}
|
|
return { maxForwardTravel, maxRollback };
|
|
});
|
|
const exitBox = await timeline.boundingBox();
|
|
if (!exitBox) throw new Error("timeline has no bounding box");
|
|
await page.mouse.move(
|
|
exitBox.x + exitBox.width / 2,
|
|
exitBox.y + exitBox.height / 2,
|
|
);
|
|
for (const deltaY of [120, 100, 80]) {
|
|
await page.mouse.wheel(0, deltaY);
|
|
await page.waitForTimeout(12);
|
|
}
|
|
const exitTrace = await exitTracePromise;
|
|
expect(exitTrace.maxForwardTravel).toBeGreaterThan(200);
|
|
expect(exitTrace.maxRollback).toBeLessThan(5);
|
|
|
|
// Loading more history must not return keepMounted to its old linear
|
|
// growth. Virtua still retains every measured size for spacer geometry;
|
|
// only the live message-row DOM stays bounded around the reader and tail.
|
|
const mountedMessageCount = await timeline
|
|
.locator("[data-message-id]")
|
|
.count();
|
|
expect(mountedMessageCount).toBeLessThan(400);
|
|
}
|
|
});
|
|
|
|
test("09 — older-page render commit is bounded under continued wheel input, and holds the anchor", async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(60_000);
|
|
// Production shape: heavy variable-height rows, a slow older-page fetch,
|
|
// and wheel input that KEEPS ARRIVING through fetch resolution. #1698 held
|
|
// the landed page until input stopped entirely; under a sustained trackpad
|
|
// gesture that turned a ~450ms page into a multi-second wait behind the
|
|
// spinner. The contract now is a bounded hold: the page's RENDER COMMIT
|
|
// (the scrollHeight jump) lands within SETTLE_HOLD_DEADLINE_MS of the
|
|
// fetch resolving even though input never quiesces, and that commit still
|
|
// holds the reader's anchored row. Chromium cannot reproduce WebKit's
|
|
// dropped-write hazard; it CAN prove the scheduling bound and the anchor.
|
|
await installMockBridge(page, {
|
|
deepHistoryMessageCount: 1_800,
|
|
channelWindowDelayMs: 300,
|
|
});
|
|
await page.goto("/#/channels/feedf00d-0000-4000-8000-000000000007");
|
|
const timeline = page.getByTestId("message-timeline");
|
|
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
|
|
await page.waitForTimeout(1_000);
|
|
|
|
// Mount mid-history rows clear of the load-older trigger, then trip it.
|
|
await timeline.evaluate((element) => {
|
|
element.scrollTop = 4000;
|
|
});
|
|
await page.waitForTimeout(300);
|
|
|
|
// In-page observer: records when the spinner first appears (the fetch is
|
|
// in flight), when the prepend commit (scrollHeight jump) lands, how long
|
|
// input had been quiet at that moment, and the anchored row's drift
|
|
// across the commit. The anchor is re-captured on every frame before the
|
|
// commit so it reflects the reader's CURRENT position under live input.
|
|
const WHEEL_TICK_PX = 30;
|
|
const tracePromise = timeline.evaluate(async (scroller, WHEEL_TICK_PX) => {
|
|
const s = scroller as HTMLElement;
|
|
const baseHeight = s.scrollHeight;
|
|
let lastInputTs = 0;
|
|
let wheelEvents = 0;
|
|
// Wheel ticks since the last anchor sample. The commit frame is heavy
|
|
// (~100ms of prepend render), so ticks land inside it, and each tick's
|
|
// main-thread event and compositor scroll apply on independent
|
|
// schedules. The reader's own motion at the commit frame is therefore
|
|
// some whole number of ticks — up to one more than the events seen.
|
|
// Only displacement that is NOT a whole number of ticks is drift.
|
|
let wheelTicksSinceSample = 0;
|
|
const onWheel = () => {
|
|
lastInputTs = performance.now();
|
|
wheelEvents += 1;
|
|
wheelTicksSinceSample += 1;
|
|
};
|
|
s.addEventListener("wheel", onWheel, { passive: true });
|
|
const topRow = () => {
|
|
const scrollerTop = s.getBoundingClientRect().top;
|
|
const row = Array.from(
|
|
s.querySelectorAll<HTMLElement>("[data-message-id]"),
|
|
).find(
|
|
(candidate) =>
|
|
candidate.getBoundingClientRect().top - scrollerTop >= 0,
|
|
);
|
|
return row?.dataset.messageId
|
|
? {
|
|
id: row.dataset.messageId,
|
|
top: row.getBoundingClientRect().top - scrollerTop,
|
|
}
|
|
: null;
|
|
};
|
|
let spinnerAt: number | null = null;
|
|
let anchor: { id: string; top: number } | null = null;
|
|
let commit: {
|
|
ts: number;
|
|
sinceSpinner: number;
|
|
gapSinceInput: number;
|
|
wheelEventsBefore: number;
|
|
} | null = null;
|
|
let anchorDriftAtCommit: number | null = null;
|
|
const deadline = performance.now() + 8_000;
|
|
while (performance.now() < deadline) {
|
|
const now = performance.now();
|
|
if (
|
|
spinnerAt === null &&
|
|
document.querySelector(
|
|
'[data-testid="message-timeline-fetching-older"]',
|
|
) !== null
|
|
) {
|
|
spinnerAt = now;
|
|
}
|
|
if (s.scrollHeight > baseHeight + 800) {
|
|
commit = {
|
|
ts: now,
|
|
sinceSpinner: spinnerAt === null ? -1 : now - spinnerAt,
|
|
gapSinceInput: now - lastInputTs,
|
|
wheelEventsBefore: wheelEvents,
|
|
};
|
|
if (anchor !== null) {
|
|
const row = Array.from(
|
|
s.querySelectorAll<HTMLElement>("[data-message-id]"),
|
|
).find((candidate) => candidate.dataset.messageId === anchor?.id);
|
|
if (row) {
|
|
const top =
|
|
row.getBoundingClientRect().top - s.getBoundingClientRect().top;
|
|
anchorDriftAtCommit = Number.POSITIVE_INFINITY;
|
|
for (let ticks = 0; ticks <= wheelTicksSinceSample + 1; ticks++) {
|
|
anchorDriftAtCommit = Math.min(
|
|
anchorDriftAtCommit,
|
|
Math.abs(top - (anchor.top - ticks * WHEEL_TICK_PX)),
|
|
);
|
|
}
|
|
} else {
|
|
anchorDriftAtCommit = Number.POSITIVE_INFINITY;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
const sampled = topRow();
|
|
if (sampled) {
|
|
anchor = sampled;
|
|
wheelTicksSinceSample = 0;
|
|
}
|
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
}
|
|
s.removeEventListener("wheel", onWheel);
|
|
return { commit, anchorDriftAtCommit, wheelEvents };
|
|
}, WHEEL_TICK_PX);
|
|
|
|
// Trip the boundary, then keep real wheel input flowing DOWN (away from
|
|
// the boundary) through and well past the 300ms fetch resolution — the
|
|
// sustained-gesture window in which #1698 withheld the page.
|
|
await timeline.evaluate((element) => {
|
|
element.scrollTop = 150;
|
|
});
|
|
const box = await timeline.boundingBox();
|
|
if (!box) throw new Error("timeline has no bounding box");
|
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
|
for (let burst = 0; burst < 60; burst += 1) {
|
|
await page.mouse.wheel(0, WHEEL_TICK_PX);
|
|
await page.waitForTimeout(40);
|
|
}
|
|
|
|
const trace = await tracePromise;
|
|
// The page committed — and while input was still flowing (the gesture
|
|
// above outlasts the fetch by ~2s), not after the reader gave up.
|
|
expect(trace.commit).not.toBeNull();
|
|
expect(
|
|
trace.commit?.gapSinceInput ?? Number.POSITIVE_INFINITY,
|
|
).toBeLessThan(80);
|
|
// Bounded hold: the commit landed within the gate's deadline of the fetch
|
|
// going in flight (spinner) — 300ms network + SETTLE_HOLD_DEADLINE_MS, with
|
|
// slack for the rAF watcher. #1698's unbounded hold lands ~2.4s here.
|
|
expect(trace.commit?.sinceSpinner ?? Number.POSITIVE_INFINITY).toBeLessThan(
|
|
300 + 1_500 + 250,
|
|
);
|
|
// The commit held the reader's anchored row within the 5px contract even
|
|
// though it landed under live input (modulo the reader's own whole wheel
|
|
// ticks — see the trace). #2855's lost correction read 452px here.
|
|
expect(trace.anchorDriftAtCommit).not.toBeNull();
|
|
expect(trace.anchorDriftAtCommit ?? Number.POSITIVE_INFINITY).toBeLessThan(
|
|
5,
|
|
);
|
|
});
|
|
});
|
|
|
|
test("thread-heavy history mounts every loaded row", async ({ page }) => {
|
|
await installMockBridge(page);
|
|
await page.goto("/");
|
|
await page.waitForFunction(
|
|
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
|
);
|
|
|
|
// Seed summaries on 120 loaded roots. Every loaded row should be realized
|
|
// immediately so first-pass scrolling never encounters Virtua's hidden
|
|
// pre-measurement state.
|
|
await page.evaluate(() => {
|
|
for (let index = 480; index < 600; index += 1) {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "deep-history",
|
|
content: `summary-only reply ${index}`,
|
|
parentEventId: `mock-deep-history-${index}`,
|
|
});
|
|
}
|
|
});
|
|
|
|
await page.getByTestId("channel-deep-history").click();
|
|
const timeline = page.getByTestId("message-timeline");
|
|
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
|
|
|
|
// Emit after opening so the live summary path updates every loaded root,
|
|
// independent of the relay page's summary cap.
|
|
await page.evaluate(() => {
|
|
for (let index = 480; index < 600; index += 1) {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "deep-history",
|
|
content: `live summary-only reply ${index}`,
|
|
parentEventId: `mock-deep-history-${index}`,
|
|
});
|
|
}
|
|
});
|
|
await page.waitForTimeout(500);
|
|
|
|
await page.waitForTimeout(300);
|
|
|
|
const loadedRows = timeline.locator("[data-message-id]");
|
|
// The mock channel's current loaded window contains 50 roots; all of them
|
|
// must already exist and be painted before the first scroll gesture.
|
|
await expect(loadedRows).toHaveCount(50);
|
|
expect(
|
|
await loadedRows.evaluateAll((rows) =>
|
|
rows.every((row) => getComputedStyle(row).visibility === "visible"),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
test("channel switches settle the last row above the composer", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page);
|
|
await page.goto("/");
|
|
await page.waitForFunction(
|
|
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
|
);
|
|
|
|
await page.evaluate(() => {
|
|
for (const [channelName, prefix] of [
|
|
["general", "switch-general"],
|
|
["engineering", "switch-engineering"],
|
|
] as const) {
|
|
for (let index = 0; index < 60; index += 1) {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName,
|
|
content: `${prefix}-${index}`,
|
|
createdAt: 1_700_000_000 + index,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
for (const channelName of ["general", "engineering", "general"]) {
|
|
await page.getByTestId(`channel-${channelName}`).click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
|
const timeline = page.getByTestId("message-timeline");
|
|
const composer = page.getByTestId("channel-composer-overlay");
|
|
await expect
|
|
.poll(async () =>
|
|
timeline.evaluate(
|
|
(element, composerElement) => {
|
|
const rows = Array.from(
|
|
element.querySelectorAll<HTMLElement>("[data-message-id]"),
|
|
);
|
|
const lastRow = rows.at(-1);
|
|
if (!lastRow) return Number.POSITIVE_INFINITY;
|
|
return (
|
|
lastRow.getBoundingClientRect().bottom -
|
|
(composerElement as HTMLElement).getBoundingClientRect().top
|
|
);
|
|
},
|
|
await composer.elementHandle(),
|
|
),
|
|
)
|
|
.toBeLessThanOrEqual(1);
|
|
}
|
|
});
|
|
|
|
test("offscreen rich-row resize preserves the viewport-center anchor", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page);
|
|
await page.goto("/");
|
|
await page.waitForFunction(
|
|
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
|
);
|
|
await page.evaluate(() => {
|
|
for (let index = 0; index < 240; index += 1) {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "general",
|
|
content: [
|
|
`rich resize row ${index}`,
|
|
"long wrapped text ".repeat((index % 8) + 1),
|
|
].join("\n"),
|
|
createdAt: 1_700_700_000 + index,
|
|
});
|
|
}
|
|
});
|
|
|
|
await page.getByTestId("channel-general").click();
|
|
const timeline = page.getByTestId("message-timeline");
|
|
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
|
|
|
|
const result = await timeline.evaluate(async (element) => {
|
|
const scroller = element as HTMLDivElement;
|
|
// Retire bottom-follow intent the same way real reader input does before
|
|
// moving into detached history. A raw scrollTop assignment alone is not
|
|
// user intent and would correctly leave bottom following armed.
|
|
scroller.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
|
|
scroller.scrollTop = scroller.scrollHeight / 2;
|
|
scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
|
|
const scrollerRect = scroller.getBoundingClientRect();
|
|
const rows = Array.from(
|
|
scroller.querySelectorAll<HTMLElement>("[data-message-id]"),
|
|
);
|
|
const visibleRows = rows.filter((row) => {
|
|
const rect = row.getBoundingClientRect();
|
|
return rect.bottom > scrollerRect.top && rect.top < scrollerRect.bottom;
|
|
});
|
|
const viewportCenter = scrollerRect.top + scroller.clientHeight / 2;
|
|
const anchor = visibleRows.reduce<HTMLElement | null>((nearest, row) => {
|
|
if (!nearest) return row;
|
|
const rowRect = row.getBoundingClientRect();
|
|
const nearestRect = nearest.getBoundingClientRect();
|
|
const rowDistance = Math.abs(
|
|
(rowRect.top + rowRect.bottom) / 2 - viewportCenter,
|
|
);
|
|
const nearestDistance = Math.abs(
|
|
(nearestRect.top + nearestRect.bottom) / 2 - viewportCenter,
|
|
);
|
|
return rowDistance < nearestDistance ? row : nearest;
|
|
}, null);
|
|
if (!anchor) throw new Error("no visible center anchor");
|
|
const rowAbove = rows
|
|
.filter((row) => row.getBoundingClientRect().bottom <= scrollerRect.top)
|
|
.at(-1);
|
|
if (!rowAbove) throw new Error("no mounted offscreen rich row above");
|
|
|
|
const anchorRect = anchor.getBoundingClientRect();
|
|
const anchorCenterOffset =
|
|
(anchorRect.top + anchorRect.bottom) / 2 - viewportCenter;
|
|
const scrollTop = scroller.scrollTop;
|
|
rowAbove.style.minHeight = `${rowAbove.getBoundingClientRect().height + 240}px`;
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
|
|
const nextScrollerRect = scroller.getBoundingClientRect();
|
|
const nextAnchorRect = anchor.getBoundingClientRect();
|
|
const nextViewportCenter = nextScrollerRect.top + scroller.clientHeight / 2;
|
|
return {
|
|
anchorDrift:
|
|
(nextAnchorRect.top + nextAnchorRect.bottom) / 2 -
|
|
nextViewportCenter -
|
|
anchorCenterOffset,
|
|
scrollCorrection: scroller.scrollTop - scrollTop,
|
|
};
|
|
});
|
|
|
|
expect(Math.abs(result.anchorDrift)).toBeLessThanOrEqual(2);
|
|
expect(result.scrollCorrection).toBeGreaterThanOrEqual(238);
|
|
});
|
|
|
|
test("live tail arrivals keep a bottom-pinned virtual timeline settled", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page);
|
|
await page.goto("/");
|
|
await page.waitForFunction(
|
|
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
|
);
|
|
|
|
await page.evaluate(() => {
|
|
for (let index = 0; index < 60; index += 1) {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "general",
|
|
content: `live-follow seed ${index}\nsecond line ${index}`,
|
|
createdAt: 1_700_000_000 + index,
|
|
});
|
|
}
|
|
});
|
|
await page.getByTestId("channel-general").click();
|
|
const timeline = page.getByTestId("message-timeline");
|
|
await expect(timeline).toContainText("live-follow seed 59");
|
|
await expect
|
|
.poll(() =>
|
|
timeline.evaluate(
|
|
(element) =>
|
|
element.scrollHeight - element.clientHeight - element.scrollTop,
|
|
),
|
|
)
|
|
.toBeLessThanOrEqual(1);
|
|
|
|
await page.evaluate(() => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "general",
|
|
content:
|
|
"remote live-follow sentinel\nline two\nline three\nline four\nline five",
|
|
createdAt: 1_800_000_000,
|
|
});
|
|
});
|
|
|
|
await expect(page.getByText("remote live-follow sentinel")).toBeVisible();
|
|
await expect
|
|
.poll(() =>
|
|
timeline.evaluate(
|
|
(element) =>
|
|
element.scrollHeight - element.clientHeight - element.scrollTop,
|
|
),
|
|
)
|
|
.toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
test("live tail arrivals stay buffered while reading and release on jump", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page);
|
|
await page.goto("/");
|
|
await page.waitForFunction(
|
|
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
|
);
|
|
await page.getByTestId("channel-deep-history").click();
|
|
|
|
const timeline = page.getByTestId("message-timeline");
|
|
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
|
|
await timeline.evaluate((element) => {
|
|
element.scrollTop = Math.max(500, element.scrollHeight / 2);
|
|
element.dispatchEvent(new Event("scroll", { bubbles: true }));
|
|
});
|
|
await expect(page.getByTestId("message-scroll-to-latest")).toBeVisible();
|
|
const frozenHeight = await timeline.evaluate(
|
|
(element) => element.scrollHeight,
|
|
);
|
|
|
|
await page.evaluate(() => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "deep-history",
|
|
content: "buffered live tail sentinel",
|
|
createdAt: 1_900_000_000,
|
|
});
|
|
});
|
|
|
|
await expect(page.getByText("buffered live tail sentinel")).toHaveCount(0);
|
|
await expect(page.getByTestId("message-scroll-to-latest")).toContainText("1");
|
|
expect(await timeline.evaluate((element) => element.scrollHeight)).toBe(
|
|
frozenHeight,
|
|
);
|
|
|
|
await page.getByTestId("message-scroll-to-latest").click();
|
|
await expect(page.getByText("buffered live tail sentinel")).toBeVisible();
|
|
});
|