diff --git a/src/components/ui/message-scroller-primitive/use-message-scroller-controller.ts b/src/components/ui/message-scroller-primitive/use-message-scroller-controller.ts index c0faa781..cedd11fb 100644 --- a/src/components/ui/message-scroller-primitive/use-message-scroller-controller.ts +++ b/src/components/ui/message-scroller-primitive/use-message-scroller-controller.ts @@ -68,6 +68,7 @@ function useMessageScrollerController({ modeRef, prependRestoreRef, preserveScrollOnPrependRef, + resizeFrameRef, rootRef, scrollEdgeThresholdRef, spacerGapRef, @@ -370,20 +371,40 @@ function useMessageScrollerController({ ]); const handleResize = React.useCallback(() => { - if (modeRef.current === "following-bottom" && autoScrollRef.current) { - scrollToEnd({ behavior: "auto" }); + // Defer the scroll correction out of the ResizeObserver callback. Writing + // scrollTop synchronously here mutates layout inside the same delivery, which + // the browser reports as "ResizeObserver loop completed with undelivered + // notifications" — benign, but it fires on every frame of a streaming reply. + // Coalesce bursts of resize notifications onto a single frame. + if (resizeFrameRef.current !== null) { return; } - // Hold the anchored turn in place as content below it resizes (a reply - // streaming in, or a transient marker collapsing) — otherwise the shrinking - // content lets the browser clamp scrollTop and the turn drops. - if (reanchorToAnchoredMessage()) { - return; - } + resizeFrameRef.current = window.requestAnimationFrame(() => { + resizeFrameRef.current = null; - scheduleStateCommit(); - }, [autoScrollRef, modeRef, reanchorToAnchoredMessage, scheduleStateCommit, scrollToEnd]); + if (modeRef.current === "following-bottom" && autoScrollRef.current) { + scrollToEnd({ behavior: "auto" }); + return; + } + + // Hold the anchored turn in place as content below it resizes (a reply + // streaming in, or a transient marker collapsing) — otherwise the shrinking + // content lets the browser clamp scrollTop and the turn drops. + if (reanchorToAnchoredMessage()) { + return; + } + + scheduleStateCommit(); + }); + }, [ + autoScrollRef, + modeRef, + reanchorToAnchoredMessage, + resizeFrameRef, + scheduleStateCommit, + scrollToEnd, + ]); const userScrollIntent = React.useCallback(() => { if ( @@ -487,12 +508,17 @@ function useMessageScrollerController({ stateFrameRef.current = null; } + if (resizeFrameRef.current !== null) { + window.cancelAnimationFrame(resizeFrameRef.current); + resizeFrameRef.current = null; + } + if (autoscrollingTimeoutRef.current !== null) { window.clearTimeout(autoscrollingTimeoutRef.current); autoscrollingTimeoutRef.current = null; } }; - }, [autoscrollingTimeoutRef, stateFrameRef]); + }, [autoscrollingTimeoutRef, resizeFrameRef, stateFrameRef]); React.useLayoutEffect(() => { if (autoScroll && modeRef.current === "following-bottom" && itemCountRef.current > 0) { diff --git a/src/components/ui/message-scroller-primitive/use-message-scroller-refs.ts b/src/components/ui/message-scroller-primitive/use-message-scroller-refs.ts index 40a62ba1..d0a2b705 100644 --- a/src/components/ui/message-scroller-primitive/use-message-scroller-refs.ts +++ b/src/components/ui/message-scroller-primitive/use-message-scroller-refs.ts @@ -27,6 +27,7 @@ type MessageScrollerRefs = { viewportTop: number; } | null>; preserveScrollOnPrependRef: React.RefObject; + resizeFrameRef: React.RefObject; rootRef: React.RefObject; scrollEdgeThresholdRef: React.RefObject; scrollMarginRef: React.RefObject; @@ -73,6 +74,10 @@ function useMessageScrollerRefs({ } | null>(null); const scrollPreviousItemPeekRef = React.useRef(scrollPreviousItemPeek); const preserveScrollOnPrependRef = React.useRef(true); + // Coalesces ResizeObserver-driven scroll corrections onto a frame so the + // scrollTop write lands outside the observer callback (avoids the benign + // "ResizeObserver loop" warning that layout-in-callback triggers). + const resizeFrameRef = React.useRef(null); const rootRef = React.useRef(null); const scrollMarginRef = React.useRef(scrollMargin); const spacerGapRef = React.useRef(0); @@ -106,6 +111,7 @@ function useMessageScrollerRefs({ modeRef, prependRestoreRef, preserveScrollOnPrependRef, + resizeFrameRef, rootRef, scrollEdgeThresholdRef, scrollMarginRef, diff --git a/src/integrations/posthog/provider.tsx b/src/integrations/posthog/provider.tsx index cd9d8d8e..11e44ec8 100644 --- a/src/integrations/posthog/provider.tsx +++ b/src/integrations/posthog/provider.tsx @@ -77,6 +77,22 @@ export function capturePostHogClientException(error: Error, properties?: Record< posthog.captureException(error, properties); } +// Chromium surfaces these as global "error" events with no real Error attached. +// They are benign layout-timing warnings, not exceptions worth tracking, and a +// single chat session can emit hundreds of them — enough to bury real errors. +const BENIGN_BROWSER_ERROR_PATTERNS = [ + "ResizeObserver loop completed with undelivered notifications", + "ResizeObserver loop limit exceeded", +]; + +function isBenignBrowserError(message: string | undefined) { + if (!message) { + return false; + } + + return BENIGN_BROWSER_ERROR_PATTERNS.some((pattern) => message.includes(pattern)); +} + function normalizeBrowserError(value: unknown, fallbackMessage: string) { if (value instanceof Error) { return value; @@ -140,6 +156,10 @@ function PostHogAuthSync() { function PostHogGlobalErrorCapture() { useEffect(() => { const handleError = (event: ErrorEvent) => { + if (isBenignBrowserError(event.message)) { + return; + } + capturePostHogClientException( normalizeBrowserError(event.error ?? event.message, "Unhandled browser error"), {