From 2380bd177dacbadadf9552b9e5de7e4bad0956fc Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Wed, 30 Sep 2026 16:42:30 -0700 Subject: [PATCH] chat: trackpad scrolling reaches the latest message on web Chat is an inverted FlatList, which react-native-web flips with scaleY(-1) and scrolls by hand: it cancels each wheel event and writes scrollTop itself. The browser snaps every scrollTop write to a device pixel, so a trackpad's small, fractional deltas round away, and more in one direction than the other: in Chromium, forty half-pixel steps toward the latest message move the list 0px while the same steps into history move it 20px. Its handler also lets the element under the pointer soak up the delta first when that element's scrollHeight exceeds its clientHeight, which a truncated chat name can do by a few pixels. The chat list now takes its wheel events first, in the capture phase on its scroll node, carries what the browser rounded away into the next step, and scrolls only the list. flows/09-chat-wheel-scroll fills chat, scrolls into history, and checks that half-pixel wheel steps travel the full distance both ways. It fails without the fix (0px of the expected 20px). Native platforms have no wheel events and are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- js/components/src/components/chat/chat.tsx | 56 ++++++++++++ js/e2e-web/flows/09-chat-wheel-scroll.spec.ts | 86 +++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 js/e2e-web/flows/09-chat-wheel-scroll.spec.ts diff --git a/js/components/src/components/chat/chat.tsx b/js/components/src/components/chat/chat.tsx index 30a86ef40..a45fe2aa4 100644 --- a/js/components/src/components/chat/chat.tsx +++ b/js/components/src/components/chat/chat.tsx @@ -4,6 +4,7 @@ import { ComponentProps, memo, type ReactNode, + type RefObject, useEffect, useMemo, useRef, @@ -98,6 +99,58 @@ const keyExtractor = (item: ChatMessageViewHydrated, index: number) => { return `${item.uri}`; }; +// On the web an inverted list is flipped with scaleY(-1), so native wheel +// scrolling would run backwards; react-native-web cancels every wheel event +// and moves scrollTop itself. Its handler loses trackpad scrolls, though: +// +// - The browser snaps each scrollTop write to a device pixel. A trackpad +// sends many small, fractional deltas, and rounding favours one direction: +// in Chromium every half-pixel step toward the latest message lands back +// where it started, so scrolling down stalls while scrolling up works. +// - It lets the element under the pointer absorb the delta first if its +// scrollHeight exceeds its clientHeight, which a truncated, overflow-hidden +// name can do by a few pixels on every row. +// +// So this list handles its own wheel events first (capture phase, on the +// scroll node, ahead of react-native-web's bubbling listener), carries the +// part of each delta the browser rounded away into the next one, and scrolls +// only the list: chat rows have no scroll areas of their own. +const WHEEL_LINE_PX = 16; + +function useInvertedWheelScroll( + listRef: RefObject, + enabled: boolean, +) { + useEffect(() => { + if (Platform.OS !== "web" || !enabled) return; + const node = ( + listRef.current as unknown as { getScrollableNode?: () => unknown } + )?.getScrollableNode?.(); + if (!(node instanceof HTMLElement)) return; + let carry = 0; + const onWheel = (ev: WheelEvent) => { + ev.stopPropagation(); + ev.preventDefault(); + // Firefox reports pixels once deltaY is read before deltaMode. + let delta = ev.deltaY; + if (ev.deltaMode === WheelEvent.DOM_DELTA_LINE) delta *= WHEEL_LINE_PX; + if (ev.deltaMode === WheelEvent.DOM_DELTA_PAGE) { + delta *= node.clientHeight; + } + if (!delta) return; + const wanted = node.scrollTop - delta + carry; + node.scrollTop = wanted; + carry = wanted - node.scrollTop; + // A whole pixel or more short means the list hit an end, not rounding. + if (Math.abs(carry) >= 1) carry = 0; + }; + node.addEventListener("wheel", onWheel, { capture: true, passive: false }); + return () => { + node.removeEventListener("wheel", onWheel, { capture: true }); + }; + }, [listRef, enabled]); +} + // A message that has faded out is gone, not absent: it keeps the space the // list is scrolled over, so the history behind it stays reachable. It must not // stay tabbable, though. react-native-web drops unknown props, so on the web a @@ -396,6 +449,8 @@ export function Chat({ }, []); const [isVisible, setIsVisible] = useState(true); const flatListRef = useRef(null); + // the list is only mounted (and inverted) once chat has loaded and is shown + useInvertedWheelScroll(flatListRef, !reverse && isVisible && !!chat); const now = useChatExpiryTick(); const visibleMessages = useMemo( () => @@ -510,6 +565,7 @@ export function Chat({ list.evaluate((el) => el.scrollTop); + +test("09-chat-wheel-scroll: small wheel deltas scroll chat both ways", async ({ + page, +}) => { + test.setTimeout(180_000); + await loginThroughPds(page); + + await page.goto(`${HTTPS_URL}/`); + await page.getByTestId("home-stream-card").first().click(); + await expect( + page.getByText("Now streaming - e2e test stream").first(), + ).toBeVisible({ timeout: 30_000 }); + + const chatInput = page.getByPlaceholder("Type a message..."); + const run = Date.now(); + // a send the page wasn't ready for clears the box and is dropped; resend + await expect(async () => { + await chatInput.fill(`wheel scroll ${run} 0`); + await chatInput.press("Enter"); + await expect(page.getByText(`wheel scroll ${run} 0`).first()).toBeVisible({ + timeout: 10_000, + }); + }).toPass({ timeout: 45_000 }); + for (let i = 1; i < MESSAGES; i++) { + await chatInput.fill(`wheel scroll ${run} ${i}`); + await chatInput.press("Enter"); + await expect( + page.getByText(`wheel scroll ${run} ${i}`, { exact: true }).first(), + ).toBeVisible({ timeout: 15_000 }); + } + + const list = page.getByTestId("chat-list").first(); + await expect + .poll(() => list.evaluate((el) => el.scrollHeight - el.clientHeight)) + .toBeGreaterThan(200); + + const box = await list.boundingBox(); + expect(box).not.toBeNull(); + await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2); + + // Into history with a mouse-wheel-sized step. In an inverted list the + // latest message sits at scrollTop 0 and history is further up. + await page.mouse.wheel(0, -150); + await expect.poll(() => scrollTop(list)).toBeGreaterThan(100); + const inHistory = await scrollTop(list); + + // Back down toward the latest message in trackpad-sized steps. + for (let i = 0; i < STEPS; i++) { + await page.mouse.wheel(0, STEP_PX); + } + const travel = STEPS * STEP_PX; + await expect + .poll(async () => inHistory - (await scrollTop(list))) + .toBeGreaterThanOrEqual(travel - 1); + const afterDown = await scrollTop(list); + expect(inHistory - afterDown).toBeLessThanOrEqual(travel + 1); + + // And up again by the same amount. + for (let i = 0; i < STEPS; i++) { + await page.mouse.wheel(0, -STEP_PX); + } + await expect + .poll(async () => (await scrollTop(list)) - afterDown) + .toBeGreaterThanOrEqual(travel - 1); + expect((await scrollTop(list)) - afterDown).toBeLessThanOrEqual(travel + 1); +}); -- knotmirror 0.0.1