perf(chat): follow the live edge with a direct scroll write

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.
This commit is contained in:
Bohdan Triapitsyn
2026-08-25 14:27:37 +03:00
parent 3703ba730e
commit 87c1ae809d
@@ -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);