mirror of
https://github.com/coder/coder.git
synced 2026-09-22 13:10:21 +08:00
fix(site): prevent mobile scroll jump during active touch gestures (#23734)
This commit is contained in:
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user