fix(chat): release end pinning while the list width resizes

A pinned viewport shook during window resizes while a scrolled-up one
stayed calm: both pinning mechanisms — the growth corrections and the
list's maintain-scroll-at-end — re-assert the end against rows that are
still re-measuring, fighting the size compensation that keeps the free
case stable. While the width is actively changing, stand both down and
hold the reading position the same way the free path does, then re-assert
the live edge once with a single instant write after the resize settles
(only when the viewport was still following).
This commit is contained in:
Bohdan Triapitsyn
2026-08-25 15:54:39 +03:00
parent 92185f3a7b
commit 5c39db0e36
2 changed files with 43 additions and 1 deletions
@@ -1061,7 +1061,11 @@ const TimelineList = React.memo(({
contentInsetEndAdjustment={composerOverlayHeight}
// While a turn is anchored, the reserved end space — not the
// live edge — defines where the viewport rests.
maintainScrollAtEnd={anchoredEndSpace || !streamingAutoFollowEnabled
// Also released while the width resizes: re-pinning against
// rows that are still re-measuring shakes the pinned
// viewport; the owning hook re-asserts the end once the
// resize settles.
maintainScrollAtEnd={anchoredEndSpace || !streamingAutoFollowEnabled || isWidthResizing
? false
: { animated: false, on: { dataChange: true, itemLayout: true, layout: true } }}
// Prepending older history must not move what the user is
@@ -508,7 +508,45 @@ export const useChatTimelineScroll = ({
const streamingAutoFollowEnabledRef = React.useRef(streamingAutoFollowEnabled);
streamingAutoFollowEnabledRef.current = streamingAutoFollowEnabled;
// While the list width is resizing, every pinning write fights the
// per-frame row re-measure and the pinned viewport shakes. Corrections
// stand down for the whole resize (visible content is held by the list's
// size compensation instead), and the live edge is re-asserted once with
// a single instant write after the resize settles.
const widthResizingRef = React.useRef(false);
React.useEffect(() => {
if (!scrollNode || typeof ResizeObserver === 'undefined') return;
let lastWidth: number | null = null;
let quietTimer: ReturnType<typeof setTimeout> | null = null;
const observer = new ResizeObserver((observerEntries) => {
const width = observerEntries[observerEntries.length - 1]?.contentRect.width;
if (typeof width !== 'number') return;
if (lastWidth === null) {
lastWidth = width;
return;
}
if (Math.abs(width - lastWidth) < 1) return;
lastWidth = width;
widthResizingRef.current = true;
if (quietTimer !== null) clearTimeout(quietTimer);
quietTimer = setTimeout(() => {
quietTimer = null;
widthResizingRef.current = false;
if (modeRef.current !== 'following-end' || !isLiveFollowActive()) return;
const node = listRef.current?.getScrollableNode();
if (!node) return;
node.scrollTop = node.scrollHeight + 4096;
}, 350);
});
observer.observe(scrollNode);
return () => {
observer.disconnect();
if (quietTimer !== null) clearTimeout(quietTimer);
};
}, [isLiveFollowActive, scrollNode]);
const onTimelineDataChange = React.useCallback(() => {
if (widthResizingRef.current) return;
if (!streamingAutoFollowEnabledRef.current) return;
if (!isLiveFollowActive()) return;