fix(site): prevent mobile scroll jump during active touch gestures (#23734)

This commit is contained in:
Michael Suchacz
2026-03-28 16:24:12 +01:00
committed by GitHub
parent a708e9d869
commit 73bde99495
2 changed files with 422 additions and 5 deletions
@@ -751,3 +751,273 @@ export const ScrollPinnedToBottomOnNewContent: Story = {
).toBeNull();
},
};
const dispatchTouchEvent = (
scrollContainer: HTMLElement,
type: "touchstart" | "touchend",
changedTouchesLength: number,
) => {
const event = new Event(type, { bubbles: true });
Object.defineProperty(event, "changedTouches", {
configurable: true,
value: Array.from({ length: changedTouchesLength }, (_, index) => ({
identifier: index,
})),
});
scrollContainer.dispatchEvent(event);
};
const touchGuardScrollStore = buildStoreWithMessages(buildLongConversation(30));
/** During an active touch gesture, the container ResizeObserver must not
* snap scroll to bottom. This prevents the mobile URL bar resize jump. */
export const ScrollNotJumpedDuringTouch: Story = {
decorators: scrollStoryDecorators,
render: () => <StoryAgentDetailView store={touchGuardScrollStore} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const scrollContainer = canvas.getByTestId("scroll-container");
await waitForScrollOverflow(scrollContainer);
// Wait for the initial bottom pin to settle.
await waitFor(
() => {
const dist =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight;
expect(dist).toBeLessThan(5);
},
{ timeout: 2000 },
);
await new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve()),
);
// Simulate a multi-touch gesture starting with two fingers down.
dispatchTouchEvent(scrollContainer, "touchstart", 2);
// Scroll partway up, within the 100px threshold but not at the
// absolute bottom. This simulates the user dragging up slightly
// during a touch.
const offsetFromBottom = 50;
const targetScrollTop =
scrollContainer.scrollHeight -
scrollContainer.clientHeight -
offsetFromBottom;
scrollContainer.scrollTop = targetScrollTop;
scrollContainer.dispatchEvent(new Event("scroll"));
const originalHeight = scrollContainer.clientHeight;
const shrunkHeight = originalHeight - 10;
// Record the scroll position before the first resize.
const scrollTopBeforeFirstResize = scrollContainer.scrollTop;
// Simulate a container resize that models the mobile URL bar
// appearing. Shrink the container height slightly to trigger
// the ResizeObserver.
scrollContainer.style.height = `${shrunkHeight}px`;
// Give the ResizeObserver a chance to fire.
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
// During an active touch, the resize guard should prevent the
// container observer from snapping to the absolute bottom.
expect(scrollContainer.scrollTop).toBeLessThanOrEqual(
scrollTopBeforeFirstResize + 1,
);
// Lift one finger, leaving a second touch active. The guard should
// still block resize snaps until the final finger is lifted.
dispatchTouchEvent(scrollContainer, "touchend", 1);
const scrollTopBeforeSecondResize = scrollContainer.scrollTop;
scrollContainer.style.height = `${originalHeight}px`;
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
expect(scrollContainer.scrollTop).toBeLessThanOrEqual(
scrollTopBeforeSecondResize + 1,
);
// End the remaining touch.
dispatchTouchEvent(scrollContainer, "touchend", 1);
// After touch ends, normal scroll tracking should resume.
// Scroll to the very bottom and verify the button disappears.
scrollContainer.scrollTop =
scrollContainer.scrollHeight - scrollContainer.clientHeight;
scrollContainer.dispatchEvent(new Event("scroll"));
await waitFor(() => {
expect(
canvas.queryByRole("button", { name: "Scroll to bottom" }),
).toBeNull();
});
},
};
const wheelGuardScrollStore = buildStoreWithMessages(buildLongConversation(30));
/** During active wheel/trackpad scrolling, the container ResizeObserver
* must not snap scroll to bottom. This prevents desktop scroll jump. */
export const ScrollNotJumpedDuringWheel: Story = {
decorators: scrollStoryDecorators,
render: () => <StoryAgentDetailView store={wheelGuardScrollStore} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const scrollContainer = canvas.getByTestId("scroll-container");
await waitForScrollOverflow(scrollContainer);
// Wait for the initial bottom pin to settle.
await waitFor(
() => {
const dist =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight;
expect(dist).toBeLessThan(5);
},
{ timeout: 2000 },
);
await new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve()),
);
// Simulate a wheel event (trackpad/mouse scroll).
scrollContainer.dispatchEvent(
new WheelEvent("wheel", { bubbles: true, deltaY: -50 }),
);
// Scroll partway up, within the 100px threshold but not at
// the absolute bottom. This simulates the user scrolling up
// slightly with a trackpad.
const offsetFromBottom = 25;
const targetScrollTop =
scrollContainer.scrollHeight -
scrollContainer.clientHeight -
offsetFromBottom;
scrollContainer.scrollTop = targetScrollTop;
scrollContainer.dispatchEvent(new Event("scroll"));
// Record the scroll position before new content arrives.
const scrollTopBeforeAppend = scrollContainer.scrollTop;
const scrollHeightBeforeAppend = scrollContainer.scrollHeight;
// Simulate new assistant content arriving while the wheel guard is
// active. Keep the append small enough to remain within the
// near-bottom threshold so auto-follow should resume.
const existing = getStoreMessages(wheelGuardScrollStore);
wheelGuardScrollStore.replaceMessages(
existing.concat([buildMessage(31, "assistant", "Short update.")]),
);
await waitFor(() => {
expect(scrollContainer.scrollHeight).toBeGreaterThan(
scrollHeightBeforeAppend,
);
});
// During active wheel scrolling, the deferred pin should stay
// suppressed until the debounce expires.
expect(scrollContainer.scrollTop).toBeLessThanOrEqual(
scrollTopBeforeAppend + 1,
);
// Wait for the wheel debounce to clear (150ms) plus a
// buffer, then verify the missed bottom pin is applied.
await new Promise<void>((resolve) => setTimeout(resolve, 200));
await waitFor(() => {
const dist =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight;
expect(dist).toBeLessThan(5);
expect(
canvas.queryByRole("button", { name: "Scroll to bottom" }),
).toBeNull();
});
},
};
const wheelDeferredStore = buildStoreWithMessages(buildLongConversation(30));
/**
* Regression: when content grows during a wheel burst (so
* ResizeObserver pins are deferred), the transcript must recover
* auto-follow after the wheel debounce expires instead of getting
* stuck in a jumped-up position.
*/
export const ScrollRepinnedAfterWheelDeferredAppend: Story = {
decorators: scrollStoryDecorators,
render: () => <StoryAgentDetailView store={wheelDeferredStore} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const scrollContainer = canvas.getByTestId("scroll-container");
await waitForScrollOverflow(scrollContainer);
// Wait for the initial bottom pin to settle.
await waitFor(
() => {
const dist =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight;
expect(dist).toBeLessThan(5);
},
{ timeout: 2000 },
);
await new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve()),
);
// Start a wheel burst to activate the wheel guard.
scrollContainer.dispatchEvent(
new WheelEvent("wheel", { bubbles: true, deltaY: 3 }),
);
// Append content while the wheel guard is active. This
// defers the ResizeObserver pin (pendingWheelPinRef = true)
// and creates the gap that previously triggered the bug.
const existing = getStoreMessages(wheelDeferredStore);
wheelDeferredStore.replaceMessages(
existing.concat([
buildMessage(31, "assistant", "A ".repeat(200)),
buildMessage(32, "assistant", "B ".repeat(200)),
]),
);
// Fire a second wheel tick. In the old code, this wheel
// event called handleUserInterrupt() which saw the gap
// and falsely disabled auto-follow.
scrollContainer.dispatchEvent(
new WheelEvent("wheel", { bubbles: true, deltaY: 3 }),
);
// Wait for the 150ms wheel debounce to expire, plus the
// catch-up scrollTranscriptToBottom to settle.
await waitFor(
() => {
const dist =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight;
expect(dist).toBeLessThan(5);
},
{ timeout: 2000 },
);
// Scroll-to-bottom button should not be visible.
expect(
canvas.queryByRole("button", { name: "Scroll to bottom" }),
).toBeNull();
},
};
@@ -623,6 +623,28 @@ const ScrollAnchoredContainer: FC<{
// scroll reaches its destination or the user actively interrupts.
const isRestoringScrollRef = useRef(false);
const cancelPendingPinsRef = useRef<(() => void) | null>(null);
// Guard counter: positive while one or more touch contacts are active.
// Prevents ResizeObserver callbacks from snapping scroll to bottom
// during mobile URL bar show/hide, which triggers container resize
// events while the user's finger is still on the screen.
const activeTouchCountRef = useRef(0);
// Guard flag: true while the user is actively scrolling via
// mouse wheel or trackpad. Set on each wheel event and cleared
// after a short debounce period. Prevents ResizeObserver
// callbacks from snapping scroll to bottom during active
// wheel/trackpad scrolling within the near-bottom threshold.
const isWheelScrollingRef = useRef(false);
// Track whether a resize would have pinned to bottom while the
// wheel guard was active. When scrolling stops, run one catch-up
// pin so auto-follow resumes without waiting for another resize.
const pendingWheelPinRef = useRef(false);
// Snapshot of autoScrollRef at the start of the current wheel
// burst. Used by the debounce timeout to decide whether to repin
// after deferred content growth.
const wheelSessionAutoScrollRef = useRef(false);
// scrollTop at the start of the current wheel burst. Compared
// against the final scrollTop to detect intentional upward scrolls.
const wheelSessionStartTopRef = useRef(0);
useLayoutEffect(() => {
isFetchingRef.current = isFetchingMoreMessages;
if (isFetchingMoreMessages) {
@@ -767,6 +789,7 @@ const ScrollAnchoredContainer: FC<{
const scheduleBottomPin = () => {
cancelPendingPins();
pendingWheelPinRef.current = false;
isRestoringScrollRef.current = true;
// Double-RAF lets React's commit phase and the browser's
// layout pass both complete before we pin to bottom.
@@ -840,7 +863,11 @@ const ScrollAnchoredContainer: FC<{
return;
}
if (autoScrollRef.current) {
if (autoScrollRef.current && activeTouchCountRef.current === 0) {
if (isWheelScrollingRef.current) {
pendingWheelPinRef.current = true;
return;
}
scheduleBottomPin();
return;
}
@@ -891,6 +918,13 @@ const ScrollAnchoredContainer: FC<{
if (Math.abs(delta) < 1 || !autoScrollRef.current) {
return;
}
if (activeTouchCountRef.current > 0) {
return;
}
if (isWheelScrollingRef.current) {
pendingWheelPinRef.current = true;
return;
}
if (restoreGuardRafId !== null) {
cancelAnimationFrame(restoreGuardRafId);
@@ -922,6 +956,7 @@ const ScrollAnchoredContainer: FC<{
if (!container) return;
let rafId: number | null = null;
let wheelTimeoutId: ReturnType<typeof setTimeout> | null = null;
const handleScroll = () => {
// While a programmatic scroll is in progress (e.g. smooth
@@ -962,24 +997,136 @@ const ScrollAnchoredContainer: FC<{
// not break streaming follow-mode.
if (!isNearBottom(container)) {
autoScrollRef.current = false;
pendingWheelPinRef.current = false;
cancelPendingPinsRef.current?.();
}
};
const getChangedTouchCount = (event: TouchEvent) => {
// A single touch event can add or remove multiple contacts.
// Count changed touches so the guard stays active until the
// final finger leaves the screen.
return Math.max(event.changedTouches.length, 1);
};
const handleWheel = () => {
if (!isWheelScrollingRef.current) {
// First wheel event of this burst: snapshot the current
// follow state and scroll position so the debounce
// timeout can distinguish content-driven gaps from
// intentional user scroll.
wheelSessionAutoScrollRef.current = autoScrollRef.current;
wheelSessionStartTopRef.current = container.scrollTop;
}
isWheelScrollingRef.current = true;
if (wheelTimeoutId !== null) {
clearTimeout(wheelTimeoutId);
}
// Clear the wheel-scrolling flag after 150ms of inactivity.
// This covers trackpad momentum and rapid discrete wheel
// ticks without permanently blocking ResizeObserver pins.
wheelTimeoutId = setTimeout(() => {
isWheelScrollingRef.current = false;
wheelTimeoutId = null;
const wasPinDeferred = pendingWheelPinRef.current;
pendingWheelPinRef.current = false;
// Repin if the wheel burst started in follow mode and
// content grew while the guard was active, unless the
// user clearly scrolled away from the near-bottom follow
// zone during the burst.
if (wasPinDeferred && wheelSessionAutoScrollRef.current) {
const scrolledUp =
container.scrollTop < wheelSessionStartTopRef.current - 1;
const nearBottom = isNearBottom(container);
if (!scrolledUp || nearBottom) {
scrollTranscriptToBottom({
behavior: "instant",
scrollContainerRef,
autoScrollRef,
isRestoringScrollRef,
setShowScrollToBottom,
});
} else {
autoScrollRef.current = false;
setShowScrollToBottom(true);
}
} else {
// Sync follow state with the actual scroll position
// now that the wheel burst is over.
const nearBottom = isNearBottom(container);
autoScrollRef.current = nearBottom;
setShowScrollToBottom(!nearBottom);
}
}, 150);
// Clear the restoration guard so user input can interrupt
// programmatic scrolls, but do not call handleUserInterrupt()
// here. The scroll handler and debounce timeout manage
// follow-mode transitions to avoid races with deferred
// content growth.
isRestoringScrollRef.current = false;
cancelPendingPinsRef.current?.();
};
const handleTouchStart = (event: TouchEvent) => {
activeTouchCountRef.current += getChangedTouchCount(event);
handleUserInterrupt();
};
const handleTouchEnd = (event: TouchEvent) => {
activeTouchCountRef.current = Math.max(
0,
activeTouchCountRef.current - getChangedTouchCount(event),
);
if (activeTouchCountRef.current === 0) {
// Re-evaluate because momentum scrolling may carry the
// user away from the bottom after touchstart initially
// saw them as near the bottom.
if (!isNearBottom(container)) {
autoScrollRef.current = false;
cancelPendingPinsRef.current?.();
}
}
};
container.addEventListener("scroll", handleScroll, { passive: true });
container.addEventListener("wheel", handleUserInterrupt, {
container.addEventListener("wheel", handleWheel, {
passive: true,
});
container.addEventListener("touchstart", handleUserInterrupt, {
container.addEventListener("touchstart", handleTouchStart, {
passive: true,
});
container.addEventListener("touchend", handleTouchEnd, {
passive: true,
});
// touchcancel fires when the OS interrupts a gesture (e.g.,
// incoming call, system gesture). Must also decrement the
// touch counter to avoid a stuck positive value.
container.addEventListener("touchcancel", handleTouchEnd, {
passive: true,
});
// Reset touch counter when page is hidden (e.g., tab switch).
// The browser may not fire touchend/touchcancel when the user
// switches away mid-gesture, which would leave the counter
// positive and permanently block ResizeObserver pins.
const handleVisibilityChange = () => {
if (document.hidden) {
activeTouchCountRef.current = 0;
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
container.removeEventListener("scroll", handleScroll);
container.removeEventListener("wheel", handleUserInterrupt);
container.removeEventListener("touchstart", handleUserInterrupt);
container.removeEventListener("wheel", handleWheel);
container.removeEventListener("touchstart", handleTouchStart);
container.removeEventListener("touchend", handleTouchEnd);
container.removeEventListener("touchcancel", handleTouchEnd);
document.removeEventListener("visibilitychange", handleVisibilityChange);
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
if (wheelTimeoutId !== null) {
clearTimeout(wheelTimeoutId);
}
};
}, [scrollContainerRef]);