From 87c1ae809de2ea279c80bdaea985f1ce7dda0ef4 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 25 Aug 2026 14:27:37 +0300 Subject: [PATCH] perf(chat): follow the live edge with a direct scroll write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streaming follow scheduled a two-frame animation-frame chain per content growth and then went through the list's programmatic scrollToEnd, whose scroll bookkeeping schedules further frames. Together they roughly doubled frame production for the whole stream (16 -> 35 frames/s in a profiled streaming session), with the matching style recalc and layout cost per frame. In following-end mode, correct synchronously in the totalSize listener by writing scrollTop directly — the same write path a user gesture takes — and keep the animation-frame chain only for the anchored-turn glide. Profiled streaming frame production returns to the previous engine's range and peak heap drops from 164 MB to 90 MB. --- .../ui/src/hooks/useChatTimelineScroll.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index f509a57b..62addff5 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -523,6 +523,28 @@ export const useChatTimelineScroll = ({ const onTimelineDataChange = React.useCallback(() => { if (!isLiveFollowActive()) return; + // Following the end needs no animation frames: the totalSize listener + // already fires after measurement, so correct synchronously the way + // the previous scroll engine wrote scrollTop directly. Scheduling a + // two-frame chain per streamed chunk kept a continuous rAF load (and + // its per-frame style recalcs) running for the whole stream. + if (modeRef.current === 'following-end') { + const list = listRef.current; + if (!list) return; + if (!realContentOverflowsViewport(list)) return; + // Write scrollTop directly instead of going through scrollToEnd: + // the list's programmatic-scroll machinery schedules follow-up + // animation frames per call, which doubles frame production for + // the whole stream. A direct write is what a user gesture does, + // and the list reconciles it through its normal onScroll path. + const node = list.getScrollableNode(); + if (!node) return; + // Overshoot so the browser clamps to the exact fractional maximum + // (scrollHeight is integer-rounded). + node.scrollTop = node.scrollHeight + 4096; + return; + } + const frames = dataChangeFramesRef.current; if (frames.first !== null) cancelAnimationFrame(frames.first); if (frames.second !== null) cancelAnimationFrame(frames.second);