From 3950947c58df63e6934b889e62f33485aacd49a6 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 30 Mar 2026 22:45:58 +0300 Subject: [PATCH] fix(site): prevent scroll handler from killing autoScroll during pin convergence (#23818) WebKit internally adjusts scrollTop during layout when content above the viewport changes height, even with overflow-anchor:none. These phantom adjustments fire scroll events where isNearBottom returns false. The scroll handler was setting autoScrollRef = nearBottom on every such event, permanently killing follow mode. The scroll handler now only enables follow mode, never disables it. When follow mode is active, the user is not wheel/touch scrolling, and isNearBottom is false, this indicates a browser-initiated scroll adjustment. Re-pin immediately and set the restore guard so the pin's own scroll event is suppressed. Disabling follow mode is exclusive to user-interaction handlers (wheel, touch, scrollbar pointerdown) via handleUserInterrupt. Guard-clear callbacks also check isNearBottom before dropping the restoration flag, re-pinning if content grew between the pin and the clear. --- .../AgentsPage/AgentChatPageView.stories.tsx | 8 ++ .../pages/AgentsPage/AgentChatPageView.tsx | 82 +++++++++++++++++-- 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index 4739a41a9d..cf8bbded47 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -524,6 +524,14 @@ const waitForScrollOverflow = async (scrollContainer: HTMLElement) => { }; const scrollAwayFromBottom = (scrollContainer: HTMLElement) => { + // Dispatch a wheel event first so the scroll handler treats + // this as user-initiated scrolling and disables follow mode. + // A bare scrollTop assignment fires a scroll event but the + // handler only re-pins (never disables autoScroll) unless + // a user-interaction event (wheel/touch/pointer) is active. + scrollContainer.dispatchEvent( + new WheelEvent("wheel", { bubbles: true, deltaY: -100 }), + ); scrollContainer.scrollTop = 0; scrollContainer.dispatchEvent(new Event("scroll")); }; diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index ce733e8113..3768ef6ae4 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -850,15 +850,21 @@ const ScrollAnchoredContainer: FC<{ ); setShowScrollToBottom(false); restoreGuardRafIdRef.current = requestAnimationFrame(() => { - isRestoringScrollRef.current = false; restoreGuardRafIdRef.current = null; + // Content may have grown between the pin and + // this callback. Re-pin instead of dropping the + // guard if we drifted off-bottom. + if (!isNearBottom(container)) { + scheduleBottomPin(); + return; + } + isRestoringScrollRef.current = false; }); }); }); }; cancelPendingPinsRef.current = cancelPendingPins; - const observer = new ResizeObserver((entries) => { const entry = entries[0]; const nextHeight = @@ -975,12 +981,25 @@ const ScrollAnchoredContainer: FC<{ 0, ); restoreGuardRafIdRef.current = requestAnimationFrame(() => { - isRestoringScrollRef.current = false; restoreGuardRafIdRef.current = null; + if (!isNearBottom(container)) { + // Content grew between the pin and this frame. + // Re-pin directly since scheduleBottomPin is in + // a different effect closure. + container.scrollTop = Math.max( + container.scrollHeight - container.clientHeight, + 0, + ); + restoreGuardRafIdRef.current = requestAnimationFrame(() => { + restoreGuardRafIdRef.current = null; + isRestoringScrollRef.current = false; + }); + return; + } + isRestoringScrollRef.current = false; }); }); observer.observe(container); - return () => { observer.disconnect(); // The shared restoreGuardRafIdRef is cancelled by the @@ -1005,7 +1024,7 @@ const ScrollAnchoredContainer: FC<{ // scroll-to-bottom), suppress normal handling. Clear the // guard once the scroll reaches the bottom so normal // tracking resumes. User-input interruptions are handled - // separately via wheel/touchstart listeners. + // separately via wheel/touchstart/pointerdown listeners. if (isRestoringScrollRef.current) { if (isNearBottom(container)) { isRestoringScrollRef.current = false; @@ -1016,7 +1035,44 @@ const ScrollAnchoredContainer: FC<{ } const nearBottom = isNearBottom(container); - autoScrollRef.current = nearBottom; + // Only ENABLE follow mode from the scroll handler. + // Never disable it here. WebKit internally adjusts + // scrollTop during layout when content above the + // viewport changes height (a form of scroll anchoring + // it applies even with overflow-anchor:none). These + // browser-initiated adjustments fire scroll events + // that see isNearBottom=false and would permanently + // kill follow mode. Disabling follow is exclusive to + // user-interaction handlers (wheel, touch, scrollbar + // pointer) which call handleUserInterrupt directly. + if (nearBottom) { + autoScrollRef.current = true; + } else if ( + autoScrollRef.current && + !isWheelScrollingRef.current && + activeTouchCountRef.current === 0 + ) { + // Follow mode is on but we are not at bottom, and + // the user is not actively scrolling. This indicates + // WebKit adjusted scrollTop during layout (a form of + // scroll anchoring it applies even with + // overflow-anchor:none). Re-pin immediately and keep + // the restore guard up so the next scroll event from + // this pin is suppressed. + isRestoringScrollRef.current = true; + container.scrollTop = Math.max( + container.scrollHeight - container.clientHeight, + 0, + ); + if (restoreGuardRafIdRef.current !== null) { + cancelAnimationFrame(restoreGuardRafIdRef.current); + } + restoreGuardRafIdRef.current = requestAnimationFrame(() => { + restoreGuardRafIdRef.current = null; + isRestoringScrollRef.current = false; + }); + return; + } // Throttle the button visibility state update to once per // frame. This is the only part that triggers a re-render. @@ -1135,6 +1191,19 @@ const ScrollAnchoredContainer: FC<{ container.addEventListener("wheel", handleWheel, { passive: true, }); + // Scrollbar-track drags don't fire wheel or touch events. + // Detect them via pointerdown on the container element + // itself (content clicks target child elements, scrollbar + // clicks target the scroller). This is the only non-wheel, + // non-touch user interaction that scrolls. + const handlePointerDown = (event: PointerEvent) => { + if (event.target === container) { + handleUserInterrupt(); + } + }; + container.addEventListener("pointerdown", handlePointerDown, { + passive: true, + }); container.addEventListener("touchstart", handleTouchStart, { passive: true, }); @@ -1160,6 +1229,7 @@ const ScrollAnchoredContainer: FC<{ return () => { container.removeEventListener("scroll", handleScroll); container.removeEventListener("wheel", handleWheel); + container.removeEventListener("pointerdown", handlePointerDown); container.removeEventListener("touchstart", handleTouchStart); container.removeEventListener("touchend", handleTouchEnd); container.removeEventListener("touchcancel", handleTouchEnd);