mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): hold true scroll floor on load-older abandon-to-bottom
When the user jumps to bottom mid-prepend (abandon), the load-older restore loop targeted the last virtualized row's END offset. That sits short of the container's true floor once padding/spacers are excluded, stranding the view above the bottom (the `Received: Infinity` scroll-history failure). Target the container's true floor (scrollHeight - clientHeight) instead. Adds relay-backed stream stability tests covering pin-to-latest, composer growth, and above-the-fold arrivals. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
@@ -50,28 +50,28 @@ type UseLoadOlderOnScrollOptions = {
|
||||
* the captured anchor row isn't resolvable). Resolved off the virtualizer's
|
||||
* live measurement each frame so the loop chases the offset as prepended rows
|
||||
* grow `getTotalSize()`.
|
||||
* - `abandonedToBottom`: the user jumped to bottom mid-prepend → the last
|
||||
* row's END offset (the true floor), not the stale mid-history anchor.
|
||||
* - `abandonedToBottom`: the user jumped to bottom mid-prepend → the
|
||||
* scroll container's true floor, not the stale mid-history anchor.
|
||||
* - otherwise: the captured first-visible row's START offset minus the gap
|
||||
* that was above it, holding the reader's eye-line across the prepend.
|
||||
*/
|
||||
function resolveTarget({
|
||||
instance,
|
||||
abandonedToBottom,
|
||||
lastIndex,
|
||||
container,
|
||||
newIndex,
|
||||
anchorTop,
|
||||
}: {
|
||||
instance: ListVirtualizer | null;
|
||||
abandonedToBottom: boolean;
|
||||
lastIndex: number;
|
||||
container: HTMLDivElement | null;
|
||||
newIndex: number | undefined;
|
||||
anchorTop: number;
|
||||
}): number | undefined {
|
||||
if (!instance) return undefined;
|
||||
if (abandonedToBottom) {
|
||||
if (lastIndex < 0) return undefined;
|
||||
return instance.getOffsetForIndex(lastIndex, "end")?.[0];
|
||||
if (!container) return undefined;
|
||||
return Math.max(0, container.scrollHeight - container.clientHeight);
|
||||
}
|
||||
if (newIndex === undefined) return undefined;
|
||||
const start = instance.getOffsetForIndex(newIndex, "start");
|
||||
@@ -225,9 +225,10 @@ export function useLoadOlderOnScroll({
|
||||
(after?.liveMessageCount ?? previousCount) > previousCount;
|
||||
// Resolve this frame's target offset. Two cases, one mechanism:
|
||||
// - Abandon: the user jumped to bottom while this loop owned
|
||||
// scroll. Hold the BOTTOM (last row's end offset), not the
|
||||
// scroll. Hold the scroll container's true floor, not the
|
||||
// captured mid-history anchor — that old offset sits short of
|
||||
// the true floor and would strand the view there, since the
|
||||
// the floor once padding/spacers are included and would strand
|
||||
// the view there, since the
|
||||
// ResizeObserver re-pin is ceded to this loop for the whole
|
||||
// window.
|
||||
// - Normal: hold the captured first-visible row at its viewport
|
||||
@@ -248,7 +249,7 @@ export function useLoadOlderOnScroll({
|
||||
const target = resolveTarget({
|
||||
instance: grew ? instance : null,
|
||||
abandonedToBottom,
|
||||
lastIndex: (after?.liveMessageCount ?? previousCount) - 1,
|
||||
container,
|
||||
newIndex,
|
||||
anchorTop,
|
||||
});
|
||||
|
||||
@@ -9,6 +9,9 @@ import { assertRelaySeeded } from "../helpers/seed";
|
||||
|
||||
const isCi = Boolean(process.env.CI);
|
||||
const relaySeedHookTimeoutMs = isCi ? 90_000 : 30_000;
|
||||
const minScrollableSeedMessages = 8;
|
||||
const timelinePinnedProbeTimeoutMs = 1_500;
|
||||
const timelinePinnedTimeoutMs = isCi ? 15_000 : 10_000;
|
||||
|
||||
async function expectTimelineToContain(page: Page, text: string) {
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(text);
|
||||
@@ -28,6 +31,26 @@ async function getTimelineMetrics(page: Page) {
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForPinnedTimeline(
|
||||
page: Page,
|
||||
timeout = timelinePinnedTimeoutMs,
|
||||
) {
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(page)).distanceFromBottom, {
|
||||
timeout,
|
||||
})
|
||||
.toBeLessThan(8);
|
||||
}
|
||||
|
||||
async function isTimelinePinned(page: Page) {
|
||||
try {
|
||||
await waitForPinnedTimeline(page, timelinePinnedProbeTimeoutMs);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureTimelineScrollable(
|
||||
senderPage: Page,
|
||||
receiverPage: Page,
|
||||
@@ -37,21 +60,26 @@ async function ensureTimelineScrollable(
|
||||
const sendButton = senderPage.getByTestId("send-message");
|
||||
|
||||
for (let index = 0; index < 24; index += 1) {
|
||||
const metrics = await getTimelineMetrics(receiverPage);
|
||||
if (metrics.scrollHeight > metrics.clientHeight + 160) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = `${prefix} seed ${index}`;
|
||||
|
||||
await expect(input).toBeEnabled();
|
||||
await input.fill(message);
|
||||
await sendButton.click();
|
||||
await expectTimelineToContain(receiverPage, message);
|
||||
|
||||
const metrics = await getTimelineMetrics(receiverPage);
|
||||
if (
|
||||
index + 1 >= minScrollableSeedMessages &&
|
||||
metrics.scrollHeight > metrics.clientHeight + 160 &&
|
||||
(await isTimelinePinned(receiverPage))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const metrics = await getTimelineMetrics(receiverPage);
|
||||
expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight + 160);
|
||||
await waitForPinnedTimeline(receiverPage);
|
||||
}
|
||||
|
||||
async function createAndJoinSharedStream(
|
||||
@@ -75,6 +103,7 @@ async function createAndJoinSharedStream(
|
||||
await expect(memberPage.getByTestId("stream-list")).toContainText(
|
||||
channelName,
|
||||
);
|
||||
await expect(memberPage.getByTestId("message-input")).toBeEnabled();
|
||||
}
|
||||
|
||||
async function sendChannelMessage(
|
||||
@@ -139,6 +168,45 @@ async function sendChannelMessage(
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureTimelineScrollableViaInvoke(
|
||||
page: Page,
|
||||
channelName: string,
|
||||
prefix: string,
|
||||
) {
|
||||
for (let index = 0; index < 24; index += 1) {
|
||||
const message = `${prefix} seed ${index}`;
|
||||
await sendChannelMessage(page, { channelName, content: message });
|
||||
await expectTimelineToContain(page, message);
|
||||
|
||||
const metrics = await getTimelineMetrics(page);
|
||||
if (
|
||||
index + 1 >= minScrollableSeedMessages &&
|
||||
metrics.scrollHeight > metrics.clientHeight + 160 &&
|
||||
(await isTimelinePinned(page))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const metrics = await getTimelineMetrics(page);
|
||||
expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight + 160);
|
||||
await waitForPinnedTimeline(page);
|
||||
}
|
||||
|
||||
async function forceChannelIntroVisible(page: Page) {
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
|
||||
await timeline.hover();
|
||||
for (let attempt = 0; attempt < 18; attempt += 1) {
|
||||
if ((await page.getByTestId("message-channel-intro").count()) > 0) return;
|
||||
await page.mouse.wheel(0, -2400);
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
await expect(page.getByTestId("message-channel-intro")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function scrollTimelineAwayFromBottom(page: Page, minDistance = 160) {
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
await timeline.hover();
|
||||
@@ -259,6 +327,35 @@ test("creates a relay-backed stream", async ({ page }) => {
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
});
|
||||
|
||||
test("opens a scrollable stream pinned to the latest message", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.slow();
|
||||
|
||||
const channelName = `open-pinned-${Date.now()}`;
|
||||
const prefix = `Open pinned ${Date.now()}`;
|
||||
|
||||
await installRelayBridge(page, "tyler");
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create a channel" }).click();
|
||||
await page.getByTestId("create-channel-name").fill(channelName);
|
||||
await page.getByTestId("create-channel-submit").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
|
||||
await ensureTimelineScrollableViaInvoke(page, channelName, prefix);
|
||||
await forceChannelIntroVisible(page);
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await page.getByTestId(`channel-${channelName}`).click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
|
||||
// The 396 bug only manifests with the intro rendered above the rows, so
|
||||
// confirm we're in the intro-present state on reopen before asserting pinned.
|
||||
await expect(page.getByTestId("message-channel-intro")).toBeVisible();
|
||||
await waitForPinnedTimeline(page);
|
||||
});
|
||||
|
||||
test("sends a message through the real relay", async ({ page }) => {
|
||||
const message = `Integration message ${Date.now()}`;
|
||||
|
||||
@@ -326,17 +423,13 @@ test("stays pinned to the latest message when new messages arrive at the bottom"
|
||||
await createAndJoinSharedStream(pageOne, pageTwo, channelName);
|
||||
|
||||
await ensureTimelineScrollable(pageOne, pageTwo, prefix);
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeLessThan(8);
|
||||
await waitForPinnedTimeline(pageTwo);
|
||||
|
||||
await pageOne.getByTestId("message-input").fill(incomingMessage);
|
||||
await pageOne.getByTestId("send-message").click();
|
||||
|
||||
await expectTimelineToContain(pageTwo, incomingMessage);
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeLessThan(8);
|
||||
await waitForPinnedTimeline(pageTwo);
|
||||
await expect(pageTwo.getByTestId("message-scroll-to-latest")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
@@ -371,9 +464,7 @@ test("stays pinned after you send a message and a remote reply arrives right aft
|
||||
await createAndJoinSharedStream(pageOne, pageTwo, channelName);
|
||||
|
||||
await ensureTimelineScrollable(pageOne, pageTwo, prefix);
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeLessThan(8);
|
||||
await waitForPinnedTimeline(pageTwo);
|
||||
|
||||
await pageTwo.getByTestId("message-input").fill(localMessage);
|
||||
await pageTwo.getByTestId("send-message").click();
|
||||
@@ -383,9 +474,7 @@ test("stays pinned after you send a message and a remote reply arrives right aft
|
||||
await pageOne.getByTestId("send-message").click();
|
||||
|
||||
await expectTimelineToContain(pageTwo, incomingMessage);
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeLessThan(8);
|
||||
await waitForPinnedTimeline(pageTwo);
|
||||
await expect(pageTwo.getByTestId("message-scroll-to-latest")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
@@ -420,9 +509,7 @@ test("keeps bottom-pinned scrolling after the composer grows", async ({
|
||||
await createAndJoinSharedStream(pageOne, pageTwo, channelName);
|
||||
|
||||
await ensureTimelineScrollable(pageOne, pageTwo, prefix);
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeLessThan(8);
|
||||
await waitForPinnedTimeline(pageTwo);
|
||||
|
||||
await receiverInput.fill("Composer pinned line one");
|
||||
await receiverInput.press("Enter");
|
||||
@@ -432,17 +519,13 @@ test("keeps bottom-pinned scrolling after the composer grows", async ({
|
||||
await receiverInput.press("Enter");
|
||||
await receiverInput.type("Composer pinned line four");
|
||||
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeLessThan(8);
|
||||
await waitForPinnedTimeline(pageTwo);
|
||||
|
||||
await pageOne.getByTestId("message-input").fill(incomingMessage);
|
||||
await pageOne.getByTestId("send-message").click();
|
||||
|
||||
await expectTimelineToContain(pageTwo, incomingMessage);
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeLessThan(8);
|
||||
await waitForPinnedTimeline(pageTwo);
|
||||
await expect(pageTwo.getByTestId("message-scroll-to-latest")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
@@ -476,9 +559,7 @@ test("keeps scroll position when new messages arrive above the fold", async ({
|
||||
await createAndJoinSharedStream(pageOne, pageTwo, channelName);
|
||||
|
||||
await ensureTimelineScrollable(pageOne, pageTwo, prefix);
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeLessThan(8);
|
||||
await waitForPinnedTimeline(pageTwo);
|
||||
|
||||
await scrollTimelineAwayFromBottom(pageTwo);
|
||||
|
||||
@@ -495,9 +576,7 @@ test("keeps scroll position when new messages arrive above the fold", async ({
|
||||
await pageTwo.getByTestId("message-scroll-to-latest").click();
|
||||
|
||||
await expectTimelineToContain(pageTwo, incomingMessage);
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeLessThan(8);
|
||||
await waitForPinnedTimeline(pageTwo);
|
||||
} finally {
|
||||
await contextOne.close();
|
||||
await contextTwo.close();
|
||||
|
||||
Reference in New Issue
Block a user