fix(desktop): preserve selected inbox rows through reflow (#1817)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-07-13 15:16:07 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent a227bb45f6
commit c06ddcf14b
5 changed files with 574 additions and 368 deletions
+45 -347
View File
@@ -20,6 +20,7 @@ import {
} from "@/features/messages/lib/messageGrouping";
import { getThreadReference } from "@/features/messages/lib/threading";
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
import { useAnchoredScroll } from "@/features/messages/ui/useAnchoredScroll";
import { UpdateIndicator } from "@/features/settings/UpdateIndicator";
import type { Channel } from "@/shared/api/types";
import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader";
@@ -115,9 +116,7 @@ export function InboxDetailPane({
onToggleReaction,
}: InboxDetailPaneProps) {
const detailPaneRef = React.useRef<HTMLElement | null>(null);
// Refs for the scroll container and its inner content div — used by the
// post-center anchor hold to compensate for late content growth above the
// selected message (reactions, channel-window merge, image decode).
// Refs for the shared anchored-scroll hook's container and content roots.
const scrollContainerRef = React.useRef<HTMLDivElement | null>(null);
const contentRef = React.useRef<HTMLDivElement | null>(null);
const [replyTargetId, setReplyTargetId] = React.useState<string | null>(null);
@@ -129,15 +128,47 @@ export function InboxDetailPane({
// scroll centering) key on this.
const conversationId = item?.conversationId ?? null;
const selectedChannelId = item?.item.channelId ?? null;
// Scroll key: changes only when the user switches to a different conversation
// or selects a different event anchor (which triggers centering once). Live
// message arrivals in the same conversation do NOT change this key.
const selectedMessageScrollKey = React.useMemo(() => {
if (!conversationId || !selectedEventId) {
return null;
}
return `${conversationId}:${selectedEventId}`;
}, [conversationId, selectedEventId]);
// Build the plain, non-virtualized timeline the shared hook anchors against.
// Live arrivals rerun its layout compensation without changing the target.
const selectedMessage = messages.find((message) => message.isSelected);
const pendingReplyMessages: InboxDisplayMessage[] = replies.map((reply) => ({
...reply,
depth: reply.depth ?? (selectedMessage?.depth ?? 0) + 1,
isSelected: false,
mentionNames: [],
}));
const displayMessages: InboxDisplayMessage[] =
messages.length > 0
? [...messages, ...pendingReplyMessages]
: item
? [
{
authorLabel: item.senderLabel,
authorPubkey: item.item.pubkey,
avatarUrl: item.avatarUrl,
content: item.preview,
createdAt: item.item.createdAt,
depth: 0,
fullTimestampLabel: item.fullTimestampLabel,
id: item.id,
isSelected: true,
mentionNames: item.mentionNames,
mentionPubkeysByName: item.mentionPubkeysByName,
timeLabel: formatTime(item.item.createdAt),
},
...pendingReplyMessages,
]
: pendingReplyMessages;
const { onScroll } = useAnchoredScroll({
channelId: conversationId,
contentRef,
isLoading: isThreadContextLoading,
messages: displayMessages,
pinTargetCentered: true,
scrollContainerRef,
targetMessageId: selectedEventId,
});
const focusComposer = React.useCallback(() => {
window.requestAnimationFrame(() => {
@@ -171,313 +202,6 @@ export function InboxDetailPane({
};
}, [conversationId]);
// Deferred deliberate-selection centering.
//
// Bug fixed here: the old one-shot rAF fired on the click render, before
// useInboxThreadContext had started its async fetch. The fetch then prepended
// older messages ABOVE the viewport, shifting it mid-thread.
//
// Fix: arm a pending-center ref on each new (conversationId, selectedEventId)
// pair. If isThreadContextLoading is already false when the rAF fires (no
// fetch needed), execute immediately. If loading starts before/as the rAF
// fires, cancel the rAF and re-execute once loading transitions true → false.
// User scroll before the center fires cancels it (never yank the reader).
//
// Effect-ordering note: on the click commit, InboxDetailPane effects run
// before HomeView (child-first), so isThreadContextLoading is still false
// at that instant — the "is not loading right now" guard alone is NOT
// sufficient; we must observe the true → false transition instead.
// The isLoading ref is kept up-to-date unconditionally so rAF callbacks
// always read the current value (closures would capture stale renders).
const pendingCenterKeyRef = React.useRef<string | null>(null);
const userScrolledRef = React.useRef(false);
const prevLoadingRef = React.useRef(isThreadContextLoading);
const isLoadingRef = React.useRef(isThreadContextLoading);
// Keep isLoadingRef current every render so rAF callbacks see the live value.
isLoadingRef.current = isThreadContextLoading;
// Post-center anchor hold.
//
// After the deliberate-selection center fires, reactions, channel-window
// merges, and image decodes can add content ABOVE the selected message.
// The browser's native scroll anchoring pins the *topmost visible row*, so
// growth between that row and the selected message pushes the selected row
// down without compensation — producing the "correct snap → brief pause →
// jumps up 2-3 messages" symptom.
//
// Fix (mirrors useAnchoredScroll.ts:423-433): after the center fires,
// hold the selected row's absolute position within the scroll container's
// content (invariant under user scroll: scrollBy on same axis changes both
// bcrect.top and scrollTop by the same amount, so the sum is stable). On
// every subsequent message-list commit (useLayoutEffect), re-measure and
// compensate with scrollBy(0, drift) when content above the anchor has grown.
// Release the hold on user interaction or selection-key change.
//
// Measurement: contentTop = bcrect.top + scrollTop - container.bcrect.top.
// This is scroll-invariant: a user scroll ±D changes bcrect.top by ∓D and
// scrollTop by ±D, leaving the sum unchanged. Only content growth above the
// anchor changes contentTop.
const anchorHoldRef = React.useRef<{
contentTop: number;
key: string;
} | null>(null);
// The expected target of a programmatic write. The scroll listener consumes
// only an event at this exact position; a no-op write leaves it unset, and a
// later real scroll at a different position still releases the hold.
const programmaticScrollTopRef = React.useRef<number | null>(null);
const isWritingScrollRef = React.useRef(false);
const selectedMessageScrollKeyRef = React.useRef(selectedMessageScrollKey);
selectedMessageScrollKeyRef.current = selectedMessageScrollKey;
// Captures the hold after the center fires. Called from both center paths.
const captureAnchorHold = React.useCallback((key: string) => {
const container = scrollContainerRef.current;
if (!container) return;
const selectedRow = container.querySelector<HTMLElement>(
'[data-testid="home-inbox-selected-message"]',
);
if (!selectedRow) return;
const contentTop =
selectedRow.getBoundingClientRect().top +
container.scrollTop -
container.getBoundingClientRect().top;
anchorHoldRef.current = { contentTop, key };
}, []);
// Releases the hold — called on user interaction and selection-key change.
const releaseAnchorHold = React.useCallback(() => {
anchorHoldRef.current = null;
}, []);
const noteProgrammaticScroll = React.useCallback(
(container: HTMLDivElement, scrollTopBefore: number) => {
if (scrollTopBefore === container.scrollTop) return;
programmaticScrollTopRef.current = container.scrollTop;
// Scroll events run before the next animation frame. Expiring this guard
// prevents a write whose event never arrives from swallowing a later user
// scroll, while still ignoring the matching programmatic scroll event.
window.requestAnimationFrame(() => {
if (programmaticScrollTopRef.current === container.scrollTop) {
programmaticScrollTopRef.current = null;
}
});
},
[],
);
// Arm the pending center whenever the selection key changes.
React.useEffect(() => {
if (!selectedMessageScrollKey) {
pendingCenterKeyRef.current = null;
releaseAnchorHold();
return;
}
pendingCenterKeyRef.current = selectedMessageScrollKey;
userScrolledRef.current = false;
releaseAnchorHold();
// Attempt the center after the current paint. By the time this rAF fires,
// any synchronous state updates (including isLoading → true) will have
// been committed. Read from the ref so we see the live value.
const rafId = window.requestAnimationFrame(() => {
if (isLoadingRef.current) {
// Loading is in progress — cancel now; the transition effect will fire.
return;
}
if (
pendingCenterKeyRef.current === selectedMessageScrollKey &&
!userScrolledRef.current
) {
pendingCenterKeyRef.current = null;
const container = scrollContainerRef.current;
const scrollTopBefore = container?.scrollTop;
isWritingScrollRef.current = true;
detailPaneRef.current
?.querySelector<HTMLElement>(
'[data-testid="home-inbox-selected-message"]',
)
?.scrollIntoView({ block: "center" });
isWritingScrollRef.current = false;
if (container && scrollTopBefore !== undefined) {
noteProgrammaticScroll(container, scrollTopBefore);
}
captureAnchorHold(selectedMessageScrollKey);
}
});
return () => {
window.cancelAnimationFrame(rafId);
};
}, [
selectedMessageScrollKey,
captureAnchorHold,
releaseAnchorHold,
noteProgrammaticScroll,
]);
// Fire the deferred center when loading transitions true → false.
React.useEffect(() => {
const wasLoading = prevLoadingRef.current;
prevLoadingRef.current = isThreadContextLoading;
if (wasLoading && !isThreadContextLoading) {
// Loading just settled. If a center is still pending for the current
// selection key and the user hasn't scrolled, execute it now.
if (
pendingCenterKeyRef.current === selectedMessageScrollKey &&
selectedMessageScrollKey !== null &&
!userScrolledRef.current
) {
pendingCenterKeyRef.current = null;
const container = scrollContainerRef.current;
const scrollTopBefore = container?.scrollTop;
isWritingScrollRef.current = true;
detailPaneRef.current
?.querySelector<HTMLElement>(
'[data-testid="home-inbox-selected-message"]',
)
?.scrollIntoView({ block: "center" });
isWritingScrollRef.current = false;
if (container && scrollTopBefore !== undefined) {
noteProgrammaticScroll(container, scrollTopBefore);
}
captureAnchorHold(selectedMessageScrollKey);
}
}
}, [
isThreadContextLoading,
selectedMessageScrollKey,
captureAnchorHold,
noteProgrammaticScroll,
]);
// Compensate for late content growth above the anchor (reactions, channel-
// window merge, image decode). Runs as a layout effect so drift is corrected
// before paint, preventing visible flicker.
// biome-ignore lint/correctness/useExhaustiveDependencies: messages and replies are the reactive triggers that drive displayMessages; we intentionally re-run on every message-list commit to catch reaction/merge re-renders; scrollContainerRef is a stable ref
React.useLayoutEffect(() => {
const hold = anchorHoldRef.current;
const container = scrollContainerRef.current;
if (
!hold ||
!container ||
hold.key !== selectedMessageScrollKeyRef.current
) {
return;
}
const selectedRow = container.querySelector<HTMLElement>(
'[data-testid="home-inbox-selected-message"]',
);
if (!selectedRow) return;
// Recompute contentTop: scroll-invariant absolute position within content.
const currentContentTop =
selectedRow.getBoundingClientRect().top +
container.scrollTop -
container.getBoundingClientRect().top;
const drift = currentContentTop - hold.contentTop;
if (Math.abs(drift) > 0.5) {
hold.contentTop = currentContentTop;
const scrollTopBefore = container.scrollTop;
isWritingScrollRef.current = true;
container.scrollBy(0, drift);
isWritingScrollRef.current = false;
noteProgrammaticScroll(container, scrollTopBefore);
}
}, [messages, replies, noteProgrammaticScroll]);
// ResizeObserver: compensate for non-React resizes (image decode, embed
// expand) that grow content above the anchor without triggering a React
// re-render. Keyed on conversationId so the observer is re-attached when
// a new conversation opens and contentRef.current becomes a fresh node.
// biome-ignore lint/correctness/useExhaustiveDependencies: conversationId is the re-attachment trigger; the effect body reads only stable refs
React.useEffect(() => {
const content = contentRef.current;
if (!content || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
const hold = anchorHoldRef.current;
const container = scrollContainerRef.current;
if (
!hold ||
!container ||
hold.key !== selectedMessageScrollKeyRef.current
) {
return;
}
const selectedRow = container.querySelector<HTMLElement>(
'[data-testid="home-inbox-selected-message"]',
);
if (!selectedRow) return;
const currentContentTop =
selectedRow.getBoundingClientRect().top +
container.scrollTop -
container.getBoundingClientRect().top;
const drift = currentContentTop - hold.contentTop;
if (Math.abs(drift) > 0.5) {
hold.contentTop = currentContentTop;
const scrollTopBefore = container.scrollTop;
isWritingScrollRef.current = true;
container.scrollBy(0, drift);
isWritingScrollRef.current = false;
noteProgrammaticScroll(container, scrollTopBefore);
}
});
observer.observe(content);
return () => {
observer.disconnect();
};
}, [conversationId]);
// Cancel the pending center if the user scrolls before it fires.
// Keyed on conversationId so listeners are reinstalled when a conversation
// opens — detailPaneRef.current is null before the item branch renders, so
// a [] effect would attach to null and miss all subsequent selections.
// biome-ignore lint/correctness/useExhaustiveDependencies: conversationId is not used inside the effect body; it is listed as a dep solely to trigger re-attachment when a new conversation opens and detailPaneRef.current becomes non-null
React.useEffect(() => {
const pane = detailPaneRef.current;
const container = scrollContainerRef.current;
if (!pane || !container) return;
const handleUserInteraction = () => {
userScrolledRef.current = true;
releaseAnchorHold();
};
const handleContainerScroll = () => {
if (isWritingScrollRef.current) return;
if (programmaticScrollTopRef.current === container.scrollTop) {
programmaticScrollTopRef.current = null;
return;
}
programmaticScrollTopRef.current = null;
handleUserInteraction();
};
pane.addEventListener("wheel", handleUserInteraction, { passive: true });
pane.addEventListener("touchstart", handleUserInteraction, {
passive: true,
});
pane.addEventListener("keydown", handleUserInteraction, { passive: true });
container.addEventListener("scroll", handleContainerScroll, {
passive: true,
});
return () => {
pane.removeEventListener("wheel", handleUserInteraction);
pane.removeEventListener("touchstart", handleUserInteraction);
pane.removeEventListener("keydown", handleUserInteraction);
container.removeEventListener("scroll", handleContainerScroll);
};
}, [conversationId, releaseAnchorHold]);
// Capture the default composer reply parent from the selected-event anchor
// when the conversation first opens (or when the user explicitly navigates
// to a different event anchor). Reset only when conversationId/selectedEventId
@@ -569,33 +293,6 @@ export function InboxDetailPane({
);
}
const selectedMessage = messages.find((message) => message.isSelected);
const pendingReplyMessages: InboxDisplayMessage[] = replies.map((reply) => ({
...reply,
depth: reply.depth ?? (selectedMessage?.depth ?? 0) + 1,
isSelected: false,
mentionNames: [],
}));
const displayMessages: InboxDisplayMessage[] =
messages.length > 0
? [...messages, ...pendingReplyMessages]
: [
{
authorLabel: item.senderLabel,
authorPubkey: item.item.pubkey,
avatarUrl: item.avatarUrl,
content: item.preview,
createdAt: item.item.createdAt,
depth: 0,
fullTimestampLabel: item.fullTimestampLabel,
id: item.id,
isSelected: true,
mentionNames: item.mentionNames,
mentionPubkeysByName: item.mentionPubkeysByName,
timeLabel: formatTime(item.item.createdAt),
},
...pendingReplyMessages,
];
const replyTarget =
displayMessages.find((message) => message.id === replyTargetId) ?? null;
// Explicit sub-message reply wins. Otherwise use the captured default parent
@@ -719,7 +416,8 @@ export function InboxDetailPane({
<div
aria-busy={isThreadContextLoading}
className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-32"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-32 [overflow-anchor:none]"
onScroll={onScroll}
ref={scrollContainerRef}
>
<div ref={contentRef}>
@@ -102,6 +102,7 @@ export function InboxMessageRow({
"group/message relative z-10 mx-1 flex gap-2.5 rounded-2xl px-2 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
isContinuation ? "items-center" : "items-start",
)}
data-message-id={message.id}
data-testid={
message.isSelected
? "home-inbox-selected-message"
@@ -0,0 +1,274 @@
import assert from "node:assert/strict";
import test from "node:test";
function installDOMShim() {
class EventTargetShim {
constructor() {
this.listeners = new Map();
}
addEventListener(type, listener) {
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
}
removeEventListener(type, listener) {
this.listeners.set(
type,
(this.listeners.get(type) ?? []).filter(
(current) => current !== listener,
),
);
}
dispatchEvent(event) {
for (const listener of this.listeners.get(event.type) ?? [])
listener(event);
return true;
}
}
class NodeShim extends EventTargetShim {
constructor(tagName) {
super();
this.tagName = tagName;
this.nodeName = tagName.toUpperCase();
this.nodeType = 1;
this.namespaceURI = "http://www.w3.org/1999/xhtml";
this.children = [];
this.childNodes = [];
this.style = {};
this.parentNode = null;
}
get ownerDocument() {
return globalThis.document;
}
get firstChild() {
return this.children[0] ?? null;
}
get lastChild() {
return this.children.at(-1) ?? null;
}
get nextSibling() {
return null;
}
get nodeValue() {
return null;
}
appendChild(child) {
this.children.push(child);
this.childNodes.push(child);
child.parentNode = this;
return child;
}
removeChild(child) {
this.children = this.children.filter((current) => current !== child);
this.childNodes = this.childNodes.filter((current) => current !== child);
child.parentNode = null;
return child;
}
insertBefore(child, reference) {
if (!reference) return this.appendChild(child);
const index = this.children.indexOf(reference);
if (index < 0) return this.appendChild(child);
this.children.splice(index, 0, child);
this.childNodes.splice(index, 0, child);
child.parentNode = this;
return child;
}
contains(node) {
return (
this === node || this.children.some((child) => child.contains(node))
);
}
}
class DocumentShim extends EventTargetShim {
constructor() {
super();
this.nodeType = 9;
this.defaultView = globalThis;
}
createElement(tagName) {
return new NodeShim(tagName);
}
createTextNode(value) {
const node = new NodeShim("#text");
node.nodeType = 3;
node.nodeValue = value;
return node;
}
createComment(value) {
const node = new NodeShim("#comment");
node.nodeType = 8;
node.nodeValue = value;
return node;
}
get activeElement() {
return null;
}
}
globalThis.document = new DocumentShim();
globalThis.HTMLIFrameElement = NodeShim;
globalThis.HTMLElement = NodeShim;
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
process.env.IS_REACT_ACT_ENVIRONMENT = "true";
Object.defineProperty(globalThis, "window", {
configurable: true,
value: globalThis,
});
globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0);
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
globalThis.CSS = { escape: (value) => value };
}
installDOMShim();
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { useAnchoredScroll } from "./useAnchoredScroll.ts";
function makePinnedCenterNodes() {
const resizeObservers = [];
const content = {};
const container = {
clientHeight: 400,
listeners: new Map(),
scrollHeight: 1_000,
scrollTop: 100,
scrollWrites: [],
addEventListener(type, listener) {
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
},
dispatchEvent(event) {
for (const listener of this.listeners.get(event.type) ?? [])
listener(event);
},
getBoundingClientRect() {
return { top: 0 };
},
querySelector() {
return row;
},
querySelectorAll() {
return [row];
},
removeEventListener(type, listener) {
this.listeners.set(
type,
(this.listeners.get(type) ?? []).filter(
(current) => current !== listener,
),
);
},
scrollBy(_x, y) {
this.scrollTop += y;
this.scrollWrites.push(y);
},
scrollTo({ top }) {
this.scrollTop = top;
},
};
let contentTop = 300;
const row = {
dataset: { messageId: "selected" },
getBoundingClientRect() {
const top = contentTop - container.scrollTop;
return { bottom: top + 40, height: 40, top };
},
scrollIntoView() {
container.scrollTop = 100;
},
};
globalThis.ResizeObserver = class {
constructor(callback) {
this.callback = callback;
resizeObservers.push(this);
}
disconnect() {}
observe(target) {
this.target = target;
}
};
return {
container,
content,
moveSelectedRowBy: (pixels) => {
contentTop += pixels;
},
resizeObservers,
};
}
function Harness({ channelId, refs }) {
useAnchoredScroll({
channelId,
contentRef: refs.content,
isLoading: false,
messages: [{ id: "selected" }],
pinTargetCentered: true,
scrollContainerRef: refs.container,
targetMessageId: "selected",
});
return null;
}
test("channel change attaches pinned-center observers after refs mount", async () => {
const refs = {
container: { current: null },
content: { current: null },
};
const root = createRoot(document.createElement("div"));
await act(async () => {
root.render(React.createElement(Harness, { channelId: null, refs }));
});
const nodes = makePinnedCenterNodes();
refs.container.current = nodes.container;
refs.content.current = nodes.content;
await act(async () => {
root.render(
React.createElement(Harness, { channelId: "conversation", refs }),
);
});
assert.equal(nodes.resizeObservers.length, 1);
assert.equal(nodes.resizeObservers[0].target, nodes.content);
assert.equal(nodes.container.listeners.get("wheel")?.length, 1);
await act(async () => {
nodes.container.dispatchEvent({ type: "wheel" });
});
nodes.moveSelectedRowBy(96);
nodes.resizeObservers[0].callback();
assert.deepEqual(
nodes.container.scrollWrites,
[],
"wheel release prevents a later resize from re-pinning the selected row",
);
await act(async () => {
root.unmount();
});
});
@@ -2,7 +2,9 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
getPinnedCenterDrift,
settleProgrammaticBottomPin,
shouldIgnorePinnedCenterScroll,
shouldSettleForSplitPanel,
shouldSettleVirtualizedBottom,
} from "./useAnchoredScroll.ts";
@@ -124,3 +126,44 @@ test("settleProgrammaticBottomPin keeps settling when the floor is still out of
2,
);
});
test("pinned center drift re-pins only after meaningful layout growth", () => {
assert.equal(
getPinnedCenterDrift({ contentTop: 400, currentContentTop: 400.5 }),
null,
);
assert.equal(
getPinnedCenterDrift({ contentTop: 400, currentContentTop: 496 }),
96,
);
});
test("pinned center programmatic scroll event preserves the anchor", () => {
assert.equal(
shouldIgnorePinnedCenterScroll({
currentScrollTop: 596,
expectedScrollTop: 596,
isWritingScroll: false,
}),
true,
);
assert.equal(
shouldIgnorePinnedCenterScroll({
currentScrollTop: 596,
expectedScrollTop: null,
isWritingScroll: true,
}),
true,
);
});
test("pinned center real user scroll releases the anchor", () => {
assert.equal(
shouldIgnorePinnedCenterScroll({
currentScrollTop: 620,
expectedScrollTop: 596,
isWritingScroll: false,
}),
false,
);
});
@@ -21,7 +21,31 @@ const TRUE_BOTTOM_THRESHOLD_PX = 1;
type AnchorState =
| { kind: "at-bottom" }
| { kind: "message"; messageId: string; topOffset: number };
| { kind: "message"; messageId: string; topOffset: number }
| { kind: "pinned-center"; messageId: string; contentTop: number };
export function getPinnedCenterDrift({
contentTop,
currentContentTop,
}: {
contentTop: number;
currentContentTop: number;
}): number | null {
const drift = currentContentTop - contentTop;
return Math.abs(drift) > 0.5 ? drift : null;
}
export function shouldIgnorePinnedCenterScroll({
currentScrollTop,
expectedScrollTop,
isWritingScroll,
}: {
currentScrollTop: number;
expectedScrollTop: number | null;
isWritingScroll: boolean;
}): boolean {
return isWritingScroll || expectedScrollTop === currentScrollTop;
}
type BottomSettleContainer = Pick<
HTMLDivElement,
@@ -80,6 +104,8 @@ type UseAnchoredScrollOptions = {
/** When set, scroll to and highlight this message on mount and on change. */
targetMessageId?: string | null;
/** Keeps a targeted message centered until the user deliberately scrolls. */
pinTargetCentered?: boolean;
onTargetReached?: (messageId: string) => void;
virtualScrollToMessage?: (
messageId: string,
@@ -160,8 +186,11 @@ function isAtTrueBottom(
* choice is what keeps the row the reader is *reading* fixed under
* in-viewport reflow (image-load, embed expansion).
*/
function computeAnchor(container: HTMLDivElement): AnchorState {
if (isAtBottomNow(container)) {
function computeAnchor(
container: HTMLDivElement,
treatNearBottomAsBottom = true,
): AnchorState {
if (treatNearBottomAsBottom && isAtBottomNow(container)) {
return { kind: "at-bottom" };
}
@@ -195,6 +224,7 @@ export function useAnchoredScroll({
splitPanelOpen = false,
targetMessageId = null,
pinTargetCentered = false,
onTargetReached,
virtualScrollToMessage,
virtualScrollToBottom,
@@ -238,6 +268,11 @@ export function useAnchoredScroll({
// ignores transient gaps and keeps chasing the floor. A `ref`, not state — the
// guard runs on a native scroll event, outside React's render cycle.
const settlingRef = React.useRef(false);
// Pinned-center corrections write scroll position themselves. Keep the next
// matching scroll event from being mistaken for a user releasing the pin.
const programmaticScrollTopRef = React.useRef<number | null>(null);
const isWritingScrollRef = React.useRef(false);
const programmaticScrollRafRef = React.useRef<number | null>(null);
// Reset everything when the channel changes — the layout effect that runs
// immediately after this reset is responsible for either jumping to bottom
@@ -257,6 +292,12 @@ export function useAnchoredScroll({
handledTargetIdRef.current = null;
forceBottomOnNextAppendRef.current = false;
settlingRef.current = false;
programmaticScrollTopRef.current = null;
isWritingScrollRef.current = false;
if (programmaticScrollRafRef.current !== null) {
cancelAnimationFrame(programmaticScrollRafRef.current);
programmaticScrollRafRef.current = null;
}
if (highlightTimeoutRef.current !== null) {
window.clearTimeout(highlightTimeoutRef.current);
highlightTimeoutRef.current = null;
@@ -267,6 +308,75 @@ export function useAnchoredScroll({
}
}, [channelId]);
const noteProgrammaticScroll = React.useCallback(
(container: HTMLDivElement, scrollTopBefore: number) => {
if (scrollTopBefore === container.scrollTop) return;
programmaticScrollTopRef.current = container.scrollTop;
if (programmaticScrollRafRef.current !== null) {
cancelAnimationFrame(programmaticScrollRafRef.current);
}
// A programmatic scroll event is delivered before the next frame. If the
// browser does not emit one, expire the guard so a later user scroll is
// never swallowed.
programmaticScrollRafRef.current = requestAnimationFrame(() => {
if (programmaticScrollTopRef.current === container.scrollTop) {
programmaticScrollTopRef.current = null;
}
programmaticScrollRafRef.current = null;
});
},
[],
);
const writePinnedCenterScroll = React.useCallback(
(container: HTMLDivElement, write: () => void) => {
const scrollTopBefore = container.scrollTop;
isWritingScrollRef.current = true;
write();
isWritingScrollRef.current = false;
noteProgrammaticScroll(container, scrollTopBefore);
},
[noteProgrammaticScroll],
);
const repinPinnedCenter = React.useCallback(() => {
const anchor = anchorRef.current;
const container = scrollContainerRef.current;
if (anchor.kind !== "pinned-center" || !container) return;
const row = container.querySelector<HTMLElement>(
`[data-message-id="${CSS.escape(anchor.messageId)}"]`,
);
if (!row) return;
const currentContentTop =
row.getBoundingClientRect().top +
container.scrollTop -
container.getBoundingClientRect().top;
const drift = getPinnedCenterDrift({
contentTop: anchor.contentTop,
currentContentTop,
});
if (drift === null) return;
anchor.contentTop = currentContentTop;
writePinnedCenterScroll(container, () => container.scrollBy(0, drift));
}, [scrollContainerRef, writePinnedCenterScroll]);
const releasePinnedCenter = React.useCallback(() => {
const container = scrollContainerRef.current;
if (!container || anchorRef.current.kind !== "pinned-center") return;
// A selected row can sit near the physical floor after its deliberate
// center. A direct user scroll there must still release the center pin;
// otherwise a passive representative update is mistaken for bottom glue.
anchorRef.current = computeAnchor(container, false);
const atBottom = isAtBottomNow(container);
setIsAtBottom((previous) => (previous === atBottom ? previous : atBottom));
if (atBottom) setNewMessageCount(0);
}, [scrollContainerRef]);
const scrollToBottomImperative = React.useCallback(
(behavior: ScrollBehavior = "auto") => {
const container = scrollContainerRef.current;
@@ -379,30 +489,50 @@ export function useAnchoredScroll({
);
const targetTopOffset =
currentTopOffset - (targetScrollTop - container.scrollTop);
const contentTop = rect.top + container.scrollTop - containerRect.top;
container.scrollTo({
top: targetScrollTop,
behavior: options.behavior ?? "auto",
});
if (pinTargetCentered) {
writePinnedCenterScroll(container, () => {
el.scrollIntoView({
block: "center",
behavior: options.behavior ?? "auto",
});
});
anchorRef.current = {
kind: "pinned-center",
messageId,
contentTop,
};
setIsAtBottom(isAtBottomNow(container));
} else {
container.scrollTo({
top: targetScrollTop,
behavior: options.behavior ?? "auto",
});
// Smooth scrolling starts an async animation, so measuring after the call can still return the pre-animation position.
// Save the clamped destination offset instead; otherwise a concurrent
// render/ResizeObserver restore can fight the smooth scroll back toward
// where it started.
anchorRef.current = {
kind: "message",
messageId,
topOffset: targetTopOffset,
};
setIsAtBottom(maxScrollTop - targetScrollTop <= AT_BOTTOM_THRESHOLD_PX);
// Smooth scrolling starts an async animation, so measuring after the call can still return the pre-animation position.
// Save the clamped destination offset instead; otherwise a concurrent
// render/ResizeObserver restore can fight the smooth scroll back toward
// where it started.
anchorRef.current = {
kind: "message",
messageId,
topOffset: targetTopOffset,
};
}
if (!pinTargetCentered) {
setIsAtBottom(maxScrollTop - targetScrollTop <= AT_BOTTOM_THRESHOLD_PX);
}
if (options.highlight) highlightMessage(messageId);
return true;
},
[
highlightMessage,
pinTargetCentered,
scrollContainerRef,
virtualizerOwnsPrependAnchoring,
writePinnedCenterScroll,
virtualScrollToMessage,
],
);
@@ -431,13 +561,33 @@ export function useAnchoredScroll({
return;
}
}
if (anchorRef.current.kind === "pinned-center") {
if (
shouldIgnorePinnedCenterScroll({
currentScrollTop: container.scrollTop,
expectedScrollTop: programmaticScrollTopRef.current,
isWritingScroll: isWritingScrollRef.current,
})
) {
if (programmaticScrollTopRef.current === container.scrollTop) {
programmaticScrollTopRef.current = null;
}
return;
}
releasePinnedCenter();
return;
}
anchorRef.current = computeAnchor(container);
const atBottom = anchorRef.current.kind === "at-bottom";
setIsAtBottom((prev) => (prev === atBottom ? prev : atBottom));
if (atBottom) {
setNewMessageCount(0);
}
}, [scrollContainerRef, virtualizerOwnsPrependAnchoring]);
}, [
releasePinnedCenter,
scrollContainerRef,
virtualizerOwnsPrependAnchoring,
]);
// ---------------------------------------------------------------------------
// Anchor restoration: after every render, stick to the bottom if the user is
@@ -527,7 +677,9 @@ export function useAnchoredScroll({
return;
}
if (anchor.kind === "at-bottom") {
if (anchor.kind === "pinned-center") {
repinPinnedCenter();
} else if (anchor.kind === "at-bottom") {
if (
virtualizerOwnsPrependAnchoring &&
shouldSettleVirtualizedBottom({
@@ -581,6 +733,7 @@ export function useAnchoredScroll({
scrollToBottomImperative,
scrollToMessageImperative,
targetMessageId,
repinPinnedCenter,
virtualScrollToBottom,
virtualSettleAtBottom,
virtualizerOwnsPrependAnchoring,
@@ -594,14 +747,16 @@ export function useAnchoredScroll({
// mid-history, native scroll anchoring (overflow-anchor) holds the reading
// row across the reflow, so there's nothing to do.
// ---------------------------------------------------------------------------
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is a deliberate re-subscription trigger — the effect body reads only the stable refs, but on a channel switch the keyed scroll container remounts and contentRef.current becomes a fresh node, so the observer must disconnect from the previous channel's detached node and re-observe the live one.
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId deliberately re-subscribes after a keyed or conditional scroll-content mount replaces ref.current.
React.useEffect(() => {
const content = contentRef.current;
if (!content || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
const container = scrollContainerRef.current;
if (!container) return;
if (
if (anchorRef.current.kind === "pinned-center") {
repinPinnedCenter();
} else if (
anchorRef.current.kind === "at-bottom" &&
!virtualizerOwnsPrependAnchoring
) {
@@ -613,10 +768,34 @@ export function useAnchoredScroll({
}, [
channelId,
contentRef,
repinPinnedCenter,
scrollContainerRef,
virtualizerOwnsPrependAnchoring,
]);
// Pinned centers survive our own corrections but release as soon as the
// reader deliberately takes control of the scroll position.
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId deliberately re-subscribes after a keyed or conditional scroll-container mount replaces ref.current.
React.useEffect(() => {
if (!pinTargetCentered) return;
const container = scrollContainerRef.current;
if (!container) return;
const handleUserInteraction = () => releasePinnedCenter();
container.addEventListener("wheel", handleUserInteraction, {
passive: true,
});
container.addEventListener("touchstart", handleUserInteraction, {
passive: true,
});
container.addEventListener("keydown", handleUserInteraction);
return () => {
container.removeEventListener("wheel", handleUserInteraction);
container.removeEventListener("touchstart", handleUserInteraction);
container.removeEventListener("keydown", handleUserInteraction);
};
}, [channelId, pinTargetCentered, releasePinnedCenter, scrollContainerRef]);
// ---------------------------------------------------------------------------
// Target message handling (deep link, jump-to-reply, etc.). Distinct from
// the initial-mount target above — this handles changes after the first
@@ -633,8 +812,15 @@ export function useAnchoredScroll({
React.useEffect(() => {
if (!targetMessageId) {
handledTargetIdRef.current = null;
releasePinnedCenter();
return;
}
if (
anchorRef.current.kind === "pinned-center" &&
anchorRef.current.messageId !== targetMessageId
) {
releasePinnedCenter();
}
if (handledTargetIdRef.current === targetMessageId || isLoading) return;
if (!hasInitializedRef.current) return; // initial-mount path will handle.
@@ -664,6 +850,7 @@ export function useAnchoredScroll({
isLoading,
messages,
onTargetReached,
releasePinnedCenter,
scrollContainerRef,
scrollToMessageImperative,
targetMessageId,
@@ -676,6 +863,9 @@ export function useAnchoredScroll({
if (highlightTimeoutRef.current !== null) {
window.clearTimeout(highlightTimeoutRef.current);
}
if (programmaticScrollRafRef.current !== null) {
cancelAnimationFrame(programmaticScrollRafRef.current);
}
};
}, []);