diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index de2ed0a875509..7111b17eb5b28 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -1083,6 +1083,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: [ @@ -1117,13 +1137,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 6257a686764f1..7444b84f906e3 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -2639,16 +2639,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;