@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.
@@ -5,16 +5,24 @@ import {
5
5
  Store,
6
6
  UnknownRecord,
7
7
  reverseRecordsDiff,
8
- squashRecordDiffs,
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 { NetworkDiff, RecordOpType, applyObjectDiff, diffRecord, getNetworkDiff } from './diff'
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: { request: TLPushRequest<R>; sent: boolean }[] = []
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.store.applyDiff(stashedChanges)
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.flushPendingPushRequests.cancel?.()
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
- let presence: TLPushRequest<any>['presence'] = undefined
742
- if (!this.lastPushedPresenceState && nextPresence) {
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
- // the Store doesn't do deep equality checks when making changes
781
- // so it's possible that the diff passed in here is actually a no-op.
782
- // either way, we also don't want to send whole objects over the wire if
783
- // only small parts of them have changed, so we'll do a shallow-ish diff
784
- // which also uses deep equality checks to see if the change is actually
785
- // a no-op.
786
- const diff = getNetworkDiff(change)
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
- /** Send any unsent push requests to the server */
816
- private flushPendingPushRequests = fpsThrottle(() => {
817
- this.debug('flushing pending push requests', {
818
- isConnectedToRoom: this.isConnectedToRoom,
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].request.clientClock !== diff.clientClock) {
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 { request } = this.pendingPushRequests.shift()!
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 { request } of this.pendingPushRequests) {
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
  }