Skip to content

Commit 8369d8b

Browse files
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 <[email protected]>
1 parent 5b721e0 commit 8369d8b

4 files changed

Lines changed: 75 additions & 29 deletions

File tree

‎apps/server/src/preview/ServerBrowser.test.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1064,13 +1064,30 @@ it.effect.each([
10641064
expected: [
10651065
{ type: "mouseMoved", x: -1 },
10661066
{ type: "mouseMoved", x: 1 },
1067-
{ type: "mouseWheel", deltaY: 20 },
1068-
{ type: "mouseWheel", deltaY: 30 },
1067+
{ type: "mouseWheel", deltaY: 50 },
10691068
{ type: "mouseMoved", x: 2 },
10701069
{ type: "keyDown", key: "a" },
10711070
{ type: "mouseMoved", x: 3 },
10721071
],
10731072
},
1073+
{
1074+
name: "wheel position, modifier and direction boundaries",
1075+
inputs: [
1076+
{ type: "wheel", x: 1, deltaX: 5, deltaY: 10 },
1077+
{ type: "wheel", x: 1, deltaX: 5, deltaY: 10 },
1078+
{ type: "wheel", x: 2, deltaY: 10 },
1079+
{ type: "wheel", x: 2, deltaY: 10, modifiers: 2 },
1080+
{ type: "wheel", x: 2, deltaY: -10, modifiers: 2 },
1081+
{ type: "wheel", x: 2, deltaY: -10, modifiers: 2 },
1082+
],
1083+
expected: [
1084+
{ type: "mouseMoved", x: -1 },
1085+
{ type: "mouseWheel", x: 1, deltaX: 10, deltaY: 20, modifiers: 0 },
1086+
{ type: "mouseWheel", x: 2, deltaX: 0, deltaY: 10, modifiers: 0 },
1087+
{ type: "mouseWheel", x: 2, deltaY: 10, modifiers: 2 },
1088+
{ type: "mouseWheel", x: 2, deltaY: -20, modifiers: 2 },
1089+
],
1090+
},
10741091
{
10751092
name: "text, navigation and resize boundaries",
10761093
inputs: [

‎apps/server/src/preview/ServerBrowser.ts‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2628,16 +2628,35 @@ const make = Effect.gen(function* () {
26282628
: {}),
26292629
});
26302630
} else {
2631-
const dispatch = () => dispatchViewerInput(tab, session, viewer, message);
2631+
const dispatch = (input: Record<string, unknown>) =>
2632+
dispatchViewerInput(tab, session, viewer, input);
26322633
if (
26332634
message.type === "mouse" &&
26342635
message.action === "move" &&
26352636
message.button === "none" &&
26362637
message.buttons === 0
26372638
) {
2638-
await tab.control.humanLatest(viewer.id, `hover:${modifiersOf(message)}`, dispatch);
2639+
await tab.control.humanLatest(
2640+
viewer.id,
2641+
`hover:${modifiersOf(message)}`,
2642+
message,
2643+
dispatch,
2644+
);
2645+
} else if (message.type === "wheel") {
2646+
// A reversal stays separate so scrolling clamped at an edge behaves as if unmerged.
2647+
await tab.control.humanLatest(
2648+
viewer.id,
2649+
`wheel:${num(message.x)}:${num(message.y)}:${modifiersOf(message)}:${Math.sign(num(message.deltaX))}:${Math.sign(num(message.deltaY))}`,
2650+
message,
2651+
dispatch,
2652+
(waiting, next) => ({
2653+
...next,
2654+
deltaX: num(waiting.deltaX) + num(next.deltaX),
2655+
deltaY: num(waiting.deltaY) + num(next.deltaY),
2656+
}),
2657+
);
26392658
} else {
2640-
await tab.control.human(viewer.id, dispatch);
2659+
await tab.control.human(viewer.id, () => dispatch(message));
26412660
}
26422661
}
26432662
} catch {

‎apps/server/src/preview/SessionControl.test.ts‎

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@ describe("SessionControl", () => {
99
const started = Promise.withResolvers<void>();
1010
const finish = Promise.withResolvers<void>();
1111
const events: Array<number | string> = [];
12-
const running = control.humanLatest("viewer", "hover:0", async () => {
12+
const running = control.humanLatest("viewer", "hover:0", undefined, async () => {
1313
events.push("running");
1414
started.resolve();
1515
await finish.promise;
1616
});
1717
await started.promise;
1818
const moves = Array.from({ length: 120 }, (_, position) =>
19-
control.humanLatest("viewer", "hover:0", async () => {
19+
control.humanLatest("viewer", "hover:0", undefined, async () => {
2020
events.push(position);
2121
}),
2222
);
@@ -36,19 +36,19 @@ describe("SessionControl", () => {
3636
const finish = Promise.withResolvers<void>();
3737
const events: string[] = [];
3838
const running = control.human("viewer", () => finish.promise);
39-
const before = control.humanLatest("viewer", "hover:0", async () => {
39+
const before = control.humanLatest("viewer", "hover:0", undefined, async () => {
4040
events.push("before");
4141
});
4242
const recordBarrier = async () => {
4343
events.push("barrier");
4444
};
4545
const between =
4646
barrier === "modifiers"
47-
? control.humanLatest("viewer", "hover:2", recordBarrier)
47+
? control.humanLatest("viewer", "hover:2", undefined, recordBarrier)
4848
: barrier === "system"
4949
? control.system(recordBarrier)
5050
: control.human("viewer", recordBarrier);
51-
const after = control.humanLatest("viewer", "hover:0", async () => {
51+
const after = control.humanLatest("viewer", "hover:0", undefined, async () => {
5252
events.push("after");
5353
});
5454
finish.resolve();
@@ -63,15 +63,15 @@ describe("SessionControl", () => {
6363
const finish = Promise.withResolvers<void>();
6464
const events: string[] = [];
6565
const running = control.human("viewer", () => finish.promise);
66-
const stale = control.humanLatest("viewer", "hover", async () => {
66+
const stale = control.humanLatest("viewer", "hover", undefined, async () => {
6767
events.push("stale");
6868
});
6969
await expect(
70-
control.humanLatest("other", "hover", async () => {
70+
control.humanLatest("other", "hover", undefined, async () => {
7171
events.push("other");
7272
}),
7373
).rejects.toThrow(BrowserControlInterrupted);
74-
const latest = control.humanLatest("viewer", "hover", async () => {
74+
const latest = control.humanLatest("viewer", "hover", undefined, async () => {
7575
events.push("latest");
7676
});
7777
finish.resolve();
@@ -93,16 +93,16 @@ describe("SessionControl", () => {
9393
});
9494
await started.promise;
9595
const stale = expect(
96-
control.humanLatest("viewer", "hover", async () => {
96+
control.humanLatest("viewer", "hover", undefined, async () => {
9797
events.push("stale");
9898
}),
9999
).rejects.toThrow(BrowserControlInterrupted);
100100
const released = control[handoff]("viewer");
101101
const retaken = control.take("viewer");
102-
const fresh = control.humanLatest("viewer", "hover", async () => {
102+
const fresh = control.humanLatest("viewer", "hover", undefined, async () => {
103103
events.push("fresh");
104104
});
105-
const latest = control.humanLatest("viewer", "hover", async () => {
105+
const latest = control.humanLatest("viewer", "hover", undefined, async () => {
106106
events.push("latest");
107107
});
108108
finish.resolve();
@@ -124,14 +124,14 @@ describe("SessionControl", () => {
124124
await started.promise;
125125
const waiting = [1, 2].map((position) =>
126126
expect(
127-
control.humanLatest("viewer", "hover", async () => {
127+
control.humanLatest("viewer", "hover", undefined, async () => {
128128
events.push(`hover ${position}`);
129129
}),
130130
).rejects.toThrow("closed"),
131131
);
132132
const closed = control.close();
133133
await expect(
134-
control.humanLatest("viewer", "hover", async () => {
134+
control.humanLatest("viewer", "hover", undefined, async () => {
135135
events.push("after close");
136136
}),
137137
).rejects.toThrow("closed");
@@ -147,18 +147,18 @@ describe("SessionControl", () => {
147147
const events: string[] = [];
148148
const running = control.human("viewer", () => finish.promise);
149149
const first = expect(
150-
control.humanLatest("viewer", "hover", async () => {
150+
control.humanLatest("viewer", "hover", undefined, async () => {
151151
events.push("superseded");
152152
}),
153153
).rejects.toThrow("dispatch failed");
154154
const last = expect(
155-
control.humanLatest("viewer", "hover", async () => {
155+
control.humanLatest("viewer", "hover", undefined, async () => {
156156
throw new Error("dispatch failed");
157157
}),
158158
).rejects.toThrow("dispatch failed");
159159
finish.resolve();
160160
await Promise.all([running, first, last]);
161-
await control.humanLatest("viewer", "hover", async () => {
161+
await control.humanLatest("viewer", "hover", undefined, async () => {
162162
events.push("recovered");
163163
});
164164
expect(events).toEqual(["recovered"]);
@@ -177,10 +177,10 @@ describe("SessionControl", () => {
177177
);
178178
return "started";
179179
});
180-
const first = control.humanLatest("viewer", "hover", async () => {
180+
const first = control.humanLatest("viewer", "hover", undefined, async () => {
181181
events.push("superseded");
182182
});
183-
const latest = control.humanLatest("viewer", "hover", async () => {
183+
const latest = control.humanLatest("viewer", "hover", undefined, async () => {
184184
events.push("latest");
185185
});
186186
await expect(navigation).resolves.toBe("started");

‎apps/server/src/preview/SessionControl.ts‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export class SessionControl {
2323
private readonly onGenerationChange: () => void;
2424
private tail: Promise<unknown> = Promise.resolve();
2525
private latestHuman: {
26-
entry: { viewerId: string; key: string; run: () => Promise<void> };
26+
entry: { viewerId: string; key: string; input: unknown; run: (input: never) => Promise<void> };
2727
result: Promise<void>;
2828
} | null = null;
2929
private pending: Promise<void> = Promise.resolve();
@@ -134,18 +134,28 @@ export class SessionControl {
134134
return this.action(() => this.owner === viewerId, run);
135135
}
136136

137-
/** Replace only adjacent waiting work; the running action and intervening actions keep their order. */
138-
humanLatest(viewerId: string, key: string, run: () => Promise<void>): Promise<void> {
139-
if (this.closed || this.owner !== viewerId) return this.human(viewerId, run);
137+
/**
138+
* Merge into only adjacent waiting work with the same key; the running action and
139+
* intervening actions keep their order. Work sharing a key must share its input type.
140+
*/
141+
humanLatest<A>(
142+
viewerId: string,
143+
key: string,
144+
input: A,
145+
run: (input: A) => Promise<void>,
146+
merge: (waiting: A, next: A) => A = (_waiting, next) => next,
147+
): Promise<void> {
148+
if (this.closed || this.owner !== viewerId) return this.human(viewerId, () => run(input));
140149
const latest = this.latestHuman;
141150
if (latest?.entry.viewerId === viewerId && latest.entry.key === key) {
151+
latest.entry.input = merge(latest.entry.input as A, input);
142152
latest.entry.run = run;
143153
return latest.result;
144154
}
145-
const entry = { viewerId, key, run };
155+
const entry = { viewerId, key, input, run };
146156
const result = this.human(viewerId, async () => {
147157
if (this.latestHuman?.entry === entry) this.latestHuman = null;
148-
await entry.run();
158+
await entry.run(entry.input);
149159
});
150160
this.latestHuman = { entry, result };
151161
return result;

0 commit comments

Comments
 (0)