fix(desktop): keep bottom pins through virtualizer settle

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
2026-06-27 08:15:50 -04:00
co-authored by Tyler Longwell
parent f00c86e744
commit 7bc8fd2071
2 changed files with 97 additions and 14 deletions
@@ -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,
);
});
@@ -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>;
@@ -88,13 +106,30 @@ type UseAnchoredScrollResult = {
getAnchorIsAtBottom: () => 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.
*
@@ -346,17 +381,14 @@ export function useAnchoredScroll({
const container = scrollContainerRef.current;
if (!container) return;
anchorRef.current = { kind: "at-bottom" };
// A smooth (animated) jump-to-latest is not atomic: the browser scrolls
// over several frames, and each intermediate `scroll` event would drive
// `onScroll` → `computeAnchor`, which latches a mid-history message anchor
// because the view is transiently NOT at the bottom. The ResizeObserver
// then "restores" that stale anchor and strands the view mid-list. Arm
// the same settle window the mount pin uses so `onScroll` holds the
// at-bottom anchor until the animation lands a true at-bottom; the
// observer's at-bottom re-pin then keeps the view glued as late row
// measurement grows `scrollHeight`. (An instant "auto" scroll completes
// before any scroll event, so it needs no guard.)
if (behavior === "smooth") settlingRef.current = true;
// 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);
@@ -497,7 +529,7 @@ export function useAnchoredScroll({
// once a scroll event reads a genuine at-bottom. Scoped to the initial
// settle so post-settle user scroll-up re-evaluates the anchor normally.
if (settlingRef.current) {
if (isAtBottomNow(container)) {
if (settleProgrammaticBottomPin(container)) {
settlingRef.current = false;
} else {
return;
@@ -640,8 +672,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;