test(timeline): drive scroll-preservation off real wheel + genuine prepend

The 'preserves user scroll while older channel history loads' e2e test
drove pagination with a synthetic scrollTop teleport plus a
dispatchEvent('scroll'), which mutates geometry a virtualizer can
re-assert and never reflects real input.

Seeding general with a small prepend was a dead end: the cold load
(CHANNEL_HISTORY_LIMIT=300) drains a sub-300 store in one shot, so the
wheel-driven fetchOlder returns only already-cached events that dedup to
zero net growth. The anchor then has no real prepend to hold and the
assertion measures virtualizer re-measure, not scroll preservation --
and __HISTORY_INFLIGHT__ fires on the request gate regardless of whether
new events come back, so it cannot prove a genuine page landed.

Target the deep-history channel (600 > 300) so a real older page sits
behind the until cursor. Its store is pre-seeded with monotonic
createdAt, so the test no longer needs its own timestamped emit loop to
avoid the same-second-collision flake. Two phases reconcile the standing
tension that the in-flight delay needed to observe drift is the same
thing that stalls the climb to the top sentinel: walk into mid-history
with no delay (each instant page advances the oldest row so the next
wheel re-enters a fresh sentinel), then enable historyDelayMs and fire
one delayed genuine page. The poll gates on a NEW older index landing
(not the inflight counter, not bare row count) AND <=2px drift of a
first-visible row captured while that page is in flight -- so a pass
proves the restore held a genuine prepend, not a re-measure.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-06-23 04:54:56 -04:00
co-authored by Will Pfleger
parent 2d91b6f958
commit 04b7a59356
+107 -67
View File
@@ -111,47 +111,25 @@ test("first channel load holds skeleton instead of showing older-history spinner
test("preserves user scroll while older channel history loads", async ({
page,
}) => {
}, testInfo) => {
testInfo.setTimeout(60_000);
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",
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
await page.evaluate(() => {
for (let index = 0; index < 40; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `visible current ${index}\nsecond line ${index}`,
});
}
window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__?.({
channelName: "general",
count: 600,
lineCount: 3,
});
});
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Use the `deep-history` channel: its store is seeded with 600 messages,
// more than CHANNEL_HISTORY_LIMIT (300, hooks.ts), so the cold load windows
// to the newest 300 and leaves ~300 genuinely older messages behind the
// `until` cursor. A shallow seed (store < 300) is fully drained by the cold
// load, so the wheel `fetchOlder` returns only already-cached duplicates that
// dedup to zero net growth -- the anchor never has a real prepend to hold and
// the assertion would measure virtualizer re-measure, not scroll preservation.
await page.getByTestId("channel-deep-history").click();
await expect(page.getByTestId("chat-title")).toHaveText("deep-history");
const timeline = page.getByTestId("message-timeline");
await expect(timeline).toContainText("visible current 39");
// Initial load should receive enough history to make the page scrollable.
// Delay only the next history request, so the test isolates pagination while
// the user is actively scrolling.
await page.evaluate(() => {
window.__BUZZ_E2E__ = {
...window.__BUZZ_E2E__,
mock: {
...window.__BUZZ_E2E__?.mock,
historyDelayMs: 1_000,
},
};
});
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
await page.waitForFunction(() => {
const element = document.querySelector(
'[data-testid="message-timeline"]',
@@ -159,48 +137,110 @@ test("preserves user scroll while older channel history loads", async ({
return element && element.scrollHeight > element.clientHeight + 1000;
});
// Move away from the bottom before jumping near the top; otherwise the
// timeline's sticky-bottom guard can intentionally pin the first upward jump.
const beforeFetch = await getTimelineMetrics(page);
await timeline.evaluate((element) => {
const timelineElement = element as HTMLDivElement;
timelineElement.scrollTop = timelineElement.scrollHeight;
timelineElement.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await page.waitForTimeout(50);
// The lowest "Deep history message #N" index currently rendered. The seed
// numbers messages 0 (oldest) .. 599 (newest), so a smaller index = older.
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 nearTop = await timeline.evaluate((element) => {
const timelineElement = element as HTMLDivElement;
timelineElement.scrollTop = 180;
timelineElement.dispatchEvent(new Event("scroll", { bubbles: true }));
return timelineElement.scrollTop;
});
expect(nearTop).toBeLessThan(260);
// PHASE 1 -- walk into mid-history with NO history delay. Each fetchOlder
// resolves instantly, the prepend lands, the oldest-rendered index advances,
// and the next wheel re-enters a fresh top sentinel. This sustained climb is
// how a deep seed reaches the sentinel under real wheel (the `count:2100`
// sibling relies on the same mechanic). We stop in mid-history -- not at the
// top -- so a genuine older page still sits behind the `until` cursor for
// phase 2 to fetch, and so the anchor we hold has older content arriving
// ABOVE it (the actual scroll-preservation scenario, not the at-top edge).
await timeline.hover();
let deepest = Number.POSITIVE_INFINITY;
let stallStreak = 0;
for (let attempt = 0; attempt < 120 && deepest > 250; attempt += 1) {
await page.mouse.wheel(0, -4000);
await page.waitForTimeout(70);
const current = await oldestRenderedIndex();
if (current !== null && current < deepest) {
deepest = current;
stallStreak = 0;
} else {
stallStreak += 1;
if (stallStreak > 20) break;
}
}
// Confirm phase 1 actually paginated into mid-history -- if it never climbed
// off the newest window the rest of the test is meaningless.
expect(deepest).toBeLessThan(400);
await page.waitForTimeout(100);
const duringFetch = await timeline.evaluate((element) => {
const timelineElement = element as HTMLDivElement;
timelineElement.scrollTop = timelineElement.scrollTop + 160;
timelineElement.dispatchEvent(new Event("scroll", { bubbles: true }));
return timelineElement.scrollTop;
// PHASE 2 -- now delay the next history page so it stays in flight long
// enough to observe the anchor across the landing. historyDelayMs is read
// live by the bridge, so toggling it here applies to the next fetch only.
await page.evaluate(() => {
window.__BUZZ_E2E__ = {
...window.__BUZZ_E2E__,
mock: { ...window.__BUZZ_E2E__?.mock, historyDelayMs: 1_000 },
};
(
window as unknown as { __HISTORY_INFLIGHT__?: number }
).__HISTORY_INFLIGHT__ = 0;
});
expect(duringFetch).toBeGreaterThan(nearTop);
const anchorDuringFetch = await getFirstVisibleMessage(page);
expect(anchorDuringFetch).not.toBeNull();
const inflightCount = () =>
page.evaluate(
() =>
(window as unknown as { __HISTORY_INFLIGHT__?: number })
.__HISTORY_INFLIGHT__ ?? 0,
);
// Snapshot the oldest rendered index BEFORE firing the delayed page, so the
// poll's growth gate can require a genuinely NEW older index after it lands.
const oldestBeforeLanding = await oldestRenderedIndex();
expect(oldestBeforeLanding).not.toBeNull();
// One wheel tick to fire the delayed older-history page.
for (let attempt = 0; attempt < 50; attempt += 1) {
if ((await inflightCount()) > 0) break;
await page.mouse.wheel(0, -4000);
await page.waitForTimeout(50);
}
expect(await inflightCount()).toBeGreaterThan(0);
// Capture the first-visible row id AFTER the fire wheel but WHILE the page is
// still in flight (the prepend lands ~historyDelayMs later). The fire wheel
// moves the viewport, so the anchor must be read at this settled in-flight
// position -- a row captured before the fire wheel can scroll out of the
// virtualized window before the prepend lands. This row exists before the
// prepend and is the one the restore must hold.
const anchorBeforeLanding = await getFirstVisibleMessage(page);
expect(anchorBeforeLanding).not.toBeNull();
// The poll must observe the anchor holding AS a genuine older page lands
// above it. It only counts a drift reading once BOTH hold in the same sample:
// the delayed fetch has RESOLVED (inflight back to 0) AND it brought a real
// older index that was not rendered before the prepend (proof the page was
// not a duplicate-only fetch). Until then it returns Infinity and keeps
// sampling, so it cannot pass against the settled pre-landing window.
await expect
.poll(
async () => {
const [anchor, metrics] = await Promise.all([
getMessagePosition(page, anchorDuringFetch?.id ?? ""),
getTimelineMetrics(page),
const [inflight, oldestNow, anchor] = await Promise.all([
inflightCount(),
oldestRenderedIndex(),
getMessagePosition(page, anchorBeforeLanding?.id ?? ""),
]);
if (metrics.scrollHeight <= beforeFetch.scrollHeight + 1000) {
const landed =
inflight === 0 &&
oldestNow !== null &&
oldestNow < (oldestBeforeLanding ?? 0);
if (!landed || !anchor) {
return Number.POSITIVE_INFINITY;
}
return anchor
? Math.abs(anchor.top - (anchorDuringFetch?.top ?? 0))
: Number.POSITIVE_INFINITY;
return Math.abs(anchor.top - (anchorBeforeLanding?.top ?? 0));
},
{
timeout: 3_000,