From 0cac5ca2f70d8d17a516fa4169a0056ba3372760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Tue, 18 Aug 2026 16:26:35 +0200 Subject: [PATCH] fix(ui): release follow on middle-button scroll Middle-button autoscroll starts from message descendants, but the virtual list only recorded pointer intent when the scroll container itself was the event target. The resulting scroll events had no user intent and were immediately pinned back to the bottom. Treat a non-interactive middle-button pointer press anywhere inside the list as explicit user scroll ownership. This exits follow mode and cancels active bottom pinning without preventing the browser's native middle-button behavior. Validated with 36 focused follow-controller tests, the UI typecheck, 539 UI tests, and git diff checks. --- packages/ui/src/components/virtual-follow-behavior.test.ts | 7 +++++++ packages/ui/src/components/virtual-follow-behavior.ts | 4 ++++ packages/ui/src/components/virtual-follow-list.tsx | 6 +++++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/virtual-follow-behavior.test.ts b/packages/ui/src/components/virtual-follow-behavior.test.ts index 460afcc7..97f23a58 100644 --- a/packages/ui/src/components/virtual-follow-behavior.test.ts +++ b/packages/ui/src/components/virtual-follow-behavior.test.ts @@ -10,6 +10,7 @@ import { VirtualScrollController, isAtBottom, isAutoFollowing, + isMiddleButtonScrollIntent, isScrollRestoreGenerationCurrent, isScrollRestoreMeasurementReady, isSnapshotAutoFollowing, @@ -29,6 +30,12 @@ function metrics(offset: number, scrollHeight = 3000, clientHeight = 600, sentin } describe("virtual follow behavior", () => { + it("treats only the middle pointer button as scroll ownership", () => { + assert.equal(isMiddleButtonScrollIntent(1), true) + assert.equal(isMiddleButtonScrollIntent(0), false) + assert.equal(isMiddleButtonScrollIntent(2), false) + }) + it("waits for Virtua measurements before applying a restored offset", () => { assert.equal(isScrollRestoreMeasurementReady({ hasHandle: false, itemCount: 20, scrollSize: 0, viewportSize: 0 }), false) assert.equal(isScrollRestoreMeasurementReady({ hasHandle: true, itemCount: 20, scrollSize: 0, viewportSize: 600 }), false) diff --git a/packages/ui/src/components/virtual-follow-behavior.ts b/packages/ui/src/components/virtual-follow-behavior.ts index ba94159f..f3d566da 100644 --- a/packages/ui/src/components/virtual-follow-behavior.ts +++ b/packages/ui/src/components/virtual-follow-behavior.ts @@ -6,6 +6,10 @@ export const ANCHOR_RESTORE_STABLE_FRAMES = 10 export const ANCHOR_RESTORE_REISSUE_INTERVAL_FRAMES = 12 export const ANCHOR_RESTORE_TOLERANCE_PX = 1 +export function isMiddleButtonScrollIntent(button: number): boolean { + return button === 1 +} + export function isScrollRestoreMeasurementReady(input: { hasHandle: boolean itemCount: number diff --git a/packages/ui/src/components/virtual-follow-list.tsx b/packages/ui/src/components/virtual-follow-list.tsx index 25c23966..2695562a 100644 --- a/packages/ui/src/components/virtual-follow-list.tsx +++ b/packages/ui/src/components/virtual-follow-list.tsx @@ -1,6 +1,6 @@ import { Show, createEffect, createMemo, createSignal, type Accessor, type JSX, on, onCleanup } from "solid-js" import { Virtualizer, type VirtualizerHandle } from "virtua/solid" -import { AnchorRestoreStabilizer, BOTTOM_FOLLOW_EPSILON_PX, getFollowSnapshotState, isAtBottom, isAutoFollowing, isScrollRestoreMeasurementReady, resolveAutoPinHoldElement, restoreFollowModeFromSnapshot, ScrollRestoreTokenGuard, selectTopViewportAnchor, VirtualScrollController, type FollowEffect, type FollowEvent, type FollowMode, type HoldTargetElementResolver, type ScrollControllerMetrics, type ScrollControllerResult } from "./virtual-follow-behavior.ts" +import { AnchorRestoreStabilizer, BOTTOM_FOLLOW_EPSILON_PX, getFollowSnapshotState, isAtBottom, isAutoFollowing, isMiddleButtonScrollIntent, isScrollRestoreMeasurementReady, resolveAutoPinHoldElement, restoreFollowModeFromSnapshot, ScrollRestoreTokenGuard, selectTopViewportAnchor, VirtualScrollController, type FollowEffect, type FollowEvent, type FollowMode, type HoldTargetElementResolver, type ScrollControllerMetrics, type ScrollControllerResult } from "./virtual-follow-behavior.ts" const DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX = 8 const EXPLICIT_BOTTOM_PIN_SETTLE_FRAMES = 2 @@ -611,6 +611,10 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { const handleWheelIntent = (event: WheelEvent) => markUserScrollIntent(event.deltaY < 0 ? "up" : event.deltaY > 0 ? "down" : null) const handlePointerIntent = (event: PointerEvent) => { if ((event.target as HTMLElement | null)?.closest(INTERACTIVE_KEY_TARGET_SELECTOR)) return + if (isMiddleButtonScrollIntent(event.button)) { + markUserScrollIntent("up") + return + } if (event.target !== element) return markUserScrollIntent(null) }