Rebasely Beta: data may be purged

examples/live-notes Public

Fold queued keystrokes into one op

With one op in flight at a time, typing on a slow connection sent a letter per round trip and fell further behind. compose() merges an edit into the last queued op when they touch the same spot (typing on, backspacing), so the next send carries everything typed meanwhile.
359d96637539b0b30fc7dbf78445d7b7cac074f7
Parent 72cc3df Browse filesHistory

Changed files

4 files changed58 additions7 deletionsagainst 72cc3df

  1. Changedclient/session.ts+8−3
  2. Changedsrc/ops.ts+17−0
  3. Changedtest/ops.test.ts+29−1
  4. Changedtest/session.test.ts+4−3
Changedclient/session.ts+8−3
@@ -2,11 +2,13 @@
// seen, and its own edits the room has not acknowledged yet.
//
// The editor keeps at most one op in flight. Further edits wait in a queue until the room acks
-// the one in flight, so every op it sends is written against a version the room has. When
+// the one in flight, so every op it sends is written against a version the room has. Queued
+// keystrokes that touch the same spot are folded into one op, so a fast typist sends a word
+// per round trip rather than a letter. When
// someone else's op arrives, the session moves it past the edits still pending here, and moves
// those edits past it, the same way the room will.
-import { apply, diff, isNoop, transform, type Op } from "../src/ops.ts";
+import { apply, compose, diff, isNoop, transform, type Op } from "../src/ops.ts";
import type { ClientMessage, ServerMessage } from "../src/protocol.ts";
export class Session {
@@ -31,7 +33,10 @@
edit(next: string): void {
for (const op of diff(this.text, next)) {
this.text = apply(this.text, op);
- this.#queue.push(op);
+ const last = this.#queue.at(-1);
+ const merged = last && compose(last, op);
+ if (merged) this.#queue[this.#queue.length - 1] = merged;
+ else this.#queue.push(op);
}
this.#flush();
}
View file
Changedsrc/ops.ts+17−0
@@ -99,3 +99,20 @@
if (endAfter > start) ops.push(insert(start, after.slice(start, endAfter)));
return ops;
}
+
+// One op with the effect of `a` followed by `b`, or null when they touch separate places.
+// Editors use it to fold a burst of keystrokes into a single op while one is in flight.
+export function compose(a: Op, b: Op): Op | null {
+ if (a.type === "insert") {
+ const at = b.pos - a.pos;
+ if (at < 0 || at > a.text.length) return null;
+ if (b.type === "insert") return insert(a.pos, a.text.slice(0, at) + b.text + a.text.slice(at));
+ // Deleting part of what was just typed.
+ if (at + b.count > a.text.length) return null;
+ return insert(a.pos, a.text.slice(0, at) + a.text.slice(at + b.count));
+ }
+ if (b.type !== "delete") return null;
+ if (b.pos === a.pos) return del(a.pos, a.count + b.count); // forward delete, repeated
+ if (b.pos + b.count === a.pos) return del(b.pos, a.count + b.count); // backspace, repeated
+ return null;
+}
View file
Changedtest/ops.test.ts+29−1
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { describe, test } from "node:test";
-import { OpError, apply, del, diff, insert, isNoop, transform, transformPast, type Op } from "../src/ops.ts";
+import { OpError, apply, compose, del, diff, insert, isNoop, transform, transformPast, type Op } from "../src/ops.ts";
import { randomOp, rng } from "./helpers/random.ts";
describe("apply", () => {
@@ -96,5 +96,33 @@
test("no change, no ops", () => {
assert.deepEqual(diff("same", "same"), []);
+ });
+});
+
+describe("compose", () => {
+ test("folds typing and backspacing into one op", () => {
+ assert.deepEqual(compose(insert(4, "o"), insert(5, "a")), insert(4, "oa"));
+ assert.deepEqual(compose(insert(4, "oat"), del(6, 1)), insert(4, "oa"));
+ assert.deepEqual(compose(del(9, 1), del(8, 1)), del(8, 2));
+ assert.deepEqual(compose(del(3, 1), del(3, 2)), del(3, 3));
+ });
+
+ test("leaves ops in separate places alone", () => {
+ assert.equal(compose(insert(4, "o"), insert(9, "x")), null);
+ assert.equal(compose(insert(4, "oat"), del(2, 3)), null);
+ assert.equal(compose(del(3, 1), insert(3, "x")), null);
+ assert.equal(compose(del(3, 1), del(6, 1)), null);
+ });
+
+ test("a composed op has the effect of the pair", () => {
+ const next = rng(11);
+ for (let i = 0; i < 5000; i++) {
+ const doc = "jump over the lazy dog".slice(0, Math.floor(next() * 23));
+ const a = randomOp(doc, next);
+ const mid = apply(doc, a);
+ const b = randomOp(mid, next);
+ const c = compose(a, b);
+ if (c) assert.equal(apply(doc, c), apply(mid, b), `doc ${JSON.stringify(doc)} a ${JSON.stringify(a)} b ${JSON.stringify(b)}`);
+ }
});
});
View file
Changedtest/session.test.ts+4−3
@@ -47,19 +47,20 @@
}
describe("Session", () => {
- test("sends one op at a time and the next after the ack", () => {
+ test("sends one op at a time and folds what is typed meanwhile", () => {
const net = new Network();
const a = net.join();
a.edit("h");
a.edit("hi");
+ a.edit("hi!");
assert.equal(net.editors[0].up.length, 1);
assert.equal(a.pending, 2);
net.deliverUp(0);
net.deliverDown(0);
- assert.deepEqual(net.editors[0].up, [{ type: "op", base: 1, op: insert(1, "i") }]);
+ assert.deepEqual(net.editors[0].up, [{ type: "op", base: 1, op: insert(1, "i!") }]);
net.drain();
assert.equal(a.pending, 0);
- assert.deepEqual(net.room.note, { text: "hi", version: 2 });
+ assert.deepEqual(net.room.note, { text: "hi!", version: 2 });
});
test("moves a remote op past its own pending edit", () => {
View file

Checks

No checks reported for this commit.

Runs

No runs for this commit.