diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 5d3cf8fd5d..55477e1253 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -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, diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index b865260de0..e604a1aecf 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -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 diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts index 515669407e..d92b6c46e7 100644 --- a/desktop/tests/e2e/scroll-history.spec.ts +++ b/desktop/tests/e2e/scroll-history.spec.ts @@ -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, }) => {