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.
This commit is contained in:
Pascal André 2026-08-18 16:26:35 +02:00
parent 7e99df93b0
commit 0cac5ca2f7
No known key found for this signature in database
3 changed files with 16 additions and 1 deletions

View file

@ -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)

View file

@ -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

View file

@ -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<T>(props: VirtualFollowListProps<T>) {
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)
}