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>
This commit is contained in:
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr
2026-06-27 20:21:50 -04:00
parent ebd11f6608
commit 948306bfaf
2 changed files with 117 additions and 2 deletions

View File

@@ -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,
);
});

View File

@@ -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<HTMLDivElement | null>;
@@ -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;