@tldraw/sync-core 4.3.0 → 4.4.0-canary.15cff7ea86f8
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/README.md +1 -1
- package/dist-cjs/index.d.ts +9 -3
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/TLSyncClient.js +81 -62
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-esm/index.d.mts +9 -3
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/TLSyncClient.mjs +90 -65
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/package.json +6 -6
- package/src/lib/TLSyncClient.test.ts +591 -0
- package/src/lib/TLSyncClient.ts +128 -97
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
|
+
FpsScheduler,
|
|
11
12
|
exhaustiveSwitchError,
|
|
12
|
-
fpsThrottle,
|
|
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,
|
|
@@ -35,6 +43,12 @@ import {
|
|
|
35
43
|
*/
|
|
36
44
|
export type SubscribingFn<T> = (cb: (val: T) => void) => () => void
|
|
37
45
|
|
|
46
|
+
/** Network sync frame rate when in solo mode (no collaborators) @internal */
|
|
47
|
+
const SOLO_MODE_FPS = 1
|
|
48
|
+
|
|
49
|
+
/** Network sync frame rate when in collaborative mode (with collaborators) @internal */
|
|
50
|
+
const COLLABORATIVE_MODE_FPS = 30
|
|
51
|
+
|
|
38
52
|
/**
|
|
39
53
|
* WebSocket close code used by the server to signal a non-recoverable sync error.
|
|
40
54
|
* This close code indicates that the connection is being terminated due to an error
|
|
@@ -275,6 +289,21 @@ const MAX_TIME_TO_WAIT_FOR_SERVER_INTERACTION_BEFORE_RESETTING_CONNECTION = PING
|
|
|
275
289
|
|
|
276
290
|
// Should connect support chunking the response to allow for large payloads?
|
|
277
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
|
+
|
|
278
307
|
/**
|
|
279
308
|
* Main client-side synchronization engine for collaborative tldraw applications.
|
|
280
309
|
*
|
|
@@ -340,7 +369,11 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
340
369
|
private lastServerInteractionTimestamp = Date.now()
|
|
341
370
|
|
|
342
371
|
/** The queue of in-flight push requests that have not yet been acknowledged by the server */
|
|
343
|
-
private pendingPushRequests:
|
|
372
|
+
private pendingPushRequests: TLPushRequest<R>[] = []
|
|
373
|
+
private unsentChanges: {
|
|
374
|
+
nextDiff?: RecordsDiff<R>
|
|
375
|
+
nextPresence?: R | null
|
|
376
|
+
} = { nextDiff: undefined, nextPresence: undefined }
|
|
344
377
|
|
|
345
378
|
/**
|
|
346
379
|
* The diff of 'unconfirmed', 'optimistic' changes that have been made locally by the user if we
|
|
@@ -355,6 +388,21 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
355
388
|
|
|
356
389
|
private disposables: Array<() => void> = []
|
|
357
390
|
|
|
391
|
+
/** Separate scheduler instance for network sync operations */
|
|
392
|
+
private readonly fpsScheduler: FpsScheduler
|
|
393
|
+
|
|
394
|
+
/** Send any unsent push requests to the server */
|
|
395
|
+
private readonly sendUnsentChanges: {
|
|
396
|
+
(): void
|
|
397
|
+
cancel?(): void
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Schedule a rebase operation */
|
|
401
|
+
private readonly scheduleRebase: {
|
|
402
|
+
(): void
|
|
403
|
+
cancel?(): void
|
|
404
|
+
}
|
|
405
|
+
|
|
358
406
|
/** @internal */
|
|
359
407
|
readonly store: S
|
|
360
408
|
/** @internal */
|
|
@@ -438,6 +486,54 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
438
486
|
|
|
439
487
|
this.presenceType = config.store.scopedTypes.presence.values().next().value ?? null
|
|
440
488
|
|
|
489
|
+
// Create a separate throttle instance for network sync operations
|
|
490
|
+
// This ensures sync operations have their own queue separate from UI operations
|
|
491
|
+
this.fpsScheduler = new FpsScheduler(COLLABORATIVE_MODE_FPS)
|
|
492
|
+
|
|
493
|
+
// Initialize throttled methods after throttle instance is created
|
|
494
|
+
this.sendUnsentChanges = this.fpsScheduler.fpsThrottle(() => {
|
|
495
|
+
this.debug('sending unsent changes', {
|
|
496
|
+
isConnectedToRoom: this.isConnectedToRoom,
|
|
497
|
+
unsentChanges: this.unsentChanges,
|
|
498
|
+
})
|
|
499
|
+
if (!this.isConnectedToRoom || this.store.isPossiblyCorrupted()) {
|
|
500
|
+
return
|
|
501
|
+
}
|
|
502
|
+
if (!this.unsentChanges.nextDiff && !this.unsentChanges.nextPresence) {
|
|
503
|
+
return
|
|
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
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
this.scheduleRebase = this.fpsScheduler.fpsThrottle(this.rebase)
|
|
536
|
+
|
|
441
537
|
if (typeof window !== 'undefined') {
|
|
442
538
|
;(window as any).tlsync = this
|
|
443
539
|
}
|
|
@@ -528,6 +624,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
528
624
|
react('pushPresence', () => {
|
|
529
625
|
if (this.didCancel?.()) return this.close()
|
|
530
626
|
const mode = this.presenceMode?.get()
|
|
627
|
+
this.fpsScheduler.updateTargetFps(this.getSyncFps())
|
|
531
628
|
if (mode !== 'full') return
|
|
532
629
|
this.pushPresence(this.presenceState!.get())
|
|
533
630
|
})
|
|
@@ -581,6 +678,8 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
581
678
|
this.isConnectedToRoom = false
|
|
582
679
|
this.pendingPushRequests = []
|
|
583
680
|
this.incomingDiffBuffer = []
|
|
681
|
+
this.unsentChanges.nextDiff = undefined
|
|
682
|
+
this.unsentChanges.nextPresence = undefined
|
|
584
683
|
if (this.socket.connectionStatus === 'online') {
|
|
585
684
|
this.socket.restart()
|
|
586
685
|
}
|
|
@@ -645,9 +744,11 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
645
744
|
|
|
646
745
|
// now re-apply the speculative changes creating a new push request with the
|
|
647
746
|
// appropriate diff
|
|
747
|
+
const networkDiff = getNetworkDiff(stashedChanges)
|
|
748
|
+
if (!networkDiff) return
|
|
648
749
|
const speculativeChanges = this.store.filterChangesByScope(
|
|
649
750
|
this.store.extractingChanges(() => {
|
|
650
|
-
this.
|
|
751
|
+
this.applyNetworkDiff(networkDiff, true)
|
|
651
752
|
}),
|
|
652
753
|
'document'
|
|
653
754
|
)
|
|
@@ -723,7 +824,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
723
824
|
close() {
|
|
724
825
|
this.debug('closing')
|
|
725
826
|
this.disposables.forEach((dispose) => dispose())
|
|
726
|
-
this.
|
|
827
|
+
this.sendUnsentChanges.cancel?.()
|
|
727
828
|
this.scheduleRebase.cancel?.()
|
|
728
829
|
}
|
|
729
830
|
|
|
@@ -738,100 +839,28 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
738
839
|
return
|
|
739
840
|
}
|
|
740
841
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
// we don't have a last presence state, so we need to push the full state
|
|
744
|
-
presence = [RecordOpType.Put, nextPresence]
|
|
745
|
-
} else if (this.lastPushedPresenceState && nextPresence) {
|
|
746
|
-
// we have a last presence state, so we need to push a diff if there is one
|
|
747
|
-
const diff = diffRecord(this.lastPushedPresenceState, nextPresence)
|
|
748
|
-
if (diff) {
|
|
749
|
-
presence = [RecordOpType.Patch, diff]
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
if (!presence) return
|
|
754
|
-
this.lastPushedPresenceState = nextPresence
|
|
755
|
-
|
|
756
|
-
// if there is a pending push that has not been sent and does not already include a presence update,
|
|
757
|
-
// then add this presence update to it
|
|
758
|
-
const lastPush = this.pendingPushRequests.at(-1)
|
|
759
|
-
if (lastPush && !lastPush.sent && !lastPush.request.presence) {
|
|
760
|
-
lastPush.request.presence = presence
|
|
761
|
-
return
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
// otherwise, create a new push request
|
|
765
|
-
const req: TLPushRequest<R> = {
|
|
766
|
-
type: 'push',
|
|
767
|
-
clientClock: this.clientClock++,
|
|
768
|
-
presence,
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
if (req) {
|
|
772
|
-
this.pendingPushRequests.push({ request: req, sent: false })
|
|
773
|
-
this.flushPendingPushRequests()
|
|
774
|
-
}
|
|
842
|
+
this.unsentChanges.nextPresence = nextPresence
|
|
843
|
+
this.sendUnsentChanges()
|
|
775
844
|
}
|
|
776
845
|
|
|
777
846
|
/** Push a change to the server, or stash it locally if we're offline */
|
|
778
847
|
private push(change: RecordsDiff<any>) {
|
|
779
848
|
this.debug('push', change)
|
|
780
|
-
|
|
781
|
-
//
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
if (!diff) return
|
|
788
|
-
|
|
789
|
-
// the change is not a no-op so we'll send it to the server
|
|
790
|
-
// but first let's merge the records diff into the speculative changes
|
|
791
|
-
this.speculativeChanges = squashRecordDiffs([this.speculativeChanges, change])
|
|
792
|
-
|
|
793
|
-
if (!this.isConnectedToRoom) {
|
|
794
|
-
// don't sent push requests or even store them up while offline
|
|
795
|
-
// when we come back online we'll generate another push request from
|
|
796
|
-
// scratch based on the speculativeChanges diff
|
|
797
|
-
return
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
const pushRequest: TLPushRequest<R> = {
|
|
801
|
-
type: 'push',
|
|
802
|
-
diff,
|
|
803
|
-
clientClock: this.clientClock++,
|
|
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])
|
|
804
856
|
}
|
|
805
|
-
|
|
806
|
-
this.pendingPushRequests.push({ request: pushRequest, sent: false })
|
|
807
|
-
|
|
808
|
-
// immediately calling .send on the websocket here was causing some interaction
|
|
809
|
-
// slugishness when e.g. drawing or translating shapes. Seems like it blocks
|
|
810
|
-
// until the send completes. So instead we'll schedule a send to happen on some
|
|
811
|
-
// tick in the near future.
|
|
812
|
-
this.flushPendingPushRequests()
|
|
857
|
+
this.sendUnsentChanges()
|
|
813
858
|
}
|
|
814
859
|
|
|
815
|
-
/**
|
|
816
|
-
private
|
|
817
|
-
this.
|
|
818
|
-
|
|
819
|
-
pendingPushRequests: this.pendingPushRequests,
|
|
820
|
-
})
|
|
821
|
-
if (!this.isConnectedToRoom || this.store.isPossiblyCorrupted()) {
|
|
822
|
-
return
|
|
823
|
-
}
|
|
824
|
-
for (const pendingPushRequest of this.pendingPushRequests) {
|
|
825
|
-
if (!pendingPushRequest.sent) {
|
|
826
|
-
if (this.socket.connectionStatus !== 'online') {
|
|
827
|
-
// we went offline, so don't send anything
|
|
828
|
-
return
|
|
829
|
-
}
|
|
830
|
-
this.socket.sendMessage(pendingPushRequest.request)
|
|
831
|
-
pendingPushRequest.sent = true
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
})
|
|
860
|
+
/** Get the target FPS for network operations based on presence mode */
|
|
861
|
+
private getSyncFps(): number {
|
|
862
|
+
return this.presenceMode?.get() === 'solo' ? SOLO_MODE_FPS : COLLABORATIVE_MODE_FPS
|
|
863
|
+
}
|
|
835
864
|
|
|
836
865
|
/**
|
|
837
866
|
* Applies a 'network' diff to the store this does value-based equality checking so that if the
|
|
@@ -899,7 +928,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
899
928
|
if (this.pendingPushRequests.length === 0) {
|
|
900
929
|
throw new Error('Received push_result but there are no pending push requests')
|
|
901
930
|
}
|
|
902
|
-
if (this.pendingPushRequests[0].
|
|
931
|
+
if (this.pendingPushRequests[0].clientClock !== diff.clientClock) {
|
|
903
932
|
throw new Error(
|
|
904
933
|
'Received push_result for a push request that is not at the front of the queue'
|
|
905
934
|
)
|
|
@@ -907,7 +936,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
907
936
|
if (diff.action === 'discard') {
|
|
908
937
|
this.pendingPushRequests.shift()
|
|
909
938
|
} else if (diff.action === 'commit') {
|
|
910
|
-
const
|
|
939
|
+
const request = this.pendingPushRequests.shift()!
|
|
911
940
|
if ('diff' in request && request.diff) {
|
|
912
941
|
this.applyNetworkDiff(request.diff, true)
|
|
913
942
|
}
|
|
@@ -919,10 +948,14 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
919
948
|
// update the speculative diff while re-applying pending changes
|
|
920
949
|
try {
|
|
921
950
|
this.speculativeChanges = this.store.extractingChanges(() => {
|
|
922
|
-
for (const
|
|
951
|
+
for (const request of this.pendingPushRequests) {
|
|
923
952
|
if (!('diff' in request) || !request.diff) continue
|
|
924
953
|
this.applyNetworkDiff(request.diff, true)
|
|
925
954
|
}
|
|
955
|
+
if (!this.unsentChanges.nextDiff) return
|
|
956
|
+
const diff = getNetworkDiff(this.unsentChanges.nextDiff)
|
|
957
|
+
if (!diff) return
|
|
958
|
+
this.applyNetworkDiff(diff, true)
|
|
926
959
|
})
|
|
927
960
|
} catch (e) {
|
|
928
961
|
console.error(e)
|
|
@@ -938,6 +971,4 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
938
971
|
this.resetConnection()
|
|
939
972
|
}
|
|
940
973
|
}
|
|
941
|
-
|
|
942
|
-
private scheduleRebase = fpsThrottle(this.rebase)
|
|
943
974
|
}
|