@tldraw/sync-core 4.4.0-next.84d68f44c848 → 4.4.0-next.bde73a32273d
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-cjs/index.d.ts +2 -1
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/TLSyncClient.js +60 -55
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-esm/index.d.mts +2 -1
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/TLSyncClient.mjs +68 -57
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/package.json +7 -8
- package/src/lib/TLSyncClient.test.ts +591 -0
- package/src/lib/TLSyncClient.ts +87 -88
- package/src/test/FuzzEditor.ts +6 -4
package/src/lib/TLSyncClient.ts
CHANGED
|
@@ -5,16 +5,24 @@ import {
|
|
|
5
5
|
Store,
|
|
6
6
|
UnknownRecord,
|
|
7
7
|
reverseRecordsDiff,
|
|
8
|
-
|
|
8
|
+
squashRecordDiffsMutable,
|
|
9
9
|
} from '@tldraw/store'
|
|
10
10
|
import {
|
|
11
11
|
FpsScheduler,
|
|
12
12
|
exhaustiveSwitchError,
|
|
13
13
|
isEqual,
|
|
14
14
|
objectMapEntries,
|
|
15
|
+
structuredClone,
|
|
15
16
|
uniqueId,
|
|
16
17
|
} from '@tldraw/utils'
|
|
17
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
NetworkDiff,
|
|
20
|
+
ObjectDiff,
|
|
21
|
+
RecordOpType,
|
|
22
|
+
applyObjectDiff,
|
|
23
|
+
diffRecord,
|
|
24
|
+
getNetworkDiff,
|
|
25
|
+
} from './diff'
|
|
18
26
|
import { interval } from './interval'
|
|
19
27
|
import {
|
|
20
28
|
TLPushRequest,
|
|
@@ -281,6 +289,21 @@ const MAX_TIME_TO_WAIT_FOR_SERVER_INTERACTION_BEFORE_RESETTING_CONNECTION = PING
|
|
|
281
289
|
|
|
282
290
|
// Should connect support chunking the response to allow for large payloads?
|
|
283
291
|
|
|
292
|
+
function getPresenceOp<R extends UnknownRecord>(
|
|
293
|
+
lastPushedPresenceState: R | null,
|
|
294
|
+
nextPresence: R | null
|
|
295
|
+
): [typeof RecordOpType.Patch, ObjectDiff] | [typeof RecordOpType.Put, R] | undefined {
|
|
296
|
+
if (!lastPushedPresenceState && nextPresence) {
|
|
297
|
+
return [RecordOpType.Put, nextPresence]
|
|
298
|
+
}
|
|
299
|
+
if (lastPushedPresenceState && nextPresence) {
|
|
300
|
+
const diff = diffRecord(lastPushedPresenceState, nextPresence)
|
|
301
|
+
if (!diff) return undefined
|
|
302
|
+
return [RecordOpType.Patch, diff]
|
|
303
|
+
}
|
|
304
|
+
return undefined
|
|
305
|
+
}
|
|
306
|
+
|
|
284
307
|
/**
|
|
285
308
|
* Main client-side synchronization engine for collaborative tldraw applications.
|
|
286
309
|
*
|
|
@@ -346,7 +369,11 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
346
369
|
private lastServerInteractionTimestamp = Date.now()
|
|
347
370
|
|
|
348
371
|
/** The queue of in-flight push requests that have not yet been acknowledged by the server */
|
|
349
|
-
private pendingPushRequests:
|
|
372
|
+
private pendingPushRequests: TLPushRequest<R>[] = []
|
|
373
|
+
private unsentChanges: {
|
|
374
|
+
nextDiff?: RecordsDiff<R>
|
|
375
|
+
nextPresence?: R | null
|
|
376
|
+
} = { nextDiff: undefined, nextPresence: undefined }
|
|
350
377
|
|
|
351
378
|
/**
|
|
352
379
|
* The diff of 'unconfirmed', 'optimistic' changes that have been made locally by the user if we
|
|
@@ -365,7 +392,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
365
392
|
private readonly fpsScheduler: FpsScheduler
|
|
366
393
|
|
|
367
394
|
/** Send any unsent push requests to the server */
|
|
368
|
-
private readonly
|
|
395
|
+
private readonly sendUnsentChanges: {
|
|
369
396
|
(): void
|
|
370
397
|
cancel?(): void
|
|
371
398
|
}
|
|
@@ -464,25 +491,45 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
464
491
|
this.fpsScheduler = new FpsScheduler(COLLABORATIVE_MODE_FPS)
|
|
465
492
|
|
|
466
493
|
// Initialize throttled methods after throttle instance is created
|
|
467
|
-
this.
|
|
468
|
-
this.debug('
|
|
494
|
+
this.sendUnsentChanges = this.fpsScheduler.fpsThrottle(() => {
|
|
495
|
+
this.debug('sending unsent changes', {
|
|
469
496
|
isConnectedToRoom: this.isConnectedToRoom,
|
|
470
|
-
|
|
497
|
+
unsentChanges: this.unsentChanges,
|
|
471
498
|
})
|
|
472
499
|
if (!this.isConnectedToRoom || this.store.isPossiblyCorrupted()) {
|
|
473
500
|
return
|
|
474
501
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
if (this.socket.connectionStatus !== 'online') {
|
|
478
|
-
// we went offline, so don't send anything
|
|
479
|
-
return
|
|
480
|
-
}
|
|
481
|
-
this.debug('sending push request', pendingPushRequest)
|
|
482
|
-
this.socket.sendMessage(pendingPushRequest.request)
|
|
483
|
-
pendingPushRequest.sent = true
|
|
484
|
-
}
|
|
502
|
+
if (!this.unsentChanges.nextDiff && !this.unsentChanges.nextPresence) {
|
|
503
|
+
return
|
|
485
504
|
}
|
|
505
|
+
const diff = this.unsentChanges.nextDiff
|
|
506
|
+
? (getNetworkDiff(this.unsentChanges.nextDiff) ?? undefined)
|
|
507
|
+
: undefined
|
|
508
|
+
const presence = this.unsentChanges.nextPresence
|
|
509
|
+
? getPresenceOp<R>(this.lastPushedPresenceState, this.unsentChanges.nextPresence)
|
|
510
|
+
: undefined
|
|
511
|
+
|
|
512
|
+
if (!diff && !presence) {
|
|
513
|
+
return
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const pushRequest: TLPushRequest<R> = {
|
|
517
|
+
type: 'push',
|
|
518
|
+
clientClock: this.clientClock,
|
|
519
|
+
diff,
|
|
520
|
+
presence,
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
this.debug('sending push request', pushRequest)
|
|
524
|
+
this.socket.sendMessage(pushRequest)
|
|
525
|
+
|
|
526
|
+
if (this.unsentChanges.nextPresence) {
|
|
527
|
+
this.lastPushedPresenceState = this.unsentChanges.nextPresence
|
|
528
|
+
}
|
|
529
|
+
this.clientClock++
|
|
530
|
+
this.pendingPushRequests.push(pushRequest)
|
|
531
|
+
this.unsentChanges.nextDiff = undefined
|
|
532
|
+
this.unsentChanges.nextPresence = undefined
|
|
486
533
|
})
|
|
487
534
|
|
|
488
535
|
this.scheduleRebase = this.fpsScheduler.fpsThrottle(this.rebase)
|
|
@@ -631,6 +678,8 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
631
678
|
this.isConnectedToRoom = false
|
|
632
679
|
this.pendingPushRequests = []
|
|
633
680
|
this.incomingDiffBuffer = []
|
|
681
|
+
this.unsentChanges.nextDiff = undefined
|
|
682
|
+
this.unsentChanges.nextPresence = undefined
|
|
634
683
|
if (this.socket.connectionStatus === 'online') {
|
|
635
684
|
this.socket.restart()
|
|
636
685
|
}
|
|
@@ -695,9 +744,11 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
695
744
|
|
|
696
745
|
// now re-apply the speculative changes creating a new push request with the
|
|
697
746
|
// appropriate diff
|
|
747
|
+
const networkDiff = getNetworkDiff(stashedChanges)
|
|
748
|
+
if (!networkDiff) return
|
|
698
749
|
const speculativeChanges = this.store.filterChangesByScope(
|
|
699
750
|
this.store.extractingChanges(() => {
|
|
700
|
-
this.
|
|
751
|
+
this.applyNetworkDiff(networkDiff, true)
|
|
701
752
|
}),
|
|
702
753
|
'document'
|
|
703
754
|
)
|
|
@@ -773,7 +824,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
773
824
|
close() {
|
|
774
825
|
this.debug('closing')
|
|
775
826
|
this.disposables.forEach((dispose) => dispose())
|
|
776
|
-
this.
|
|
827
|
+
this.sendUnsentChanges.cancel?.()
|
|
777
828
|
this.scheduleRebase.cancel?.()
|
|
778
829
|
}
|
|
779
830
|
|
|
@@ -788,78 +839,22 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
788
839
|
return
|
|
789
840
|
}
|
|
790
841
|
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
// we don't have a last presence state, so we need to push the full state
|
|
794
|
-
presence = [RecordOpType.Put, nextPresence]
|
|
795
|
-
} else if (this.lastPushedPresenceState && nextPresence) {
|
|
796
|
-
// we have a last presence state, so we need to push a diff if there is one
|
|
797
|
-
const diff = diffRecord(this.lastPushedPresenceState, nextPresence)
|
|
798
|
-
if (diff) {
|
|
799
|
-
presence = [RecordOpType.Patch, diff]
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
if (!presence) return
|
|
804
|
-
this.lastPushedPresenceState = nextPresence
|
|
805
|
-
|
|
806
|
-
// if there is a pending push that has not been sent and does not already include a presence update,
|
|
807
|
-
// then add this presence update to it
|
|
808
|
-
const lastPush = this.pendingPushRequests.at(-1)
|
|
809
|
-
if (lastPush && !lastPush.sent && !lastPush.request.presence) {
|
|
810
|
-
lastPush.request.presence = presence
|
|
811
|
-
return
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
// otherwise, create a new push request
|
|
815
|
-
const req: TLPushRequest<R> = {
|
|
816
|
-
type: 'push',
|
|
817
|
-
clientClock: this.clientClock++,
|
|
818
|
-
presence,
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
if (req) {
|
|
822
|
-
this.pendingPushRequests.push({ request: req, sent: false })
|
|
823
|
-
this.flushPendingPushRequests()
|
|
824
|
-
}
|
|
842
|
+
this.unsentChanges.nextPresence = nextPresence
|
|
843
|
+
this.sendUnsentChanges()
|
|
825
844
|
}
|
|
826
845
|
|
|
827
846
|
/** Push a change to the server, or stash it locally if we're offline */
|
|
828
847
|
private push(change: RecordsDiff<any>) {
|
|
829
848
|
this.debug('push', change)
|
|
830
|
-
|
|
831
|
-
//
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
if (!diff) return
|
|
838
|
-
|
|
839
|
-
// the change is not a no-op so we'll send it to the server
|
|
840
|
-
// but first let's merge the records diff into the speculative changes
|
|
841
|
-
this.speculativeChanges = squashRecordDiffs([this.speculativeChanges, change])
|
|
842
|
-
|
|
843
|
-
if (!this.isConnectedToRoom) {
|
|
844
|
-
// don't sent push requests or even store them up while offline
|
|
845
|
-
// when we come back online we'll generate another push request from
|
|
846
|
-
// scratch based on the speculativeChanges diff
|
|
847
|
-
return
|
|
849
|
+
squashRecordDiffsMutable(this.speculativeChanges, [change])
|
|
850
|
+
// in offline mode, we only accumulate in speculativeChanges
|
|
851
|
+
if (!this.isConnectedToRoom) return
|
|
852
|
+
if (!this.unsentChanges.nextDiff) {
|
|
853
|
+
this.unsentChanges.nextDiff = structuredClone(change)
|
|
854
|
+
} else {
|
|
855
|
+
squashRecordDiffsMutable(this.unsentChanges.nextDiff, [change])
|
|
848
856
|
}
|
|
849
|
-
|
|
850
|
-
const pushRequest: TLPushRequest<R> = {
|
|
851
|
-
type: 'push',
|
|
852
|
-
diff,
|
|
853
|
-
clientClock: this.clientClock++,
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
this.pendingPushRequests.push({ request: pushRequest, sent: false })
|
|
857
|
-
|
|
858
|
-
// immediately calling .send on the websocket here was causing some interaction
|
|
859
|
-
// slugishness when e.g. drawing or translating shapes. Seems like it blocks
|
|
860
|
-
// until the send completes. So instead we'll schedule a send to happen on some
|
|
861
|
-
// tick in the near future.
|
|
862
|
-
this.flushPendingPushRequests()
|
|
857
|
+
this.sendUnsentChanges()
|
|
863
858
|
}
|
|
864
859
|
|
|
865
860
|
/** Get the target FPS for network operations based on presence mode */
|
|
@@ -933,7 +928,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
933
928
|
if (this.pendingPushRequests.length === 0) {
|
|
934
929
|
throw new Error('Received push_result but there are no pending push requests')
|
|
935
930
|
}
|
|
936
|
-
if (this.pendingPushRequests[0].
|
|
931
|
+
if (this.pendingPushRequests[0].clientClock !== diff.clientClock) {
|
|
937
932
|
throw new Error(
|
|
938
933
|
'Received push_result for a push request that is not at the front of the queue'
|
|
939
934
|
)
|
|
@@ -941,7 +936,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
941
936
|
if (diff.action === 'discard') {
|
|
942
937
|
this.pendingPushRequests.shift()
|
|
943
938
|
} else if (diff.action === 'commit') {
|
|
944
|
-
const
|
|
939
|
+
const request = this.pendingPushRequests.shift()!
|
|
945
940
|
if ('diff' in request && request.diff) {
|
|
946
941
|
this.applyNetworkDiff(request.diff, true)
|
|
947
942
|
}
|
|
@@ -953,10 +948,14 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
953
948
|
// update the speculative diff while re-applying pending changes
|
|
954
949
|
try {
|
|
955
950
|
this.speculativeChanges = this.store.extractingChanges(() => {
|
|
956
|
-
for (const
|
|
951
|
+
for (const request of this.pendingPushRequests) {
|
|
957
952
|
if (!('diff' in request) || !request.diff) continue
|
|
958
953
|
this.applyNetworkDiff(request.diff, true)
|
|
959
954
|
}
|
|
955
|
+
if (!this.unsentChanges.nextDiff) return
|
|
956
|
+
const diff = getNetworkDiff(this.unsentChanges.nextDiff)
|
|
957
|
+
if (!diff) return
|
|
958
|
+
this.applyNetworkDiff(diff, true)
|
|
960
959
|
})
|
|
961
960
|
} catch (e) {
|
|
962
961
|
console.error(e)
|
package/src/test/FuzzEditor.ts
CHANGED
|
@@ -105,10 +105,12 @@ export class FuzzEditor extends RandomSource {
|
|
|
105
105
|
initialState: 'select',
|
|
106
106
|
store,
|
|
107
107
|
getContainer: () => document.createElement('div'),
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
108
|
+
options: {
|
|
109
|
+
text: {
|
|
110
|
+
addFontsFromNode: defaultAddFontsFromNode,
|
|
111
|
+
tipTapConfig: {
|
|
112
|
+
extensions: tipTapDefaultExtensions,
|
|
113
|
+
},
|
|
112
114
|
},
|
|
113
115
|
},
|
|
114
116
|
})
|