fix(web): close handleScroll race that disabled auto-bottom on cold-cache image load

Tracks the post-clamp scrollTop of every programmatic scroll-to-bottom in
lastProgrammaticBottomScrollRef. handleScroll skips the at-bottom flip and
re-pins when the event's scrollTop matches the sentinel — i.e., the event
was queued by our own command and layout grew underneath. User scrolls
break the match (scrollTop changes) and flow through the normal path.

This complements the 2026-03-25 race-fix (which gated auxiliary effects on
isAtBottomRef) by also preventing handleScroll from flipping that ref to
false based on a post-growth distance measurement of our own scroll.
This commit is contained in:
Jannis Braun
2026-04-25 13:02:01 +02:00
parent 84ec8e04b7
commit d18845acb5
@@ -60,6 +60,7 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
const isNearBottomRef = useRef(true); const isNearBottomRef = useRef(true);
const [isAtBottom, setIsAtBottom] = useState(true); const [isAtBottom, setIsAtBottom] = useState(true);
const isAtBottomRef = useRef(true); const isAtBottomRef = useRef(true);
const lastProgrammaticBottomScrollRef = useRef<number | null>(null);
const [isLoadingMore, setIsLoadingMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false);
const showInitialSkeleton = useDelayedLoading(isLoading && messages.length === 0); const showInitialSkeleton = useDelayedLoading(isLoading && messages.length === 0);
const showPaginationSkeleton = useDelayedLoading(isLoadingMore); const showPaginationSkeleton = useDelayedLoading(isLoadingMore);
@@ -114,6 +115,7 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
} }
prevMessagesLength.current = 0; prevMessagesLength.current = 0;
lastProgrammaticBottomScrollRef.current = null;
// If we have a saved position for the incoming channel, don't mark as near/at-bottom // If we have a saved position for the incoming channel, don't mark as near/at-bottom
// — this prevents the ResizeObserver from snapping to bottom before the restore rAF fires // — this prevents the ResizeObserver from snapping to bottom before the restore rAF fires
@@ -154,6 +156,7 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
} }
// No saved anchor or message not in cache — snap to bottom // No saved anchor or message not in cache — snap to bottom
container.scrollTop = container.scrollHeight; container.scrollTop = container.scrollHeight;
lastProgrammaticBottomScrollRef.current = container.scrollTop;
isAtBottomRef.current = true; isAtBottomRef.current = true;
setIsAtBottom(true); setIsAtBottom(true);
}); });
@@ -175,6 +178,7 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
const c = containerRef.current; const c = containerRef.current;
if (!c || !isAtBottomRef.current) return; if (!c || !isAtBottomRef.current) return;
c.scrollTop = c.scrollHeight; c.scrollTop = c.scrollHeight;
lastProgrammaticBottomScrollRef.current = c.scrollTop;
}); });
observer.observe(content); observer.observe(content);
return () => observer.disconnect(); return () => observer.disconnect();
@@ -191,6 +195,7 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
const c = containerRef.current; const c = containerRef.current;
if (!c || !isAtBottomRef.current) return; if (!c || !isAtBottomRef.current) return;
c.scrollTop = c.scrollHeight; c.scrollTop = c.scrollHeight;
lastProgrammaticBottomScrollRef.current = c.scrollTop;
}; };
content.addEventListener('load', handleMediaLoad, true); content.addEventListener('load', handleMediaLoad, true);
@@ -231,6 +236,27 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
const container = containerRef.current; const container = containerRef.current;
if (!container) return; if (!container) return;
// Sentinel: if scrollTop equals our last programmatic bottom-scroll value, this event
// was queued by our own command. Layout may have grown between the command and the
// event firing, but our intent is "stay at bottom" — do not let a post-growth distance
// measurement flip the at-bottom flags. Re-pin defensively (content may have grown
// again) and update the sentinel; convergence is described in the spec §2.
if (container.scrollTop === lastProgrammaticBottomScrollRef.current) {
isAtBottomRef.current = true;
setIsAtBottom(true);
isNearBottomRef.current = true;
setIsNearBottom(true);
container.scrollTop = container.scrollHeight;
lastProgrammaticBottomScrollRef.current = container.scrollTop;
visibleMsgIdRef.current = null;
return;
}
// Sentinel mismatch — the user has scrolled (or is scrolling) somewhere we did not
// command. Invalidate the sentinel so a future user scroll that coincidentally lands on
// the stale value can't trigger a false match and yank them to bottom.
lastProgrammaticBottomScrollRef.current = null;
// Check scroll position relative to bottom // Check scroll position relative to bottom
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
// "at bottom" = within 150px — used for auto-scrolling on new messages // "at bottom" = within 150px — used for auto-scrolling on new messages