From 948306bfafce478afeececd90a2b117a42f061ba Mon Sep 17 00:00:00 2001 From: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Date: Sat, 27 Jun 2026 20:21:50 -0400 Subject: [PATCH] Fix desktop bottom scroll pin settling Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> --- .../messages/ui/useAnchoredScroll.test.mjs | 50 ++++++++++++++ .../features/messages/ui/useAnchoredScroll.ts | 69 ++++++++++++++++++- 2 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 desktop/src/features/messages/ui/useAnchoredScroll.test.mjs diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.test.mjs b/desktop/src/features/messages/ui/useAnchoredScroll.test.mjs new file mode 100644 index 0000000000..e804ca0951 --- /dev/null +++ b/desktop/src/features/messages/ui/useAnchoredScroll.test.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { settleProgrammaticBottomPin } from "./useAnchoredScroll.ts"; + +function fakeContainer({ clientHeight, scrollHeight, scrollTop }) { + const writes = []; + return { + clientHeight, + scrollHeight, + scrollTop, + writes, + scrollTo({ top, behavior }) { + writes.push({ top, behavior }); + this.scrollTop = top; + }, + }; +} + +test("settleProgrammaticBottomPin chases the physical floor before clearing", () => { + const container = fakeContainer({ + clientHeight: 100, + scrollHeight: 200, + scrollTop: 70, + }); + + assert.equal(settleProgrammaticBottomPin(container), true); + assert.deepEqual(container.writes, [{ top: 200, behavior: "auto" }]); + assert.equal(container.scrollTop, 200); +}); + +test("settleProgrammaticBottomPin keeps settling when the floor is still out of reach", () => { + const container = fakeContainer({ + clientHeight: 100, + scrollHeight: 200, + scrollTop: 70, + }); + container.scrollTo = ({ top, behavior }) => { + container.writes.push({ top, behavior }); + // Browser/virtualizer has not caught up yet: leave a >1px physical gap. + container.scrollTop = 98; + }; + + assert.equal(settleProgrammaticBottomPin(container), false); + assert.deepEqual(container.writes, [{ top: 200, behavior: "auto" }]); + assert.equal( + container.scrollHeight - container.clientHeight - container.scrollTop, + 2, + ); +}); diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index 23e638bf47..fc159aee65 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -9,11 +9,29 @@ import type { TimelineMessage } from "@/features/messages/types"; * rounding from the layout engine. */ const AT_BOTTOM_THRESHOLD_PX = 32; +// Tests and user-visible "pinned" affordances need the view at the physical +// floor, not merely within the looser UI at-bottom threshold. The loose +// threshold decides whether the user is close enough to count as reading the +// latest message; this strict threshold decides when a programmatic bottom pin +// has actually finished settling. +const TRUE_BOTTOM_THRESHOLD_PX = 1; type AnchorState = | { kind: "at-bottom" } | { kind: "message"; messageId: string; topOffset: number }; +type BottomSettleContainer = Pick< + HTMLDivElement, + "scrollHeight" | "clientHeight" | "scrollTop" | "scrollTo" +>; + +export function settleProgrammaticBottomPin( + container: BottomSettleContainer, +): boolean { + container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); + return isAtTrueBottom(container); +} + type UseAnchoredScrollOptions = { /** Scroll container. Owned by the parent so external refs still compose. */ scrollContainerRef: React.RefObject; @@ -68,13 +86,30 @@ type UseAnchoredScrollResult = { ) => boolean; }; -function isAtBottomNow(container: HTMLDivElement) { +function isAtBottomNow( + container: Pick< + HTMLDivElement, + "scrollHeight" | "clientHeight" | "scrollTop" + >, +) { return ( container.scrollHeight - container.clientHeight - container.scrollTop <= AT_BOTTOM_THRESHOLD_PX ); } +function isAtTrueBottom( + container: Pick< + HTMLDivElement, + "scrollHeight" | "clientHeight" | "scrollTop" + >, +) { + return ( + container.scrollHeight - container.clientHeight - container.scrollTop <= + TRUE_BOTTOM_THRESHOLD_PX + ); +} + /** * Pick an anchor for the current scroll position. * @@ -244,6 +279,12 @@ export function useAnchoredScroll({ // appends, we snap to bottom even if they had scrolled up to read history. // Consumed (and cleared) by the next append in the restoration effect. const forceBottomOnNextAppendRef = React.useRef(false); + // True from a programmatic bottom pin until the virtualized list's row + // measurement settles and the view reaches a true physical bottom. During + // this window `onScroll` ignores the transient gap the settle opens (see the + // guard there). A `ref`, not state — the guard runs on a native scroll event, + // outside React's render cycle. + const settlingRef = React.useRef(false); // Reset everything when the channel changes — the layout effect that runs // immediately after this reset is responsible for either jumping to bottom @@ -260,6 +301,7 @@ export function useAnchoredScroll({ fetchingOlderRef.current = false; handledTargetIdRef.current = null; forceBottomOnNextAppendRef.current = false; + settlingRef.current = false; if (highlightTimeoutRef.current !== null) { window.clearTimeout(highlightTimeoutRef.current); highlightTimeoutRef.current = null; @@ -271,6 +313,14 @@ export function useAnchoredScroll({ const container = scrollContainerRef.current; if (!container) return; anchorRef.current = { kind: "at-bottom" }; + // A programmatic jump-to-bottom is not atomic, even for `behavior: "auto"`: + // the browser can emit `scroll` while the virtualized list is still + // settling row measurements. During that window `computeAnchor` may read + // the transient gap as a deliberate scroll-up and latch a mid-history + // message anchor, which strands future appends above the floor. Arm the + // settle guard for every imperative bottom jump so `onScroll` holds the + // at-bottom anchor until it can snap to the true floor. + settlingRef.current = true; container.scrollTo({ top: container.scrollHeight, behavior }); setIsAtBottom(true); setNewMessageCount(0); @@ -352,6 +402,20 @@ export function useAnchoredScroll({ const onScroll = React.useCallback(() => { const container = scrollContainerRef.current; if (!container) return; + // A virtualizer settle grows `scrollHeight` (rows below the fold measure a + // frame or two after a bottom pin) and emits scroll events while + // `scrollTop` holds at the old floor — opening a transient gap above the + // true bottom. `computeAnchor` would read that gap as a deliberate scroll-up + // and latch a message anchor, freezing the view short of the bottom. While + // settling, keep the anchor at-bottom so re-pins can finish chasing the + // floor; clear the window once a scroll event reaches the true bottom. + if (settlingRef.current) { + if (settleProgrammaticBottomPin(container)) { + settlingRef.current = false; + } else { + return; + } + } anchorRef.current = computeAnchor(container); const atBottom = anchorRef.current.kind === "at-bottom"; setIsAtBottom((prev) => (prev === atBottom ? prev : atBottom)); @@ -425,8 +489,9 @@ export function useAnchoredScroll({ // message pulls the view down. if (newLatestArrived && forceBottomOnNextAppendRef.current) { forceBottomOnNextAppendRef.current = false; - container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); anchorRef.current = { kind: "at-bottom" }; + settlingRef.current = true; + container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); setIsAtBottom(true); setNewMessageCount(0); prevLastMessageIdRef.current = lastMessage?.id;