fix(timeline): cede the resize observer to the load-older index restore

The load-older index restore (useLoadOlderOnScroll) is the single scroll
writer across a prepend, and the layout effect cedes to it via the isPrepend
bail. The ResizeObserver in useAnchoredScroll did not: it ceded only for
convergence. So when the prepended rows measured late and grew scrollHeight,
the observer fired with the now-windowed-out message anchor, hit
restoreAnchorToMessage's all-gone fallback, pinned to the floor, and stomped
the index restore's correct offset.

Add a shared in-flight flag the index loop sets while it owns scroll, checked
at the top of the ResizeObserver callback alongside the convergence cede. The
restore math and the all-gone fallback are unchanged; the observer simply
defers to the single writer for the duration of the prepend.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-06-18 19:38:18 -04:00
parent 4ac28bfd67
commit fe489d81a8
3 changed files with 58 additions and 2 deletions

View File

@@ -258,6 +258,7 @@ const MessageTimelineBase = React.forwardRef<
scrollToBottom,
scrollToBottomOnNextUpdate,
scrollToMessage,
setLoadOlderRestoreInFlight,
} = useAnchoredScroll({
channelId,
contentRef,
@@ -375,6 +376,7 @@ const MessageTimelineBase = React.forwardRef<
hasOlderMessages,
isLoading: showTimelineSkeleton,
restoreScrollPosition,
setLoadOlderRestoreInFlight,
scrollContainerRef,
sentinelRef: topSentinelRef,
virtualizer: virtualizerOption,

View File

@@ -66,6 +66,10 @@ type UseAnchoredScrollResult = {
* then re-seats the anchor + at-bottom bookkeeping so the next passive
* restore and `isAtBottom` read agree with where we put the scroll. */
restoreScrollPosition: (scrollTop: number) => void;
/** Brackets the load-older index restore's scroll ownership. While `true`,
* the ResizeObserver cedes — the index path is the sole `scrollTop` writer
* across the prepend, mirroring the `convergingTargetIdRef` cede. */
setLoadOlderRestoreInFlight: (inFlight: boolean) => void;
};
function isAtBottomNow(container: HTMLDivElement) {
@@ -248,6 +252,15 @@ export function useAnchoredScroll({
// the same two-writer contention the prepend bail prevents. Cleared when the
// target settles (consumer clears the route param → `targetMessageId` null).
const convergingTargetIdRef = React.useRef<string | null>(null);
// Set while `useLoadOlderOnScroll`'s index-restore loop owns scroll across a
// load-older prepend. That loop drives the virtualizer to re-aim the anchored
// row as the prepended rows measure; the ResizeObserver must cede for the
// same reason it cedes during convergence — otherwise the prepended rows
// growing `scrollHeight` fire the observer with the (now windowed-out) anchor,
// its all-gone fallback pins to the floor, and stomps the index restore's
// correct offset. The layout effect already cedes the prepend (isPrepend
// bail); this is the matching cede for the non-React-driven observer writer.
const loadOlderRestoreInFlightRef = React.useRef(false);
const highlightTimeoutRef = React.useRef<number | null>(null);
// One-shot: the consumer calls `scrollToBottomOnNextUpdate()` right before
// it sends a message (see ChannelPane). When the user's own message then
@@ -270,6 +283,7 @@ export function useAnchoredScroll({
prevMessageCountRef.current = 0;
handledTargetIdRef.current = null;
convergingTargetIdRef.current = null;
loadOlderRestoreInFlightRef.current = false;
forceBottomOnNextAppendRef.current = false;
if (highlightTimeoutRef.current !== null) {
window.clearTimeout(highlightTimeoutRef.current);
@@ -371,6 +385,12 @@ export function useAnchoredScroll({
[scrollContainerRef, syncAnchorAfterProgrammaticScroll],
);
// Let the load-older index path mark its scroll-ownership window so the
// ResizeObserver cedes to it (see `loadOlderRestoreInFlightRef`).
const setLoadOlderRestoreInFlight = React.useCallback((inFlight: boolean) => {
loadOlderRestoreInFlightRef.current = inFlight;
}, []);
// Scroll handler: recompute anchor + bottom state from the current
// scroll position. Cheap enough to run on every scroll event — a single
// `getBoundingClientRect` walk plus rect reads.
@@ -549,6 +569,13 @@ export function useAnchoredScroll({
// loop is the sole writer until it settles (mirrors the layout effect's
// `convergingTargetIdRef` bail).
if (convergingTargetIdRef.current !== null) return;
// Cede while the load-older index restore owns scroll. The prepended rows
// measuring late is exactly what grows `scrollHeight` and fires this
// observer; if it re-pinned here the anchor row is already windowed out,
// so the all-gone fallback would pin to the floor and stomp the index
// restore's correct offset. The index loop is the sole writer until it
// settles (mirrors the layout effect's isPrepend bail).
if (loadOlderRestoreInFlightRef.current) return;
const anchor = anchorRef.current;
if (anchor.kind === "at-bottom") {
// Stuck to bottom: re-pin to the new floor. Virtualizer measurement
@@ -648,5 +675,6 @@ export function useAnchoredScroll({
scrollToBottomOnNextUpdate,
scrollToMessage: scrollToMessageImperative,
restoreScrollPosition,
setLoadOlderRestoreInFlight,
};
}

View File

@@ -8,6 +8,15 @@ type UseLoadOlderOnScrollOptions = {
hasOlderMessages: boolean;
isLoading: boolean;
restoreScrollPosition: (scrollTop: number) => void;
/**
* Brackets the index-restore loop's scroll ownership so the anchored hook's
* ResizeObserver cedes for the duration of the prepend. Without it, the
* prepended rows measuring late fire the observer with the windowed-out
* anchor and its all-gone fallback pins the view to the floor, stomping this
* loop's correct re-aim. Only the virtualized (index) path needs it — the
* non-virtualized path's restore is a single synchronous `scrollTop` write.
*/
setLoadOlderRestoreInFlight?: (inFlight: boolean) => void;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
sentinelRef: React.RefObject<HTMLDivElement | null>;
/**
@@ -34,6 +43,7 @@ export function useLoadOlderOnScroll({
hasOlderMessages,
isLoading,
restoreScrollPosition,
setLoadOlderRestoreInFlight,
scrollContainerRef,
sentinelRef,
virtualizer = null,
@@ -42,6 +52,12 @@ export function useLoadOlderOnScroll({
React.useEffect(() => {
restoreScrollPositionRef.current = restoreScrollPosition;
});
// Mirror the cede setter so the long-lived Intersection observer reads the
// live callback without re-subscribing (same rationale as the restore ref).
const setInFlightRef = React.useRef(setLoadOlderRestoreInFlight);
React.useEffect(() => {
setInFlightRef.current = setLoadOlderRestoreInFlight;
});
// Mirror the virtualizer option into a ref so the long-lived Intersection
// observer reads the live getter + count without re-subscribing per render.
const virtualizerRef = React.useRef(virtualizer);
@@ -97,6 +113,10 @@ export function useLoadOlderOnScroll({
const previousCount = virt.itemCount;
void fetchOlder().then(() => {
// Claim scroll ownership for the whole re-aim window so the
// anchored hook's ResizeObserver cedes while the prepended rows
// measure (released at every exit below via `finishPrepend`).
setInFlightRef.current?.(true);
// Capture the anchor at fetch-RESOLVE time, not sentinel-fire
// time: the history request can be in flight for a while and the
// user may keep scrolling during it (the e2e scrolls further down
@@ -138,6 +158,12 @@ export function useLoadOlderOnScroll({
let frame = 0;
let lastTarget: number | null = null;
let stableFrames = 0;
// Release scroll ownership (re-enabling the ResizeObserver) and
// re-arm the sentinel observer. Called at both loop exits.
const finishPrepend = () => {
setInFlightRef.current?.(false);
observe();
};
const waitForPrepend = () => {
const after = virtualizerRef.current;
const grew =
@@ -171,7 +197,7 @@ export function useLoadOlderOnScroll({
// against ending before the last row measures.
stableFrames += 1;
if (stableFrames >= 2) {
observe();
finishPrepend();
return;
}
}
@@ -179,7 +205,7 @@ export function useLoadOlderOnScroll({
}
frame += 1;
if (frame >= maxFrames) {
observe();
finishPrepend();
return;
}
requestAnimationFrame(waitForPrepend);