fix(desktop): settle channel opens at latest message

Wait for the virtualized timeline API before consuming initial channel
positioning, and do not mistake measurement-driven offsets for reader
input while the virtualizer is settling at the floor.

Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
Wes
2026-07-25 08:29:28 -06:00
parent 2a051a404d
commit d4549ab760
3 changed files with 51 additions and 2 deletions

View File

@@ -670,7 +670,12 @@ function VirtualizedTimelineRows({
if (!list || !(scroller instanceof HTMLDivElement)) return;
onVirtualizerRangeChanged?.();
const distanceFromBottom = list.scrollSize - list.viewportSize - offset;
if (distanceFromBottom > 32) cancelBottomSettle();
// Do not infer reader intent from an intermediate virtualizer offset.
// Initial channel positioning deliberately chases the floor while rows
// are measured; those measurements can briefly report a large gap and
// emit `onScroll` without any user input. Cancelling here strands the
// channel above its newest message. The settle hook's wheel, pointer,
// touch, and key listeners are the authoritative user-interaction gate.
onAtBottomStateChange?.(distanceFromBottom <= 32);
if (
prependAnchorRef.current !== null ||
@@ -686,7 +691,6 @@ function VirtualizedTimelineRows({
},
[
armUpwardMomentum,
cancelBottomSettle,
capturePrependAnchor,
onAtBottomStateChange,
onStartReached,

View File

@@ -607,6 +607,11 @@ export function useAnchoredScroll({
// to the requested target message, or to the bottom by default.
if (!hasInitializedRef.current) {
if (isLoading) return;
// The virtualized list owns the actual scroll node. Its API registers in
// a child layout effect, after this parent hook's first pass; treating
// that API-less pass as initialized writes to the inert outer wrapper
// and permanently consumes the channel's initial bottom pin.
if (virtualizerOwnsPrependAnchoring && !virtualScrollToBottom) return;
// Establish the initial position before the browser paints. The follow-up
// frame is a settling pass for content whose measurements land with the
// commit (fonts, deferred rows, media), not the first bottom pin. Keeping

View File

@@ -68,6 +68,46 @@ async function getMessagePosition(
}, messageId);
}
test("channel switch settles at the newest message after virtualized rows measure", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
await page.evaluate(() => {
const base = Math.floor(Date.now() / 1000);
for (let index = 0; index < 80; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `switch-bottom ${index} ${"variable-height ".repeat(
index % 7,
)}`,
createdAt: base + index,
});
}
});
// Open another channel first so this exercises a real channel switch, where
// the virtualizer API is temporarily null while the keyed list remounts.
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const timeline = page.getByTestId("message-timeline");
await expect(timeline).toContainText("switch-bottom 79");
await expect
.poll(async () => {
const metrics = await getTimelineMetrics(page);
return metrics.scrollHeight - metrics.clientHeight - metrics.scrollTop;
})
.toBeLessThanOrEqual(1);
await expect(page.getByTestId("message-scroll-to-latest")).toHaveCount(0);
});
test("first channel load paints the first window without waiting for the row-floor top-up", async ({
page,
}) => {