From ca06ff0e051b722db422c1a525bd1e4c74f108c2 Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:32:13 +0900 Subject: [PATCH] Stop the wheel on a Bot's screen from also scrolling the page around it LiveScreen forwarded the wheel from React's onWheel and called preventDefault there, so that scrolling the Bot's page would not also scroll this one. React attaches wheel to its root as a passive listener (react-dom's addTrappedEventListener, for touchstart, touchmove and wheel whenever the browser supports passive listeners), and a browser ignores preventDefault in a passive listener. Chrome says so in the console: "Unable to preventDefault inside passive event listener invocation." So while somebody drove a Bot's browser, the wheel reached the Bot's page and also scrolled whatever on this page was under it. In the expanded view that includes the max-h-[75vh] overflow-auto frame holding the screen, and Ctrl with the wheel zoomed the app. The wheel is now handled by a listener on the canvas itself, added with passive: false while driving, and removed when driving stops. What it sends is unchanged. `at` now takes anything with clientX and clientY, so a native event can be passed to it as well as React's. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 8 ++++ app/src/components/computer/live-screen.tsx | 41 ++++++++++++++------- app/tests/live-screen-mouse.test.tsx | 40 ++++++++++++++++++++ 3 files changed, 76 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0db3abad..962fa6c3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### Scrolling a Bot's browser no longer scrolls or zooms the page around it + +While somebody drives a Bot's browser, a turn of the mouse wheel over its screen is sent to it, and +the screen was meant to keep the wheel from also acting on the app. React attaches its wheel handler +as a passive listener, which a browser does not allow to do that, so the wheel scrolled the frame +holding the Bot's screen along with the Bot's page, and Ctrl with the wheel zoomed the app. The +wheel is now handled by a listener that can hold it, so it reaches only the Bot's browser. + ### The proof-of-concept Bot reads a model name set with whitespace around it A `BOT_MODEL` carrying a leading space reached this Bot as it was written. Its startup check refuses diff --git a/app/src/components/computer/live-screen.tsx b/app/src/components/computer/live-screen.tsx index ce85392c9..6c2a1deef 100644 --- a/app/src/components/computer/live-screen.tsx +++ b/app/src/components/computer/live-screen.tsx @@ -227,7 +227,7 @@ export function LiveScreen({ computerId, driving, onProblem }: Props) { * Convert from displayed canvas coordinates to page coordinates with the shared, tested helper. * A screencast frame is the viewport, so its frame size stands in for natural image size. */ - const at = useCallback((event: React.MouseEvent) => { + const at = useCallback((event: { clientX: number; clientY: number }) => { const canvas = canvasRef.current; const size = frameSize.current; if (!canvas || !size) return null; @@ -335,6 +335,33 @@ export function LiveScreen({ computerId, driving, onProblem }: Props) { }; }, [driving, send]); + /** + * The wheel, forwarded while driving, from a listener that is allowed to stop it here. + * + * Not React's `onWheel`: React attaches that to its root as a passive listener, so the + * `preventDefault` in it was ignored ("Unable to preventDefault inside passive event listener + * invocation."). The wheel reached the Bot's page and also scrolled whatever on this page was + * under it, the frame that holds this screen included, and Ctrl and the wheel zoomed this page. + */ + useEffect(() => { + const canvas = canvasRef.current; + if (!driving || !canvas) return; + const onWheel = (event: WheelEvent) => { + const point = at(event); + if (!point) return; + event.preventDefault(); + send({ + type: "wheel", + ...point, + deltaX: event.deltaX, + deltaY: event.deltaY, + modifiers: modifierBits(event), + }); + }; + canvas.addEventListener("wheel", onWheel, { passive: false }); + return () => canvas.removeEventListener("wheel", onWheel); + }, [driving, at, send]); + return ( event.preventDefault(), - onWheel: (event: React.WheelEvent) => { - const point = at(event); - if (!point) return; - event.preventDefault(); - send({ - type: "wheel", - ...point, - deltaX: event.deltaX, - deltaY: event.deltaY, - modifiers: modifierBits(event), - }); - }, } : {})} aria-label={ diff --git a/app/tests/live-screen-mouse.test.tsx b/app/tests/live-screen-mouse.test.tsx index daacda294..46dc2aee6 100644 --- a/app/tests/live-screen-mouse.test.tsx +++ b/app/tests/live-screen-mouse.test.tsx @@ -165,3 +165,43 @@ test("moving the mouse is not a click", async () => { }, ]); }); + +test("a turn of the wheel is stopped here as well as sent there", async () => { + // In a browser React attaches `onWheel` to its root as a passive listener, so a `preventDefault` + // there is ignored and the wheel scrolled this page as well as the Bot's. Under happy-dom React + // does not detect passive support and binds it actively, which hides exactly that, so this pins + // the listener the canvas holds itself, and that it is not passive. + const wheelListeners: unknown[] = []; + const addEventListener = HTMLCanvasElement.prototype.addEventListener; + HTMLCanvasElement.prototype.addEventListener = function ( + this: HTMLCanvasElement, + ...args: Parameters + ) { + if (args[0] === "wheel") wheelListeners.push(args[2]); + return addEventListener.apply(this, args); + }; + try { + const { canvas, socket } = await liveCanvas(); + expect(wheelListeners).toEqual([{ passive: false }]); + + // happy-dom's WheelEvent carries no coordinates, so a mouse event of that type stands in. + const wheel = new MouseEvent("wheel", { + clientX: 40, + clientY: 30, + bubbles: true, + cancelable: true, + }); + Object.defineProperties(wheel, { + deltaX: { value: 0 }, + deltaY: { value: 120 }, + }); + canvas.dispatchEvent(wheel); + + expect(wheel.defaultPrevented).toBe(true); + expect(socket.sent).toEqual([ + { type: "wheel", x: 40, y: 30, deltaX: 0, deltaY: 120, modifiers: 0 }, + ]); + } finally { + HTMLCanvasElement.prototype.addEventListener = addEventListener; + } +});