mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Will Pfleger
parent
2d91b6f958
commit
04b7a59356
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user