From 5b721e047aa1d25e225aadd473b62515f44fcbfd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 31 Oct 2026 18:04:11 +0000 Subject: [PATCH 1/2] test(server): wheel burst ahead of a click reproduces #28217 A blocked hover, then 120 streamed wheels and a click, should dispatch one summed mouseWheel before the click. On main every wheel is dispatched on its own, so the click waits behind 120 CDP calls. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index f4671d89b5634..11959edc173fd 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -1010,6 +1010,26 @@ it.effect.each([ { type: "mouseReleased", x: 119 }, ], }, + { + name: "wheel burst before a click", + inputs: [ + ...Array.from({ length: 120 }, () => ({ + type: "wheel", + x: 100, + y: 200, + deltaX: 0, + deltaY: 12, + })), + { type: "mouse", action: "down", x: 100, buttons: 1, button: "left" }, + { type: "mouse", action: "up", x: 100, buttons: 0, button: "left" }, + ], + expected: [ + { type: "mouseMoved", x: -1 }, + { type: "mouseWheel", x: 100, y: 200, deltaX: 0, deltaY: 1440 }, + { type: "mousePressed", x: 100 }, + { type: "mouseReleased", x: 100 }, + ], + }, { name: "modifier changes and drag points", inputs: [ From 8369d8be7573ac76f14436cab1ce7759c4e1d4e2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 31 Oct 2026 18:11:44 +0000 Subject: [PATCH 2/2] fix(server): merge waiting Browser wheel input into one dispatch Every streamed wheel message became its own CDP mouseWheel dispatch, and Chromium takes 16-33 ms per dispatch, so a scroll burst queued seconds of work ahead of the next click. humanLatest now carries the waiting input and an optional merge, so adjacent waiting wheels at the same point, with the same modifiers and scroll direction, dispatch once with summed deltas. The running dispatch, any other input, and a control handoff remain boundaries. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 21 +++++++++-- apps/server/src/preview/ServerBrowser.ts | 25 +++++++++++-- .../server/src/preview/SessionControl.test.ts | 36 +++++++++---------- apps/server/src/preview/SessionControl.ts | 22 ++++++++---- 4 files changed, 75 insertions(+), 29 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 11959edc173fd..55a0ee9666a55 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -1064,13 +1064,30 @@ it.effect.each([ expected: [ { type: "mouseMoved", x: -1 }, { type: "mouseMoved", x: 1 }, - { type: "mouseWheel", deltaY: 20 }, - { type: "mouseWheel", deltaY: 30 }, + { type: "mouseWheel", deltaY: 50 }, { type: "mouseMoved", x: 2 }, { type: "keyDown", key: "a" }, { type: "mouseMoved", x: 3 }, ], }, + { + name: "wheel position, modifier and direction boundaries", + inputs: [ + { type: "wheel", x: 1, deltaX: 5, deltaY: 10 }, + { type: "wheel", x: 1, deltaX: 5, deltaY: 10 }, + { type: "wheel", x: 2, deltaY: 10 }, + { type: "wheel", x: 2, deltaY: 10, modifiers: 2 }, + { type: "wheel", x: 2, deltaY: -10, modifiers: 2 }, + { type: "wheel", x: 2, deltaY: -10, modifiers: 2 }, + ], + expected: [ + { type: "mouseMoved", x: -1 }, + { type: "mouseWheel", x: 1, deltaX: 10, deltaY: 20, modifiers: 0 }, + { type: "mouseWheel", x: 2, deltaX: 0, deltaY: 10, modifiers: 0 }, + { type: "mouseWheel", x: 2, deltaY: 10, modifiers: 2 }, + { type: "mouseWheel", x: 2, deltaY: -20, modifiers: 2 }, + ], + }, { name: "text, navigation and resize boundaries", inputs: [ diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 84299d3e9246a..c1256706d8e6c 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -2628,16 +2628,35 @@ const make = Effect.gen(function* () { : {}), }); } else { - const dispatch = () => dispatchViewerInput(tab, session, viewer, message); + const dispatch = (input: Record) => + dispatchViewerInput(tab, session, viewer, input); if ( message.type === "mouse" && message.action === "move" && message.button === "none" && message.buttons === 0 ) { - await tab.control.humanLatest(viewer.id, `hover:${modifiersOf(message)}`, dispatch); + await tab.control.humanLatest( + viewer.id, + `hover:${modifiersOf(message)}`, + message, + dispatch, + ); + } else if (message.type === "wheel") { + // A reversal stays separate so scrolling clamped at an edge behaves as if unmerged. + await tab.control.humanLatest( + viewer.id, + `wheel:${num(message.x)}:${num(message.y)}:${modifiersOf(message)}:${Math.sign(num(message.deltaX))}:${Math.sign(num(message.deltaY))}`, + message, + dispatch, + (waiting, next) => ({ + ...next, + deltaX: num(waiting.deltaX) + num(next.deltaX), + deltaY: num(waiting.deltaY) + num(next.deltaY), + }), + ); } else { - await tab.control.human(viewer.id, dispatch); + await tab.control.human(viewer.id, () => dispatch(message)); } } } catch { diff --git a/apps/server/src/preview/SessionControl.test.ts b/apps/server/src/preview/SessionControl.test.ts index e086ad854b3fb..f47be6319cb81 100644 --- a/apps/server/src/preview/SessionControl.test.ts +++ b/apps/server/src/preview/SessionControl.test.ts @@ -9,14 +9,14 @@ describe("SessionControl", () => { const started = Promise.withResolvers(); const finish = Promise.withResolvers(); const events: Array = []; - const running = control.humanLatest("viewer", "hover:0", async () => { + const running = control.humanLatest("viewer", "hover:0", undefined, async () => { events.push("running"); started.resolve(); await finish.promise; }); await started.promise; const moves = Array.from({ length: 120 }, (_, position) => - control.humanLatest("viewer", "hover:0", async () => { + control.humanLatest("viewer", "hover:0", undefined, async () => { events.push(position); }), ); @@ -36,7 +36,7 @@ describe("SessionControl", () => { const finish = Promise.withResolvers(); const events: string[] = []; const running = control.human("viewer", () => finish.promise); - const before = control.humanLatest("viewer", "hover:0", async () => { + const before = control.humanLatest("viewer", "hover:0", undefined, async () => { events.push("before"); }); const recordBarrier = async () => { @@ -44,11 +44,11 @@ describe("SessionControl", () => { }; const between = barrier === "modifiers" - ? control.humanLatest("viewer", "hover:2", recordBarrier) + ? control.humanLatest("viewer", "hover:2", undefined, recordBarrier) : barrier === "system" ? control.system(recordBarrier) : control.human("viewer", recordBarrier); - const after = control.humanLatest("viewer", "hover:0", async () => { + const after = control.humanLatest("viewer", "hover:0", undefined, async () => { events.push("after"); }); finish.resolve(); @@ -63,15 +63,15 @@ describe("SessionControl", () => { const finish = Promise.withResolvers(); const events: string[] = []; const running = control.human("viewer", () => finish.promise); - const stale = control.humanLatest("viewer", "hover", async () => { + const stale = control.humanLatest("viewer", "hover", undefined, async () => { events.push("stale"); }); await expect( - control.humanLatest("other", "hover", async () => { + control.humanLatest("other", "hover", undefined, async () => { events.push("other"); }), ).rejects.toThrow(BrowserControlInterrupted); - const latest = control.humanLatest("viewer", "hover", async () => { + const latest = control.humanLatest("viewer", "hover", undefined, async () => { events.push("latest"); }); finish.resolve(); @@ -93,16 +93,16 @@ describe("SessionControl", () => { }); await started.promise; const stale = expect( - control.humanLatest("viewer", "hover", async () => { + control.humanLatest("viewer", "hover", undefined, async () => { events.push("stale"); }), ).rejects.toThrow(BrowserControlInterrupted); const released = control[handoff]("viewer"); const retaken = control.take("viewer"); - const fresh = control.humanLatest("viewer", "hover", async () => { + const fresh = control.humanLatest("viewer", "hover", undefined, async () => { events.push("fresh"); }); - const latest = control.humanLatest("viewer", "hover", async () => { + const latest = control.humanLatest("viewer", "hover", undefined, async () => { events.push("latest"); }); finish.resolve(); @@ -124,14 +124,14 @@ describe("SessionControl", () => { await started.promise; const waiting = [1, 2].map((position) => expect( - control.humanLatest("viewer", "hover", async () => { + control.humanLatest("viewer", "hover", undefined, async () => { events.push(`hover ${position}`); }), ).rejects.toThrow("closed"), ); const closed = control.close(); await expect( - control.humanLatest("viewer", "hover", async () => { + control.humanLatest("viewer", "hover", undefined, async () => { events.push("after close"); }), ).rejects.toThrow("closed"); @@ -147,18 +147,18 @@ describe("SessionControl", () => { const events: string[] = []; const running = control.human("viewer", () => finish.promise); const first = expect( - control.humanLatest("viewer", "hover", async () => { + control.humanLatest("viewer", "hover", undefined, async () => { events.push("superseded"); }), ).rejects.toThrow("dispatch failed"); const last = expect( - control.humanLatest("viewer", "hover", async () => { + control.humanLatest("viewer", "hover", undefined, async () => { throw new Error("dispatch failed"); }), ).rejects.toThrow("dispatch failed"); finish.resolve(); await Promise.all([running, first, last]); - await control.humanLatest("viewer", "hover", async () => { + await control.humanLatest("viewer", "hover", undefined, async () => { events.push("recovered"); }); expect(events).toEqual(["recovered"]); @@ -177,10 +177,10 @@ describe("SessionControl", () => { ); return "started"; }); - const first = control.humanLatest("viewer", "hover", async () => { + const first = control.humanLatest("viewer", "hover", undefined, async () => { events.push("superseded"); }); - const latest = control.humanLatest("viewer", "hover", async () => { + const latest = control.humanLatest("viewer", "hover", undefined, async () => { events.push("latest"); }); await expect(navigation).resolves.toBe("started"); diff --git a/apps/server/src/preview/SessionControl.ts b/apps/server/src/preview/SessionControl.ts index cff65d3379a02..57644e3be7135 100644 --- a/apps/server/src/preview/SessionControl.ts +++ b/apps/server/src/preview/SessionControl.ts @@ -23,7 +23,7 @@ export class SessionControl { private readonly onGenerationChange: () => void; private tail: Promise = Promise.resolve(); private latestHuman: { - entry: { viewerId: string; key: string; run: () => Promise }; + entry: { viewerId: string; key: string; input: unknown; run: (input: never) => Promise }; result: Promise; } | null = null; private pending: Promise = Promise.resolve(); @@ -134,18 +134,28 @@ export class SessionControl { return this.action(() => this.owner === viewerId, run); } - /** Replace only adjacent waiting work; the running action and intervening actions keep their order. */ - humanLatest(viewerId: string, key: string, run: () => Promise): Promise { - if (this.closed || this.owner !== viewerId) return this.human(viewerId, run); + /** + * Merge into only adjacent waiting work with the same key; the running action and + * intervening actions keep their order. Work sharing a key must share its input type. + */ + humanLatest( + viewerId: string, + key: string, + input: A, + run: (input: A) => Promise, + merge: (waiting: A, next: A) => A = (_waiting, next) => next, + ): Promise { + if (this.closed || this.owner !== viewerId) return this.human(viewerId, () => run(input)); const latest = this.latestHuman; if (latest?.entry.viewerId === viewerId && latest.entry.key === key) { + latest.entry.input = merge(latest.entry.input as A, input); latest.entry.run = run; return latest.result; } - const entry = { viewerId, key, run }; + const entry = { viewerId, key, input, run }; const result = this.human(viewerId, async () => { if (this.latestHuman?.entry === entry) this.latestHuman = null; - await entry.run(); + await entry.run(entry.input); }); this.latestHuman = { entry, result }; return result;