mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
a227bb45f6
commit
c06ddcf14b
@@ -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);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user