mirror of
https://github.com/block/buzz.git
synced 2026-11-09 02:41:57 +00:00
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:
parent
4ac28bfd67
commit
fe489d81a8
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user