From 76ba0c673c82617cd76cf37b1e35d872c52c159f Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Fri, 21 Aug 2026 22:41:49 +0200 Subject: [PATCH] Cache max scroll offset outside the timeline scroll hot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile telemetry shows 1,500+ scroll stalls on div.thread-scrollbar. BottomAnchoredScrollBody read scrollHeight/clientHeight on every scroll and wheel event (syncBottomStateFromScroll, markWheelScrollIntent, and the throttled writeScrollAnchor), forcing a synchronous layout pass per event on an unvirtualized timeline of up to ~5,400 DOM nodes. Mirror useStickyBottomScroll's established pattern: cache scrollHeight - clientHeight in a ref and let per-scroll-event code read only scrollTop plus the cache. The cache refreshes only where layout legitimately changes: the ResizeObserver callback (it already watches both the scroll port and the content wrapper), the bottom-restore loop (which deliberately re-reads fresh geometry after content growth), the programmatic scroll paths (scrollToBottom, clamped reveal, saved-row restore, prepend compensation), and the one-shot unmount anchor flush. Two guards keep the cache honest: - Shrink-edge verification: on a content-shrink frame the scroll event outruns the ResizeObserver refresh, so a still-pinned viewport compares its clamped scrollTop against a stale-high max and reads as a user detach — unrecoverable, since the bottom-restore is suppressed once stick-to-bottom is off (deterministic on iOS: tap-collapsing a long tool output while pinned). On the attach->detach edge only, one fresh read re-tests the predicate before flipping state; the same edge verification guards markWheelScrollIntent and writeScrollAnchor. Growth stays cache-only (stale-low is safe). Zero reads per steady-state scroll event, one per detach edge. - The cache is only authoritative after the first ResizeObserver delivery; before that (or under a polyfill that never fires, as in the shared vitest setup) hot paths fall back to live reads, the pre-cache behavior, instead of trusting a frozen value. jsdom tests now deliver the ResizeObserver notification a real browser fires when scroll geometry changes. New tests pin the contract: getter spies prove one read on the detach edge and zero across a mid-timeline burst and re-attach; a shrink-frame regression test proves a pinned viewport stays pinned and never persists a detached anchor; isAtBottom threshold transitions stay correct against the resize-refreshed cache. Co-Authored-By: Claude Fable 5 --- ...d-scroll-body.scroll-preservation.test.tsx | 234 +++++++++++++++++- .../ui/bottom-anchored-scroll-body.tsx | 203 +++++++++++---- 2 files changed, 386 insertions(+), 51 deletions(-) diff --git a/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx b/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx index ab5b1a1985..90cf54e3f8 100644 --- a/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx +++ b/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx @@ -232,13 +232,18 @@ describe("BottomAnchoredScrollBody scroll preservation", () => { top: 80, bottom: 180, }); + // Content lays out settled at the bottom; the ResizeObserver delivery + // refreshes the component's cached max scroll offset, exactly as a real + // browser does whenever the scroll port or content wrapper resizes. setScrollMetrics(scrollArea, { scrollHeight: 400, clientHeight: 100, - scrollTop: 150, + scrollTop: 300, }); + getLatestResizeObserver().trigger(); // User-intent scroll away from bottom, then a scroll event triggers capture. + scrollArea.scrollTop = 150; fireEvent.wheel(scrollArea); fireEvent.scroll(scrollArea); @@ -271,9 +276,11 @@ describe("BottomAnchoredScrollBody scroll preservation", () => { setScrollMetrics(scrollArea, { scrollHeight: 400, clientHeight: 100, - scrollTop: 150, + scrollTop: 300, }); + getLatestResizeObserver().trigger(); + scrollArea.scrollTop = 150; fireEvent.wheel(scrollArea); fireEvent.scroll(scrollArea); @@ -302,9 +309,11 @@ describe("BottomAnchoredScrollBody scroll preservation", () => { setScrollMetrics(scrollArea, { scrollHeight: 1_400, clientHeight: 100, - scrollTop: 1_000, + scrollTop: 1_300, }); + getLatestResizeObserver().trigger(); + scrollArea.scrollTop = 1_000; fireEvent.wheel(scrollArea); fireEvent.scroll(scrollArea); @@ -330,9 +339,11 @@ describe("BottomAnchoredScrollBody scroll preservation", () => { setScrollMetrics(scrollArea, { scrollHeight: 400, clientHeight: 100, - scrollTop: 150, + scrollTop: 300, }); + getLatestResizeObserver().trigger(); + scrollArea.scrollTop = 150; fireEvent.wheel(scrollArea, { deltaY: -100 }); fireEvent.scroll(scrollArea); fireEvent.click(getByRole("button", { name: "Capture prepend anchor" })); @@ -400,6 +411,7 @@ describe("BottomAnchoredScrollBody scroll preservation", () => { clientHeight: 100, scrollTop: 300, }); + getLatestResizeObserver().trigger(); fireEvent.scroll(scrollArea); @@ -641,8 +653,10 @@ describe("BottomAnchoredScrollBody scroll preservation", () => { setScrollMetrics(a1.scrollArea, { scrollHeight: 400, clientHeight: 100, - scrollTop: 150, + scrollTop: 300, }); + getLatestResizeObserver().trigger(); + a1.scrollArea.scrollTop = 150; fireEvent.wheel(a1.scrollArea); fireEvent.scroll(a1.scrollArea); a1.unmount(); @@ -664,8 +678,10 @@ describe("BottomAnchoredScrollBody scroll preservation", () => { setScrollMetrics(b.scrollArea, { scrollHeight: 400, clientHeight: 100, - scrollTop: 150, + scrollTop: 300, }); + getLatestResizeObserver().trigger(); + b.scrollArea.scrollTop = 150; fireEvent.wheel(b.scrollArea); fireEvent.scroll(b.scrollArea); b.unmount(); @@ -702,4 +718,210 @@ describe("BottomAnchoredScrollBody scroll preservation", () => { expect(a2.scrollArea.scrollTop).toBe(220); }); + + it("never reads scrollHeight or clientHeight from per-scroll-event handlers", () => { + const { scrollArea, rowElements } = renderTimeline({ + threadId: "thread-a", + rowIds: ["row-a", "row-b", "row-c"], + }); + mockScrollAreaRect(scrollArea); + mockRowRect(requireHTMLElement(rowElements.get("row-a")!), { + top: -120, + bottom: -20, + }); + mockRowRect(requireHTMLElement(rowElements.get("row-b")!), { + top: -20, + bottom: 80, + }); + mockRowRect(requireHTMLElement(rowElements.get("row-c")!), { + top: 80, + bottom: 180, + }); + setScrollMetrics(scrollArea, { + scrollHeight: 400, + clientHeight: 100, + scrollTop: 300, + }); + getLatestResizeObserver().trigger(); + + // From here on, every scrollHeight/clientHeight read is observable. On an + // unvirtualized timeline those getters force a full synchronous layout + // pass, so scroll/wheel handlers must run on the cached max offset alone. + const readScrollHeight = vi.fn(() => 400); + const readClientHeight = vi.fn(() => 100); + Object.defineProperty(scrollArea, "scrollHeight", { + configurable: true, + get: readScrollHeight, + }); + Object.defineProperty(scrollArea, "clientHeight", { + configurable: true, + get: readClientHeight, + }); + + // The attach -> detach edge is allowed exactly one verification read (the + // content-shrink guard re-testing the cached off-bottom classification). + scrollArea.scrollTop = 150; + fireEvent.wheel(scrollArea); + fireEvent.scroll(scrollArea); + expect(readScrollHeight).toHaveBeenCalledTimes(1); + expect(readClientHeight).toHaveBeenCalledTimes(1); + readScrollHeight.mockClear(); + readClientHeight.mockClear(); + + // Steady state — a mid-timeline scroll burst, a wheel-down, and a return + // to the bottom — must be entirely read-free. + for (let scrollTop = 140; scrollTop >= 50; scrollTop -= 10) { + scrollArea.scrollTop = scrollTop; + fireEvent.scroll(scrollArea); + } + fireEvent.wheel(scrollArea, { deltaY: 120 }); + scrollArea.scrollTop = 300; + fireEvent.scroll(scrollArea); + + expect(readScrollHeight).not.toHaveBeenCalled(); + expect(readClientHeight).not.toHaveBeenCalled(); + // The cached geometry still classified the burst correctly: the detach + // captured the top-most visible row mid-timeline... + expect(readAnchor("thread-a")).toEqual({ + rowId: "row-b", + offsetWithinRow: 20, + atBottom: false, + }); + // ...and the final scroll re-attached to the bottom, so the next growth + // (a legitimate fresh read in the resize path) restores to the new max. + setScrollMetrics(scrollArea, { + scrollHeight: 500, + clientHeight: 100, + scrollTop: 300, + }); + getLatestResizeObserver().trigger(); + expect(scrollArea.scrollTop).toBe(400); + }); + + it("stays pinned when a shrink-frame scroll event outruns the resize refresh", () => { + const { scrollArea, rowElements } = renderTimeline({ + threadId: "thread-a", + rowIds: ["row-a", "row-b", "row-c"], + }); + mockScrollAreaRect(scrollArea); + mockRowRect(requireHTMLElement(rowElements.get("row-a")!), { + top: -120, + bottom: -20, + }); + mockRowRect(requireHTMLElement(rowElements.get("row-b")!), { + top: -20, + bottom: 80, + }); + // Pinned at the bottom of settled content; the cached max offset is 300. + setScrollMetrics(scrollArea, { + scrollHeight: 400, + clientHeight: 100, + scrollTop: 300, + }); + getLatestResizeObserver().trigger(); + + // iOS tap-collapsing a long tool output while pinned: the touch marks + // user intent, the content shrinks (new max offset 100), and the browser + // clamps scrollTop and delivers the scroll event BEFORE the + // ResizeObserver refresh — the cache still says 300. + fireEvent.touchStart(scrollArea); + setScrollMetrics(scrollArea, { + scrollHeight: 200, + clientHeight: 100, + scrollTop: 100, + }); + fireEvent.scroll(scrollArea); + + // The stale-high cache reads 200px off-bottom with recent intent, which + // would detach for good (the bottom-restore is suppressed once + // stick-to-bottom is off) and persist a mid-timeline row anchor. The + // detach-edge verification must keep us pinned instead. + expect(readAnchor("thread-a")).toEqual({ + rowId: "", + offsetWithinRow: 0, + atBottom: true, + }); + + // The late resize delivery finds stick-to-bottom intact, so further + // content growth keeps following the bottom. + getLatestResizeObserver().trigger(); + setScrollMetrics(scrollArea, { + scrollHeight: 250, + clientHeight: 100, + scrollTop: 100, + }); + getLatestResizeObserver().trigger(); + expect(scrollArea.scrollTop).toBe(150); + }); + + it("tracks isAtBottom transitions against the cache refreshed by resizes", () => { + const { scrollArea, rowElements } = renderTimeline({ + threadId: "thread-a", + rowIds: ["row-a", "row-b", "row-c"], + }); + mockScrollAreaRect(scrollArea); + mockRowRect(requireHTMLElement(rowElements.get("row-a")!), { + top: -120, + bottom: -20, + }); + mockRowRect(requireHTMLElement(rowElements.get("row-b")!), { + top: -20, + bottom: 80, + }); + setScrollMetrics(scrollArea, { + scrollHeight: 400, + clientHeight: 100, + scrollTop: 300, + }); + getLatestResizeObserver().trigger(); + + // Detach: against the cached max offset (300), 100 is far off the bottom. + scrollArea.scrollTop = 100; + fireEvent.wheel(scrollArea); + fireEvent.scroll(scrollArea); + expect(readAnchor("thread-a")).toEqual({ + rowId: "row-b", + offsetWithinRow: 20, + atBottom: false, + }); + + // Content doubles while detached: the resize refreshes the cache (max + // offset 700) without yanking the detached viewport. + setScrollMetrics(scrollArea, { + scrollHeight: 800, + clientHeight: 100, + scrollTop: 100, + }); + getLatestResizeObserver().trigger(); + expect(scrollArea.scrollTop).toBe(100); + + // 694 is 6px shy of the refreshed bottom (700), outside the 4px threshold: + // still detached. The stale pre-resize max (300) would misclassify it as + // at-bottom and the growth below would yank to the new maximum. + fireEvent.wheel(scrollArea, { deltaY: 400 }); + scrollArea.scrollTop = 694; + fireEvent.scroll(scrollArea); + + setScrollMetrics(scrollArea, { + scrollHeight: 900, + clientHeight: 100, + scrollTop: 694, + }); + getLatestResizeObserver().trigger(); + expect(scrollArea.scrollTop).toBe(694); + + // 797 is 3px shy of the again-refreshed bottom (800): re-attaches... + fireEvent.wheel(scrollArea, { deltaY: 200 }); + scrollArea.scrollTop = 797; + fireEvent.scroll(scrollArea); + + // ...so the next content growth follows the bottom again. + setScrollMetrics(scrollArea, { + scrollHeight: 1_000, + clientHeight: 100, + scrollTop: 797, + }); + getLatestResizeObserver().trigger(); + expect(scrollArea.scrollTop).toBe(900); + }); }); diff --git a/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx b/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx index b7e86e432d..eee0a5e491 100644 --- a/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx +++ b/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx @@ -129,19 +129,15 @@ export function useBottomAnchoredScroll(): BottomAnchorContextValue | null { return useContext(BottomAnchorContext); } +// Reading `scrollHeight`/`clientHeight` forces synchronous layout (WebKit +// especially), so only the cache-refresh paths may call this — never +// per-scroll-event code. See `refreshMaxScrollOffset` in the component. function getMaxScrollOffset(element: HTMLElement) { return Math.max(0, element.scrollHeight - element.clientHeight); } -function isScrolledNearBottom(element: HTMLElement) { - return ( - getMaxScrollOffset(element) - element.scrollTop <= - BOTTOM_ANCHOR_THRESHOLD_PX - ); -} - -function scrollElementToBottom(element: HTMLElement) { - element.scrollTop = getMaxScrollOffset(element); +function isScrolledNearBottom(maxScrollOffset: number, scrollTop: number) { + return maxScrollOffset - scrollTop <= BOTTOM_ANCHOR_THRESHOLD_PX; } function isElementFullyVisibleInScrollArea({ @@ -168,13 +164,6 @@ function getScrollOffsetToRevealElement({ ); } -function getRevealScrollOffsetClampedToMax(args: ElementVisibilityArgs) { - return Math.min( - getMaxScrollOffset(args.scrollArea), - getScrollOffsetToRevealElement(args), - ); -} - interface TopMostVisibleRow { rowId: string; offsetWithinRow: number; @@ -315,6 +304,23 @@ export function BottomAnchoredScrollBody({ trailingTimeout: number | null; }>({ lastWriteAt: 0, trailingTimeout: null }); const userDetachedFromBottomRef = useRef(false); + // Cached `scrollHeight - clientHeight` of the scroll area. Reading those two + // properties forces synchronous layout in WebKit, which on an unvirtualized + // timeline (thousands of DOM nodes) stalls every scroll event. Mirroring + // useStickyBottomScroll, per-scroll-event handlers read only `scrollTop` + // plus this cache; it is refreshed where layout legitimately changes — the + // ResizeObserver (which watches both the scroll port and the content + // wrapper, so every size change lands there), the programmatic + // scroll/restore paths, which need fresh geometry anyway, and one fresh + // verification read on the attach->detach edge (see + // syncBottomStateFromScroll for the content-shrink race it covers). + const maxScrollOffsetRef = useRef(0); + // The cache is only authoritative once the ResizeObserver has delivered: + // without deliveries (no ResizeObserver in the environment, or a no-op + // polyfill that never fires) nothing keeps it fresh, so reads fall back to + // live geometry — the pre-cache behavior — instead of trusting a frozen + // value that would classify every position as at-bottom. + const resizeObserverHasDeliveredRef = useRef(false); const [isAtBottom, setIsAtBottom] = useState(true); const initialScrollRestoreRowId = useMemo(() => { if (scrollAnchorThreadId === undefined) return null; @@ -328,6 +334,22 @@ export function BottomAnchoredScrollBody({ const getScrollElement = useCallback(() => scrollAreaRef.current, []); + const refreshMaxScrollOffset = useCallback((scrollArea: HTMLElement) => { + const maxScrollOffset = getMaxScrollOffset(scrollArea); + maxScrollOffsetRef.current = maxScrollOffset; + return maxScrollOffset; + }, []); + + // Hot-path read: the cache once the ResizeObserver has delivered, a live + // read before that (and forever, when no observer will ever fire). + const readMaxScrollOffset = useCallback( + (scrollArea: HTMLElement) => + resizeObserverHasDeliveredRef.current + ? maxScrollOffsetRef.current + : refreshMaxScrollOffset(scrollArea), + [refreshMaxScrollOffset], + ); + const cancelPendingScrollRestore = useCallback(() => { pendingScrollRestoreRef.current = null; }, []); @@ -344,19 +366,25 @@ export function BottomAnchoredScrollBody({ // once we're pinned again. // // CSS scroll anchoring (the trailing sentinel) keeps scrollTop pinned at - // sub-pixel precision during content growth/shrink. `scrollElementToBottom` - // sets `scrollTop = scrollHeight - clientHeight` — both integer-rounded - // Web API values — so calling it while we're already within sub-pixel - // range yanks scrollTop by ±1px against the browser's fractional value, - // producing visible jitter on every frame of a row expand/collapse. - // Restore only when anchoring has actually let us drift away from bottom. + // sub-pixel precision during content growth/shrink. Setting + // `scrollTop = scrollHeight - clientHeight` — both integer-rounded Web API + // values — while we're already within sub-pixel range yanks scrollTop by + // ±1px against the browser's fractional value, producing visible jitter on + // every frame of a row expand/collapse. Restore only when anchoring has + // actually let us drift away from bottom. + // + // This runs after observed size changes, so it deliberately reads fresh + // geometry — layout has genuinely changed — and refreshes the cache with it. const restoreBottomOnce = useCallback(() => { const scrollArea = scrollAreaRef.current; if (!scrollArea || !shouldStickToBottomRef.current) return false; - if (isScrolledNearBottom(scrollArea)) return false; - scrollElementToBottom(scrollArea); + const maxScrollOffset = refreshMaxScrollOffset(scrollArea); + if (isScrolledNearBottom(maxScrollOffset, scrollArea.scrollTop)) { + return false; + } + scrollArea.scrollTop = maxScrollOffset; return true; - }, []); + }, [refreshMaxScrollOffset]); const queueBottomRestore = useCallback(() => { if (!shouldStickToBottomRef.current) return; @@ -398,10 +426,10 @@ export function BottomAnchoredScrollBody({ shouldStickToBottomRef.current = true; setIsAtBottom(true); if (scrollArea) { - scrollElementToBottom(scrollArea); + scrollArea.scrollTop = refreshMaxScrollOffset(scrollArea); } queueBottomRestore(); - }, [cancelPendingScrollRestore, queueBottomRestore]); + }, [cancelPendingScrollRestore, queueBottomRestore, refreshMaxScrollOffset]); const scrollElementIntoView = useCallback( ({ element, options }: ScrollElementIntoViewArgs) => { @@ -428,12 +456,16 @@ export function BottomAnchoredScrollBody({ return; } - scrollArea.scrollTop = getRevealScrollOffsetClampedToMax({ - element, - scrollArea, - }); + const maxScrollOffset = refreshMaxScrollOffset(scrollArea); + scrollArea.scrollTop = Math.min( + maxScrollOffset, + getScrollOffsetToRevealElement({ element, scrollArea }), + ); - const targetIsAtBottom = isScrolledNearBottom(scrollArea); + const targetIsAtBottom = isScrolledNearBottom( + maxScrollOffset, + scrollArea.scrollTop, + ); shouldStickToBottomRef.current = targetIsAtBottom; setIsAtBottom(targetIsAtBottom); @@ -444,7 +476,7 @@ export function BottomAnchoredScrollBody({ cancelQueuedRestore(); }, - [cancelQueuedRestore, queueBottomRestore], + [cancelQueuedRestore, queueBottomRestore, refreshMaxScrollOffset], ); const captureScrollAnchor = useCallback(() => { @@ -464,6 +496,9 @@ export function BottomAnchoredScrollBody({ if (delta <= 0) return; scrollArea.scrollTop = anchor.scrollTop + delta; pendingPrependAnchorRef.current = null; + // Content height just changed under us; fold it into the cache now instead + // of waiting for the ResizeObserver delivery at the end of the frame. + refreshMaxScrollOffset(scrollArea); }); const hasRecentUserScrollIntent = useCallback(() => { @@ -483,7 +518,19 @@ export function BottomAnchoredScrollBody({ if (scrollAnchorThreadId === undefined) return; const scrollArea = scrollAreaOverride ?? scrollAreaRef.current; if (!scrollArea) return; - const atBottomByGeometry = isScrolledNearBottom(scrollArea); + let atBottomByGeometry = isScrolledNearBottom( + readMaxScrollOffset(scrollArea), + scrollArea.scrollTop, + ); + if (!atBottomByGeometry && shouldStickToBottomRef.current) { + // Same content-shrink edge as syncBottomStateFromScroll: while still + // attached, verify an off-bottom reading with fresh geometry before + // letting it demote the anchor to a mid-timeline row. + atBottomByGeometry = isScrolledNearBottom( + refreshMaxScrollOffset(scrollArea), + scrollArea.scrollTop, + ); + } const recentUserIntent = hasRecentUserScrollIntent(); const anchorAtom = threadTimelineScrollAnchorAtomFamily(scrollAnchorThreadId); @@ -519,7 +566,13 @@ export function BottomAnchoredScrollBody({ atBottom: false, }); }, - [hasRecentUserScrollIntent, scrollAnchorThreadId, store], + [ + hasRecentUserScrollIntent, + readMaxScrollOffset, + refreshMaxScrollOffset, + scrollAnchorThreadId, + store, + ], ); const captureScrollAnchorThrottled = useCallback(() => { @@ -562,13 +615,13 @@ export function BottomAnchoredScrollBody({ scrollArea, }); const targetScrollTop = Math.min( - getMaxScrollOffset(scrollArea), + refreshMaxScrollOffset(scrollArea), revealOffset + anchor.offsetWithinRow, ); scrollArea.scrollTop = targetScrollTop; return targetScrollTop; }, - [cancelQueuedRestore], + [cancelQueuedRestore, refreshMaxScrollOffset], ); const markUserScrollIntent = useCallback(() => { @@ -579,13 +632,29 @@ export function BottomAnchoredScrollBody({ const markWheelScrollIntent = useCallback( (event: WheelEvent) => { const scrollArea = scrollAreaRef.current; - if (event.deltaY > 0 && scrollArea && isScrolledNearBottom(scrollArea)) { - userScrollIntentUntilRef.current = 0; - return; + // Wheel events fire at scroll rate; run on the cached max offset and + // spend a fresh verification read only when a still-attached viewport + // reads as off-bottom (the content-shrink edge described in + // syncBottomStateFromScroll). While detached, wheeling stays cache-only. + if (event.deltaY > 0 && scrollArea) { + const nearBottom = + isScrolledNearBottom( + readMaxScrollOffset(scrollArea), + scrollArea.scrollTop, + ) || + (shouldStickToBottomRef.current && + isScrolledNearBottom( + refreshMaxScrollOffset(scrollArea), + scrollArea.scrollTop, + )); + if (nearBottom) { + userScrollIntentUntilRef.current = 0; + return; + } } markUserScrollIntent(); }, - [markUserScrollIntent], + [markUserScrollIntent, readMaxScrollOffset, refreshMaxScrollOffset], ); const markTouchStartScrollIntent = useCallback(() => { @@ -637,7 +706,33 @@ export function BottomAnchoredScrollBody({ return; } - if (isScrolledNearBottom(scrollArea)) { + // Cached max offset: this runs on every scroll event of an unvirtualized + // timeline, where a scrollHeight/clientHeight read would force a full + // layout pass per event. + let nearBottom = isScrolledNearBottom( + readMaxScrollOffset(scrollArea), + scrollArea.scrollTop, + ); + if ( + !nearBottom && + shouldStickToBottomRef.current && + hasRecentUserScrollIntent() + ) { + // Attach -> detach edge. On a content-shrink frame this scroll event + // outruns the ResizeObserver refresh: the browser has already clamped + // scrollTop to the new, smaller maximum while the cache still holds the + // old one, so a still-pinned viewport reads as a user detach — with no + // recovery, because the bottom-restore is suppressed once + // stick-to-bottom is off (deterministic on iOS, e.g. tap-collapsing a + // long tool output while pinned). Spend one fresh read on this edge + // only to re-test; steady-state scrolling stays cache-only. + nearBottom = isScrolledNearBottom( + refreshMaxScrollOffset(scrollArea), + scrollArea.scrollTop, + ); + } + + if (nearBottom) { userDetachedFromBottomRef.current = false; shouldStickToBottomRef.current = true; userScrollIntentUntilRef.current = 0; @@ -656,7 +751,12 @@ export function BottomAnchoredScrollBody({ cancelQueuedRestore(); // The user is scrolling on their own; don't yank them back to the anchor. pendingScrollRestoreRef.current = null; - }, [cancelQueuedRestore, hasRecentUserScrollIntent]); + }, [ + cancelQueuedRestore, + hasRecentUserScrollIntent, + readMaxScrollOffset, + refreshMaxScrollOffset, + ]); const handleScroll = useCallback(() => { syncBottomStateFromScroll(); @@ -698,11 +798,21 @@ export function BottomAnchoredScrollBody({ }, [applyScrollRestore, queueBottomRestore]); const handleScrollAreaResize = useCallback(() => { + const scrollArea = scrollAreaRef.current; + if (scrollArea) { + // The steady-state cache refresh: the observer watches both the scroll + // port and the content wrapper, so every legitimate + // scrollHeight/clientHeight change passes through here. The first + // delivery is also what makes the cache authoritative for hot-path + // reads (see resizeObserverHasDeliveredRef). + refreshMaxScrollOffset(scrollArea); + resizeObserverHasDeliveredRef.current = true; + } // While a restore is pending, the ResizeObserver is the settle signal; the // bottom-restore is suppressed (stick-to-bottom is false) anyway. if (advancePendingScrollRestore()) return; queueBottomRestore(); - }, [advancePendingScrollRestore, queueBottomRestore]); + }, [advancePendingScrollRestore, queueBottomRestore, refreshMaxScrollOffset]); // Begin restoring the saved scroll position on mount, before the listener // effect's `queueBottomRestore()` runs (a useEffect, which runs after layout @@ -752,9 +862,12 @@ export function BottomAnchoredScrollBody({ window.clearTimeout(captureThrottle.trailingTimeout); captureThrottle.trailingTimeout = null; } + // One-shot unmount flush: the cache may lag the final layout by a frame + // and this write decides where the user comes back to, so read fresh. + refreshMaxScrollOffset(scrollArea); writeScrollAnchor(scrollArea); }, - [writeScrollAnchor], + [refreshMaxScrollOffset, writeScrollAnchor], ); useLayoutEffect(() => {