Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions apps/server/src/preview/ServerBrowser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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: [
Expand Down
25 changes: 22 additions & 3 deletions apps/server/src/preview/ServerBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2639,16 +2639,35 @@ const make = Effect.gen(function* () {
: {}),
});
} else {
const dispatch = () => dispatchViewerInput(tab, session, viewer, message);
const dispatch = (input: Record<string, unknown>) =>
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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
dispatch,
(waiting, next) => ({
...next,
deltaX: num(waiting.deltaX) + num(next.deltaX),
deltaY: num(waiting.deltaY) + num(next.deltaY),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}),
);
} else {
await tab.control.human(viewer.id, dispatch);
await tab.control.human(viewer.id, () => dispatch(message));
}
}
} catch {
Expand Down
36 changes: 18 additions & 18 deletions apps/server/src/preview/SessionControl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ describe("SessionControl", () => {
const started = Promise.withResolvers<void>();
const finish = Promise.withResolvers<void>();
const events: Array<number | string> = [];
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);
}),
);
Expand All @@ -36,19 +36,19 @@ describe("SessionControl", () => {
const finish = Promise.withResolvers<void>();
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 () => {
events.push("barrier");
};
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();
Expand All @@ -63,15 +63,15 @@ describe("SessionControl", () => {
const finish = Promise.withResolvers<void>();
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();
Expand All @@ -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();
Expand All @@ -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");
Expand All @@ -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"]);
Expand All @@ -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");
Expand Down
22 changes: 16 additions & 6 deletions apps/server/src/preview/SessionControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export class SessionControl {
private readonly onGenerationChange: () => void;
private tail: Promise<unknown> = Promise.resolve();
private latestHuman: {
entry: { viewerId: string; key: string; run: () => Promise<void> };
entry: { viewerId: string; key: string; input: unknown; run: (input: never) => Promise<void> };
result: Promise<void>;
} | null = null;
private pending: Promise<void> = Promise.resolve();
Expand Down Expand Up @@ -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<void>): Promise<void> {
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<A>(
viewerId: string,
key: string,
input: A,
run: (input: A) => Promise<void>,
merge: (waiting: A, next: A) => A = (_waiting, next) => next,
): Promise<void> {
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;
Expand Down
Loading