Skip to content

Commit 93b71a0

Browse files
committed
Implement cursor offsets rather than intervals
Instead of calculating an even interval based upon the length of the buffer and the batchtime, instead pass along movement offsets as part of the message, and replay these offsets as part of the dispensing. This ensures you get a more accurate representation of cursor movements.
1 parent c84252f commit 93b71a0

File tree

5 files changed

+78
-63
lines changed

5 files changed

+78
-63
lines changed

src/CursorBatching.ts

+26-5
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { CursorUpdate } from './Cursors.js';
44
import { CURSOR_UPDATE } from './utilities/Constants.js';
55
import type { StrictCursorsOptions } from './options/CursorsOptions.js';
66

7-
type OutgoingBuffer = Pick<CursorUpdate, 'position' | 'data'>[];
7+
type BufferData = { cursor: Pick<CursorUpdate, 'position' | 'data'>; offset: number };
8+
9+
type OutgoingBuffer = BufferData[];
810

911
export default class CursorBatching {
1012
outgoingBuffers: OutgoingBuffer = [];
@@ -20,15 +22,32 @@ export default class CursorBatching {
2022
// Set to `true` if there is more than one user listening to cursors
2123
shouldSend: boolean = false;
2224

25+
// Used for tracking offsets in the buffer
26+
bufferStartTimestamp: number = 0;
27+
2328
constructor(readonly outboundBatchInterval: StrictCursorsOptions['outboundBatchInterval']) {
2429
this.batchTime = outboundBatchInterval;
2530
}
2631

2732
pushCursorPosition(channel: Types.RealtimeChannelPromise, cursor: Pick<CursorUpdate, 'position' | 'data'>) {
2833
// Ignore the cursor update if there is no one listening
2934
if (!this.shouldSend) return;
35+
36+
// Assume there is a better way to get timestamp
37+
const timestamp = new Date().getTime();
38+
39+
let offset: number;
40+
// First update in the buffer is always 0
41+
if (this.outgoingBuffers.length === 0) {
42+
offset = 0;
43+
this.bufferStartTimestamp = timestamp;
44+
} else {
45+
// Add the offset compared to the first update in the buffer
46+
offset = timestamp - this.bufferStartTimestamp;
47+
}
48+
3049
this.hasMovement = true;
31-
this.pushToBuffer(cursor);
50+
this.pushToBuffer({ cursor, offset });
3251
this.publishFromBuffer(channel, CURSOR_UPDATE);
3352
}
3453

@@ -40,18 +59,18 @@ export default class CursorBatching {
4059
this.batchTime = batchTime;
4160
}
4261

43-
private pushToBuffer(value: Pick<CursorUpdate, 'position' | 'data'>) {
62+
private pushToBuffer(value: BufferData) {
4463
this.outgoingBuffers.push(value);
4564
}
4665

47-
private async publishFromBuffer(channel, eventName: string) {
66+
private async publishFromBuffer(channel: Types.RealtimeChannelPromise, eventName: string) {
4867
if (!this.isRunning) {
4968
this.isRunning = true;
5069
await this.batchToChannel(channel, eventName);
5170
}
5271
}
5372

54-
private async batchToChannel(channel, eventName: string) {
73+
private async batchToChannel(channel: Types.RealtimeChannelPromise, eventName: string) {
5574
if (!this.hasMovement) {
5675
this.isRunning = false;
5776
return;
@@ -65,3 +84,5 @@ export default class CursorBatching {
6584
this.isRunning = true;
6685
}
6786
}
87+
88+
export { type BufferData };

src/CursorDispensing.ts

+17-39
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,37 @@
11
import { Types } from 'ably';
22

33
import { type CursorUpdate } from './Cursors.js';
4-
5-
import { clamp } from './utilities/math.js';
4+
import { type BufferData } from './CursorBatching.js';
65

76
export default class CursorDispensing {
87
private buffer: Record<string, CursorUpdate[]> = {};
98
private handlerRunning: boolean = false;
10-
private timerIds: ReturnType<typeof setTimeout>[] = [];
119
private emitCursorUpdate: (update: CursorUpdate) => void;
12-
private getCurrentBatchTime: () => number;
1310

14-
constructor(emitCursorUpdate, getCurrentBatchTime) {
11+
constructor(emitCursorUpdate: (update: CursorUpdate) => void) {
1512
this.emitCursorUpdate = emitCursorUpdate;
16-
this.getCurrentBatchTime = getCurrentBatchTime;
1713
}
1814

19-
emitFromBatch(batchDispenseInterval: number) {
15+
emitFromBatch() {
2016
if (!this.bufferHaveData()) {
2117
this.handlerRunning = false;
2218
return;
2319
}
2420

2521
this.handlerRunning = true;
2622

27-
const processBuffer = () => {
28-
for (let connectionId in this.buffer) {
29-
const buffer = this.buffer[connectionId];
30-
const update = buffer.shift();
31-
32-
if (!update) continue;
33-
this.emitCursorUpdate(update);
34-
}
35-
36-
if (this.bufferHaveData()) {
37-
this.emitFromBatch(this.calculateDispenseInterval());
38-
} else {
39-
this.handlerRunning = false;
40-
}
23+
for (let connectionId in this.buffer) {
24+
const buffer = this.buffer[connectionId];
25+
const update = buffer.shift();
4126

42-
this.timerIds.shift();
43-
};
27+
if (!update) continue;
28+
setTimeout(() => this.emitCursorUpdate(update), update.offset);
29+
}
4430

45-
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
46-
this.timerIds.forEach((id) => clearTimeout(id));
47-
this.timerIds = [];
48-
processBuffer();
31+
if (this.bufferHaveData()) {
32+
this.emitFromBatch();
4933
} else {
50-
this.timerIds.push(setTimeout(processBuffer, batchDispenseInterval));
34+
this.handlerRunning = false;
5135
}
5236
}
5337

@@ -59,22 +43,16 @@ export default class CursorDispensing {
5943
);
6044
}
6145

62-
calculateDispenseInterval(): number {
63-
const bufferLengths = Object.entries(this.buffer).map(([, v]) => v.length);
64-
const highest = bufferLengths.sort()[bufferLengths.length - 1];
65-
const finalOutboundBatchInterval = this.getCurrentBatchTime();
66-
return Math.floor(clamp(finalOutboundBatchInterval / highest, 1, 1000 / 15));
67-
}
68-
6946
processBatch(message: Types.Message) {
7047
const updates = message.data || [];
7148

72-
updates.forEach((update) => {
49+
updates.forEach((update: BufferData) => {
7350
const enhancedMsg = {
7451
clientId: message.clientId,
7552
connectionId: message.connectionId,
76-
position: update.position,
77-
data: update.data,
53+
position: update.cursor.position,
54+
offset: update.offset,
55+
data: update.cursor.data,
7856
};
7957

8058
if (this.buffer[enhancedMsg.connectionId]) {
@@ -85,7 +63,7 @@ export default class CursorDispensing {
8563
});
8664

8765
if (!this.handlerRunning && this.bufferHaveData()) {
88-
this.emitFromBatch(this.calculateDispenseInterval());
66+
this.emitFromBatch();
8967
}
9068
}
9169
}

src/CursorHistory.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,14 @@ export default class CursorHistory {
1616
private messageToUpdate(
1717
connectionId: string,
1818
clientId: string,
19-
update: Pick<CursorUpdate, 'position' | 'data'>,
19+
update: Pick<CursorUpdate, 'position' | 'data' | 'offset'>,
2020
): CursorUpdate {
2121
return {
2222
clientId,
2323
connectionId,
2424
position: update.position,
2525
data: update.data,
26+
offset: update.offset,
2627
};
2728
}
2829

src/Cursors.mockClient.test.ts

+30-16
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,10 @@ describe('Cursors (mockClient)', () => {
6565
it<CursorsTestContext>('emits a cursorsUpdate event', ({ space, dispensing, batching, fakeMessageStub }) => {
6666
const fakeMessage = {
6767
...fakeMessageStub,
68-
data: [{ position: { x: 1, y: 1 } }, { position: { x: 1, y: 2 }, data: { color: 'red' } }],
68+
data: [
69+
{ cursor: { position: { x: 1, y: 1 } } },
70+
{ cursor: { position: { x: 1, y: 2 }, data: { color: 'red' } } },
71+
],
6972
};
7073

7174
const spy = vitest.fn();
@@ -149,16 +152,16 @@ describe('Cursors (mockClient)', () => {
149152

150153
it<CursorsTestContext>('creates an outgoingBuffer for a new cursor movement', ({ batching, channel }) => {
151154
batching.pushCursorPosition(channel, { position: { x: 1, y: 1 }, data: {} });
152-
expect(batching.outgoingBuffers).toEqual([{ position: { x: 1, y: 1 }, data: {} }]);
155+
expect(batching.outgoingBuffers).toEqual([{ cursor: { position: { x: 1, y: 1 }, data: {} }, offset: 0 }]);
153156
});
154157

155158
it<CursorsTestContext>('adds cursor data to an existing buffer', ({ batching, channel }) => {
156159
batching.pushCursorPosition(channel, { position: { x: 1, y: 1 }, data: {} });
157-
expect(batching.outgoingBuffers).toEqual([{ position: { x: 1, y: 1 }, data: {} }]);
160+
expect(batching.outgoingBuffers).toEqual([{ cursor: { position: { x: 1, y: 1 }, data: {} }, offset: 0 }]);
158161
batching.pushCursorPosition(channel, { position: { x: 2, y: 2 }, data: {} });
159162
expect(batching.outgoingBuffers).toEqual([
160-
{ position: { x: 1, y: 1 }, data: {} },
161-
{ position: { x: 2, y: 2 }, data: {} },
163+
{ cursor: { position: { x: 1, y: 1 }, data: {} }, offset: 0 },
164+
{ cursor: { position: { x: 2, y: 2 }, data: {} }, offset: 0 },
162165
]);
163166
});
164167

@@ -193,15 +196,17 @@ describe('Cursors (mockClient)', () => {
193196

194197
it<CursorsTestContext>('should publish the cursor buffer', async ({ batching, channel }) => {
195198
batching.hasMovement = true;
196-
batching.outgoingBuffers = [{ position: { x: 1, y: 1 }, data: {} }];
199+
batching.outgoingBuffers = [{ cursor: { position: { x: 1, y: 1 }, data: {} }, offset: 0 }];
197200
const spy = vi.spyOn(channel, 'publish');
198201
await batching['batchToChannel'](channel, CURSOR_UPDATE);
199-
expect(spy).toHaveBeenCalledWith(CURSOR_UPDATE, [{ position: { x: 1, y: 1 }, data: {} }]);
202+
expect(spy).toHaveBeenCalledWith(CURSOR_UPDATE, [
203+
{ cursor: { position: { x: 1, y: 1 }, data: {} }, offset: 0 },
204+
]);
200205
});
201206

202207
it<CursorsTestContext>('should clear the buffer', async ({ batching, channel }) => {
203208
batching.hasMovement = true;
204-
batching.outgoingBuffers = [{ position: { x: 1, y: 1 }, data: {} }];
209+
batching.outgoingBuffers = [{ cursor: { position: { x: 1, y: 1 }, data: {} }, offset: 0 }];
205210
await batching['batchToChannel'](channel, CURSOR_UPDATE);
206211
expect(batching.outgoingBuffers).toEqual([]);
207212
});
@@ -239,7 +244,7 @@ describe('Cursors (mockClient)', () => {
239244

240245
const fakeMessage = {
241246
...fakeMessageStub,
242-
data: [{ position: { x: 1, y: 1 } }],
247+
data: [{ cursor: { position: { x: 1, y: 1 } } }],
243248
};
244249

245250
dispensing['handlerRunning'] = true;
@@ -252,7 +257,7 @@ describe('Cursors (mockClient)', () => {
252257

253258
const fakeMessage = {
254259
...fakeMessageStub,
255-
data: [{ position: { x: 1, y: 1 } }],
260+
data: [{ cursor: { position: { x: 1, y: 1 } } }],
256261
};
257262

258263
dispensing.processBatch(fakeMessage);
@@ -266,9 +271,9 @@ describe('Cursors (mockClient)', () => {
266271
const fakeMessage = {
267272
...fakeMessageStub,
268273
data: [
269-
{ position: { x: 1, y: 1 } },
270-
{ position: { x: 2, y: 3 }, data: { color: 'blue' } },
271-
{ position: { x: 5, y: 4 } },
274+
{ cursor: { position: { x: 1, y: 1 } }, offset: 0 },
275+
{ cursor: { position: { x: 2, y: 3 }, data: { color: 'blue' } }, offset: 1 },
276+
{ cursor: { position: { x: 5, y: 4 } }, offset: 2 },
272277
],
273278
};
274279

@@ -278,18 +283,21 @@ describe('Cursors (mockClient)', () => {
278283
{
279284
position: { x: 1, y: 1 },
280285
data: undefined,
286+
offset: 0,
281287
clientId: 'clientId',
282288
connectionId: 'connectionId',
283289
},
284290
{
285291
position: { x: 2, y: 3 },
286292
data: { color: 'blue' },
293+
offset: 1,
287294
clientId: 'clientId',
288295
connectionId: 'connectionId',
289296
},
290297
{
291298
position: { x: 5, y: 4 },
292299
data: undefined,
300+
offset: 2,
293301
clientId: 'clientId',
294302
connectionId: 'connectionId',
295303
},
@@ -303,9 +311,9 @@ describe('Cursors (mockClient)', () => {
303311
const fakeMessage = {
304312
...fakeMessageStub,
305313
data: [
306-
{ position: { x: 1, y: 1 } },
307-
{ position: { x: 2, y: 3 }, data: { color: 'blue' } },
308-
{ position: { x: 5, y: 4 } },
314+
{ cursor: { position: { x: 1, y: 1 } }, offset: 0 },
315+
{ cursor: { position: { x: 2, y: 3 }, data: { color: 'blue' } }, offset: 1 },
316+
{ cursor: { position: { x: 5, y: 4 } }, offset: 2 },
309317
],
310318
};
311319

@@ -359,6 +367,7 @@ describe('Cursors (mockClient)', () => {
359367
x: 2,
360368
y: 3,
361369
},
370+
offset: 0,
362371
},
363372
connectionId2: {
364373
connectionId: 'connectionId2',
@@ -368,6 +377,7 @@ describe('Cursors (mockClient)', () => {
368377
x: 25,
369378
y: 44,
370379
},
380+
offset: 0,
371381
},
372382
connectionId3: {
373383
connectionId: 'connectionId3',
@@ -377,6 +387,7 @@ describe('Cursors (mockClient)', () => {
377387
x: 225,
378388
y: 244,
379389
},
390+
offset: 0,
380391
},
381392
};
382393
});
@@ -496,6 +507,7 @@ describe('Cursors (mockClient)', () => {
496507
x: 2,
497508
y: 3,
498509
},
510+
offset: 0,
499511
},
500512
};
501513

@@ -524,6 +536,7 @@ describe('Cursors (mockClient)', () => {
524536
y: 44,
525537
},
526538
data: undefined,
539+
offset: 0,
527540
},
528541
connectionId3: {
529542
connectionId: 'connectionId3',
@@ -533,6 +546,7 @@ describe('Cursors (mockClient)', () => {
533546
y: 244,
534547
},
535548
data: undefined,
549+
offset: 0,
536550
},
537551
});
538552
});

src/Cursors.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@ import type { CursorsOptions, StrictCursorsOptions } from './options/CursorsOpti
1717
type ConnectionId = string;
1818
type CursorPosition = { x: number; y: number };
1919
type CursorData = Record<string, unknown>;
20+
2021
type CursorUpdate = {
2122
clientId: string;
2223
connectionId: string;
2324
position: CursorPosition;
25+
offset: number;
2426
data?: CursorData;
2527
};
2628
type CursorsEventMap = {
@@ -60,8 +62,7 @@ export default class Cursors extends EventEmitter<CursorsEventMap> {
6062
this.cursorBatching = new CursorBatching(this.options.outboundBatchInterval);
6163

6264
const emitCursorUpdate = (update: CursorUpdate): void => this.emit('cursorsUpdate', update);
63-
const getCurrentBatchTime: () => number = () => this.cursorBatching.batchTime;
64-
this.cursorDispensing = new CursorDispensing(emitCursorUpdate, getCurrentBatchTime);
65+
this.cursorDispensing = new CursorDispensing(emitCursorUpdate);
6566
}
6667

6768
/**

0 commit comments

Comments
 (0)