diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 1c214ba769..fc3fa1c28d 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -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, diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index 347daf5822..024e80faa2 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -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(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(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, }; } diff --git a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts index 54d42ad9c8..1f092ac64e 100644 --- a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts +++ b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts @@ -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; sentinelRef: React.RefObject; /** @@ -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);