mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
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:
parent
ebd11f6608
commit
948306bfaf
50
desktop/src/features/messages/ui/useAnchoredScroll.test.mjs
Normal file
50
desktop/src/features/messages/ui/useAnchoredScroll.test.mjs
Normal 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,
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user