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:
Wes
2026-06-27 07:47:07 -06:00
co-authored by Brain
parent aca40b6289
commit a2005bc27d
2 changed files with 121 additions and 41 deletions
@@ -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,
});
+111 -32
View File
@@ -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();