@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.
@@ -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
11
  FpsScheduler,
12
12
  exhaustiveSwitchError,
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,
@@ -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: { 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 }
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 flushPendingPushRequests: {
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.flushPendingPushRequests = this.fpsScheduler.fpsThrottle(() => {
468
- this.debug('flushing pending push requests', {
494
+ this.sendUnsentChanges = this.fpsScheduler.fpsThrottle(() => {
495
+ this.debug('sending unsent changes', {
469
496
  isConnectedToRoom: this.isConnectedToRoom,
470
- pendingPushRequests: this.pendingPushRequests,
497
+ unsentChanges: this.unsentChanges,
471
498
  })
472
499
  if (!this.isConnectedToRoom || this.store.isPossiblyCorrupted()) {
473
500
  return
474
501
  }
475
- for (const pendingPushRequest of this.pendingPushRequests) {
476
- if (!pendingPushRequest.sent) {
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.store.applyDiff(stashedChanges)
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.flushPendingPushRequests.cancel?.()
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
- let presence: TLPushRequest<any>['presence'] = undefined
792
- if (!this.lastPushedPresenceState && nextPresence) {
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
- // the Store doesn't do deep equality checks when making changes
831
- // so it's possible that the diff passed in here is actually a no-op.
832
- // either way, we also don't want to send whole objects over the wire if
833
- // only small parts of them have changed, so we'll do a shallow-ish diff
834
- // which also uses deep equality checks to see if the change is actually
835
- // a no-op.
836
- const diff = getNetworkDiff(change)
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].request.clientClock !== diff.clientClock) {
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 { request } = this.pendingPushRequests.shift()!
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 { request } of this.pendingPushRequests) {
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)
@@ -105,10 +105,12 @@ export class FuzzEditor extends RandomSource {
105
105
  initialState: 'select',
106
106
  store,
107
107
  getContainer: () => document.createElement('div'),
108
- textOptions: {
109
- addFontsFromNode: defaultAddFontsFromNode,
110
- tipTapConfig: {
111
- extensions: tipTapDefaultExtensions,
108
+ options: {
109
+ text: {
110
+ addFontsFromNode: defaultAddFontsFromNode,
111
+ tipTapConfig: {
112
+ extensions: tipTapDefaultExtensions,
113
+ },
112
114
  },
113
115
  },
114
116
  })