Various sync & time travel fixes

This commit is contained in:
Marcel Mraz 2024-12-23 16:48:45 +01:00
parent 6a17541713
commit 1abb901ec2
No known key found for this signature in database
GPG key ID: 4EBD6E62DC830CD2
5 changed files with 46 additions and 37 deletions

View file

@ -139,6 +139,7 @@ import type { ElementsChange } from "../packages/excalidraw/change";
import Slider from "rc-slider"; import Slider from "rc-slider";
import "rc-slider/assets/index.css"; import "rc-slider/assets/index.css";
import { SyncClient } from "../packages/excalidraw/sync/client";
polyfill(); polyfill();
@ -388,7 +389,7 @@ const ExcalidrawWrapper = () => {
syncAPI?.connect(); syncAPI?.connect();
return () => { return () => {
syncAPI?.disconnect(); syncAPI?.disconnect(SyncClient.NORMAL_CLOSURE);
clearInterval(interval); clearInterval(interval);
}; };
}, [syncAPI]); }, [syncAPI]);
@ -885,9 +886,11 @@ const ExcalidrawWrapper = () => {
max={acknowledgedIncrements.length} max={acknowledgedIncrements.length}
value={nextVersion === -1 ? acknowledgedIncrements.length : nextVersion} value={nextVersion === -1 ? acknowledgedIncrements.length : nextVersion}
onChange={(value) => { onChange={(value) => {
if (value !== acknowledgedIncrements.length - 1) { // CFDO: should be disabled when offline! (later we could have speculative changes in the versioning log as well)
// CFDO: in safari the whole canvas gets selected when dragging
if (value !== acknowledgedIncrements.length) {
// don't listen to updates in the detached mode // don't listen to updates in the detached mode
syncAPI?.disconnect(); syncAPI?.disconnect(SyncClient.NORMAL_CLOSURE);
} else { } else {
// reconnect once we're back to the latest version // reconnect once we're back to the latest version
syncAPI?.connect(); syncAPI?.connect();

View file

@ -8,7 +8,7 @@ import type {
export class DurableIncrementsRepository implements IncrementsRepository { export class DurableIncrementsRepository implements IncrementsRepository {
constructor(private storage: DurableObjectStorage) { constructor(private storage: DurableObjectStorage) {
// #region DEV ONLY // #region DEV ONLY
this.storage.sql.exec(`DROP TABLE IF EXISTS increments;`); // this.storage.sql.exec(`DROP TABLE IF EXISTS increments;`);
// #endregion // #endregion
this.storage.sql.exec(`CREATE TABLE IF NOT EXISTS increments( this.storage.sql.exec(`CREATE TABLE IF NOT EXISTS increments(

View file

@ -106,7 +106,6 @@
"@testing-library/jest-dom": "5.16.2", "@testing-library/jest-dom": "5.16.2",
"@testing-library/react": "16.0.0", "@testing-library/react": "16.0.0",
"@types/async-lock": "^1.4.2", "@types/async-lock": "^1.4.2",
"@types/lodash.debounce": "4.0.9",
"@types/pako": "1.0.3", "@types/pako": "1.0.3",
"@types/pica": "5.1.3", "@types/pica": "5.1.3",
"@types/resize-observer-browser": "0.1.7", "@types/resize-observer-browser": "0.1.7",

View file

@ -1,5 +1,4 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
import debounce from "lodash.debounce";
import throttle from "lodash.throttle"; import throttle from "lodash.throttle";
import ReconnectingWebSocket, { import ReconnectingWebSocket, {
type Event, type Event,
@ -19,11 +18,18 @@ import type {
PUSH_PAYLOAD, PUSH_PAYLOAD,
SERVER_INCREMENT, SERVER_INCREMENT,
} from "./protocol"; } from "./protocol";
import { debounce } from "../utils";
const NO_STATUS_RECEIVED_ERROR_CODE = 1005; interface AcknowledgedIncrement {
const ABNORMAL_CLOSURE_ERROR_CODE = 1006; increment: StoreIncrement;
version: number;
}
export class SyncClient { export class SyncClient {
public static readonly NORMAL_CLOSURE = 1000;
public static readonly NO_STATUS_RECEIVED_ERROR_CODE = 1005;
public static readonly ABNORMAL_CLOSURE_ERROR_CODE = 1006;
private static readonly HOST_URL = import.meta.env.DEV private static readonly HOST_URL = import.meta.env.DEV
? "ws://localhost:8787" ? "ws://localhost:8787"
: "https://excalidraw-sync.marcel-529.workers.dev"; : "https://excalidraw-sync.marcel-529.workers.dev";
@ -38,11 +44,15 @@ export class SyncClient {
private readonly repository: MetadataRepository; private readonly repository: MetadataRepository;
// CFDO: shouldn't be stateful, only request / response // CFDO: shouldn't be stateful, only request / response
private readonly acknowledgedIncrementsMap: Map<string, StoreIncrement> = private readonly acknowledgedIncrementsMap: Map<
new Map(); string,
AcknowledgedIncrement
> = new Map();
public get acknowledgedIncrements() { public get acknowledgedIncrements() {
return Array.from(this.acknowledgedIncrementsMap.values()); return Array.from(this.acknowledgedIncrementsMap.values())
.sort((a, b) => (a.version < b.version ? -1 : 1))
.map((x) => x.increment);
} }
private readonly roomId: string; private readonly roomId: string;
@ -87,7 +97,6 @@ export class SyncClient {
}); });
} }
// CFDO I: throttle does not work that well here (after some period it tries to reconnect too often)
public connect = throttle( public connect = throttle(
() => { () => {
if (this.server && this.server.readyState !== this.server.CLOSED) { if (this.server && this.server.readyState !== this.server.CLOSED) {
@ -121,7 +130,7 @@ export class SyncClient {
); );
public disconnect = throttle( public disconnect = throttle(
(code?: number, reason?: string) => { (code: number, reason?: string) => {
if (!this.server) { if (!this.server) {
return; return;
} }
@ -134,7 +143,9 @@ export class SyncClient {
} }
console.log( console.log(
`Disconnecting from the sync server with code "${code}" and reason "${reason}"...`, `Disconnecting from the sync server with code "${code}"${
reason ? ` and reason "${reason}".` : "."
}`,
); );
this.server.removeEventListener("message", this.onMessage); this.server.removeEventListener("message", this.onMessage);
this.server.removeEventListener("open", this.onOpen); this.server.removeEventListener("open", this.onOpen);
@ -153,7 +164,7 @@ export class SyncClient {
private onClose = (event: CloseEvent) => { private onClose = (event: CloseEvent) => {
this.disconnect( this.disconnect(
event.code || NO_STATUS_RECEIVED_ERROR_CODE, event.code || SyncClient.NO_STATUS_RECEIVED_ERROR_CODE,
event.reason || "Connection closed without a reason", event.reason || "Connection closed without a reason",
); );
}; };
@ -161,8 +172,8 @@ export class SyncClient {
private onError = (event: Event) => { private onError = (event: Event) => {
this.disconnect( this.disconnect(
event.type === "error" event.type === "error"
? ABNORMAL_CLOSURE_ERROR_CODE ? SyncClient.ABNORMAL_CLOSURE_ERROR_CODE
: NO_STATUS_RECEIVED_ERROR_CODE, : SyncClient.NO_STATUS_RECEIVED_ERROR_CODE,
`Received "${event.type}" on the sync connection`, `Received "${event.type}" on the sync connection`,
); );
}; };
@ -227,12 +238,9 @@ export class SyncClient {
}); });
} }
// CFDO: should be flushed once regular push / pull goes through // CFDO: could be flushed once regular push / pull goes through
private schedulePush = (ms: number = 1000) => private schedulePush = debounce(() => this.push(), 1000);
debounce(this.push, ms, { leading: true, trailing: false }); private schedulePull = debounce(() => this.pull(), 1000);
private schedulePull = (ms: number = 1000) =>
debounce(this.pull, ms, { leading: true, trailing: false });
// CFDO: refactor by applying all operations to store, not to the elements // CFDO: refactor by applying all operations to store, not to the elements
private handleAcknowledged(payload: { increments: Array<SERVER_INCREMENT> }) { private handleAcknowledged(payload: { increments: Array<SERVER_INCREMENT> }) {
@ -246,11 +254,12 @@ export class SyncClient {
const { increments: remoteIncrements } = payload; const { increments: remoteIncrements } = payload;
// apply remote increments // apply remote increments
for (const { id, version, payload } of remoteIncrements.sort((a, b) => for (const { id, version, payload } of remoteIncrements) {
a.version <= b.version ? -1 : 1,
)) {
// CFDO: temporary to load all increments on init // CFDO: temporary to load all increments on init
this.acknowledgedIncrementsMap.set(id, StoreIncrement.load(payload)); this.acknowledgedIncrementsMap.set(id, {
increment: StoreIncrement.load(payload),
version,
});
// local increment shall not have to be applied again // local increment shall not have to be applied again
if (this.queue.has(id)) { if (this.queue.has(id)) {
@ -301,11 +310,11 @@ export class SyncClient {
this.lastAcknowledgedVersion = nextAcknowledgedVersion; this.lastAcknowledgedVersion = nextAcknowledgedVersion;
} catch (e) { } catch (e) {
console.error("Failed to apply acknowledged increments:", e); console.error("Failed to apply acknowledged increments:", e);
this.schedulePull().call(this); this.schedulePull();
return; return;
} }
this.schedulePush().call(this); this.schedulePush();
} }
private handleRejected(payload: { ids: Array<string>; message: string }) { private handleRejected(payload: { ids: Array<string>; message: string }) {

View file

@ -3383,13 +3383,6 @@
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
"@types/lodash.debounce@4.0.9":
version "4.0.9"
resolved "https://registry.yarnpkg.com/@types/lodash.debounce/-/lodash.debounce-4.0.9.tgz#0f5f21c507bce7521b5e30e7a24440975ac860a5"
integrity sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==
dependencies:
"@types/lodash" "*"
"@types/lodash.throttle@4.1.7": "@types/lodash.throttle@4.1.7":
version "4.1.7" version "4.1.7"
resolved "https://registry.yarnpkg.com/@types/lodash.throttle/-/lodash.throttle-4.1.7.tgz#4ef379eb4f778068022310ef166625f420b6ba58" resolved "https://registry.yarnpkg.com/@types/lodash.throttle/-/lodash.throttle-4.1.7.tgz#4ef379eb4f778068022310ef166625f420b6ba58"
@ -7971,7 +7964,7 @@ lodash.camelcase@^4.3.0:
resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA== integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==
lodash.debounce@4.0.8, lodash.debounce@^4.0.8: lodash.debounce@^4.0.8:
version "4.0.8" version "4.0.8"
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==
@ -9409,6 +9402,11 @@ rechoir@^0.7.0:
dependencies: dependencies:
resolve "^1.9.0" resolve "^1.9.0"
reconnecting-websocket@4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz#3b0e5b96ef119e78a03135865b8bb0af1b948783"
integrity sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng==
redent@^3.0.0: redent@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f"