@tldraw/sync-core 5.6.0-next.c4e9c54a816e → 5.6.0-next.f151f7f3ebd6

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.
@@ -355,10 +355,12 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
355
355
  ) {
356
356
  const { sessionId, socket, isReadonly = false, objectAccess = 'write' } = opts
357
357
  const handleSocketMessage = (event: MessageEvent) =>
358
- this.handleSocketMessage(sessionId, event.data)
359
- const handleSocketError = this.handleSocketError.bind(this, sessionId)
360
- const handleSocketClose = this.handleSocketClose.bind(this, sessionId)
358
+ this.handleSocketMessage(sessionId, event.data, socket)
359
+ const handleSocketError = () => this.handleSocketError(sessionId, socket)
360
+ const handleSocketClose = () => this.handleSocketClose(sessionId, socket)
361
361
 
362
+ // A reconnect under the same session id can leave the old socket open.
363
+ this.sessions.get(sessionId)?.unlisten()
362
364
  this.sessions.set(sessionId, {
363
365
  assembler: new JsonChunkAssembler(),
364
366
  socket,
@@ -422,8 +424,14 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
422
424
  * The method handles message chunking/reassembly and forwards complete messages
423
425
  * to the underlying sync room for processing.
424
426
  *
427
+ * Pass the socket the message came from whenever you have it. A client that reconnects under
428
+ * the same session id can leave its previous socket delivering messages for a while; with the
429
+ * socket given, those are dropped instead of being spliced into the new socket's chunked
430
+ * messages.
431
+ *
425
432
  * @param sessionId - Session identifier matching the one used in handleSocketConnect
426
433
  * @param message - Raw message data from the client (string or binary)
434
+ * @param socket - The socket the message arrived on, when known
427
435
  *
428
436
  * @example
429
437
  * ```ts
@@ -435,17 +443,30 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
435
443
  * },
436
444
  * message(ws, message) {
437
445
  * const { sessionId, room } = ws.data
438
- * room.handleSocketMessage(sessionId, message)
446
+ * room.handleSocketMessage(sessionId, message, ws)
439
447
  * }
440
448
  * })
441
449
  * ```
442
450
  */
443
- handleSocketMessage(sessionId: string, message: string | AllowSharedBufferSource) {
444
- const assembler = this.sessions.get(sessionId)?.assembler
445
- if (!assembler) {
451
+ handleSocketMessage(
452
+ sessionId: string,
453
+ message: string | AllowSharedBufferSource,
454
+ socket?: WebSocketMinimal
455
+ ) {
456
+ const entry = this.sessions.get(sessionId)
457
+ if (!entry) {
446
458
  this.log?.warn?.('Received message from unknown session', sessionId)
447
459
  return
448
460
  }
461
+ // Each socket carries its own chunk sequence, but the assembler belongs to the session id.
462
+ // Feeding a superseded socket's in-flight chunks into the replacement's assembler breaks
463
+ // both messages ("Unexpected non-chunk message", "Chunks received in wrong order", or a
464
+ // lone tail chunk parsed as JSON) — see #10132.
465
+ if (socket && entry.socket !== socket) {
466
+ this.log?.warn?.('Ignoring message from superseded socket', sessionId)
467
+ return
468
+ }
469
+ const assembler = entry.assembler
449
470
 
450
471
  try {
451
472
  const messageString =
@@ -500,17 +521,22 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
500
521
  * where socket event listeners cannot be attached directly. This will initiate cleanup
501
522
  * and session removal for the affected client.
502
523
  *
524
+ * Pass the socket the error came from whenever you have it, so a superseded socket's error
525
+ * after a same-id reconnect doesn't cancel the replacement session.
526
+ *
503
527
  * @param sessionId - Session identifier matching the one used in handleSocketConnect
528
+ * @param socket - The socket the error came from, when known
504
529
  *
505
530
  * @example
506
531
  * ```ts
507
532
  * // In a custom WebSocket handler
508
533
  * socket.addEventListener('error', () => {
509
- * room.handleSocketError(sessionId)
534
+ * room.handleSocketError(sessionId, socket)
510
535
  * })
511
536
  * ```
512
537
  */
513
- handleSocketError(sessionId: string) {
538
+ handleSocketError(sessionId: string, socket?: WebSocketMinimal) {
539
+ if (this.isSupersededSocket(sessionId, socket)) return
514
540
  this.clearSnapshotTimer(sessionId)
515
541
  this.room.handleClose(sessionId)
516
542
  }
@@ -520,21 +546,33 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
520
546
  * environments where socket event listeners cannot be attached directly. This will
521
547
  * initiate cleanup and session removal for the disconnected client.
522
548
  *
549
+ * Pass the socket the close came from whenever you have it. A client that reconnects under the
550
+ * same session id can leave its previous socket half-open, and that socket's late close would
551
+ * otherwise cancel the new, live session.
552
+ *
523
553
  * @param sessionId - Session identifier matching the one used in handleSocketConnect
554
+ * @param socket - The socket that closed, when known
524
555
  *
525
556
  * @example
526
557
  * ```ts
527
558
  * // In a custom WebSocket handler
528
559
  * socket.addEventListener('close', () => {
529
- * room.handleSocketClose(sessionId)
560
+ * room.handleSocketClose(sessionId, socket)
530
561
  * })
531
562
  * ```
532
563
  */
533
- handleSocketClose(sessionId: string) {
564
+ handleSocketClose(sessionId: string, socket?: WebSocketMinimal) {
565
+ if (this.isSupersededSocket(sessionId, socket)) return
534
566
  this.clearSnapshotTimer(sessionId)
535
567
  this.room.handleClose(sessionId)
536
568
  }
537
569
 
570
+ private isSupersededSocket(sessionId: string, socket: WebSocketMinimal | undefined) {
571
+ if (!socket) return false
572
+ const entry = this.sessions.get(sessionId)
573
+ return !!entry && entry.socket !== socket
574
+ }
575
+
538
576
  /**
539
577
  * Resumes a previously-connected session directly into `Connected` state, bypassing
540
578
  * the connect handshake. Use this after server hibernation (e.g., Cloudflare Durable
@@ -576,6 +614,7 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
576
614
  ) {
577
615
  const { sessionId, socket, snapshot } = opts
578
616
 
617
+ this.sessions.get(sessionId)?.unlisten()
579
618
  this.sessions.set(sessionId, {
580
619
  assembler: new JsonChunkAssembler(),
581
620
  socket,
@@ -375,6 +375,67 @@ describe('TLSyncClient', () => {
375
375
  expect(pushWithPage!.diff![localPage.id][0]).toBe(RecordOpType.Put)
376
376
  })
377
377
 
378
+ it('[CL5] a reconnect flushes pending store history first, so a change queued behind the frame throttle is neither lost nor resurrected', () => {
379
+ connectClient()
380
+
381
+ socket.mockConnectionStatus('offline')
382
+ const localPage = makePage('Offline Page')
383
+ store.put([localPage]) // reaches speculativeChanges
384
+
385
+ // In the browser the store delivers history on the next animation frame, so the client's
386
+ // view of speculative changes can lag the store. Hold the next flush back the same way.
387
+ const flushHistory = store._flushHistory
388
+ store._flushHistory = () => {}
389
+ store.remove([localPage.id]) // ...so this delete is still queued when we reconnect
390
+ store._flushHistory = flushHistory
391
+
392
+ socket.mockConnectionStatus('online')
393
+ socket.mockServerMessage(
394
+ createConnectMessage({ hydrationType: 'wipe_presence', serverClock: 2, diff: {} })
395
+ )
396
+ vi.advanceTimersByTime(100)
397
+
398
+ // the user deleted the page: it must not come back, and must not be pushed as a put
399
+ expect(store.get(localPage.id)).toBeUndefined()
400
+ expect(
401
+ getSentPushes().some((msg) => msg.diff?.[localPage.id]?.[0] === RecordOpType.Put)
402
+ ).toBe(false)
403
+ })
404
+
405
+ it('[CL5] a store listener that throws during the reconnect flush does not stall the connect', () => {
406
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
407
+ try {
408
+ client = createClient()
409
+ let listenerThrown = false
410
+ store.listen(() => {
411
+ if (listenerThrown) return
412
+ listenerThrown = true
413
+ throw new Error('listener error')
414
+ })
415
+
416
+ // queue a local change behind the frame throttle so the connect flush delivers it
417
+ const flushHistory = store._flushHistory
418
+ store._flushHistory = () => {}
419
+ const localPage = makePage('Local Page', 'a5')
420
+ store.put([localPage])
421
+ store._flushHistory = flushHistory
422
+
423
+ const serverDiff = documentScopeDiff()
424
+ delete serverDiff[localPage.id] // the server never saw it
425
+ socket.mockServerMessage(createConnectMessage({ diff: serverDiff }))
426
+ vi.advanceTimersByTime(100)
427
+
428
+ expect(client.isConnectedToRoom).toBe(true)
429
+ expect(onLoad).toHaveBeenCalled()
430
+ expect(store.get(localPage.id)).toEqual(localPage)
431
+ expect(
432
+ getSentPushes().some((msg) => msg.diff?.[localPage.id]?.[0] === RecordOpType.Put)
433
+ ).toBe(true)
434
+ } finally {
435
+ consoleSpy.mockRestore()
436
+ }
437
+ })
438
+
378
439
  it('[CL6] wipe_all reconnect wipes document records before applying the server diff', () => {
379
440
  client = createClient()
380
441
 
@@ -754,6 +754,15 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
754
754
  this.resetConnection(true)
755
755
  return
756
756
  }
757
+ // Local changes reach `push` (and so `speculativeChanges`) on the store's frame-throttled
758
+ // history flush. Anything still queued there would be neither reverted below nor
759
+ // re-applied on top of the server state, so bring speculativeChanges up to date first.
760
+ try {
761
+ this.store._flushHistory()
762
+ } catch (e) {
763
+ console.error(e)
764
+ }
765
+
757
766
  // at the end of this process we want to have at most one pending push request
758
767
  // based on anything inside this.speculativeChanges
759
768
  transact(() => {
@@ -21,7 +21,7 @@ import {
21
21
  ZERO_INDEX_KEY,
22
22
  } from 'tldraw'
23
23
  import { beforeEach, describe, expect, it, vi } from 'vitest'
24
- import { MAX_ASSEMBLED_MESSAGE_CHARS } from '../lib/chunk'
24
+ import { chunk, MAX_ASSEMBLED_MESSAGE_CHARS } from '../lib/chunk'
25
25
  import { RecordOpType } from '../lib/diff'
26
26
  import { DEFAULT_INITIAL_SNAPSHOT, InMemorySyncStorage } from '../lib/InMemorySyncStorage'
27
27
  import { getTlsyncProtocolVersion } from '../lib/protocol'
@@ -454,6 +454,75 @@ describe('28. TLSocketRoom (SR)', () => {
454
454
  expect(room.getSessions()).toHaveLength(0)
455
455
  })
456
456
 
457
+ it('[SR5] ignores chunks from a superseded socket so they do not corrupt the replacement socket', () => {
458
+ const log: TLSyncLog = { warn: vi.fn(), error: vi.fn() }
459
+ const room = new TLSocketRoom({ log })
460
+ const oldSocket = createMockSocket()
461
+ connectSession(room, 'S', oldSocket)
462
+ // the client reconnects under the same session id while the old socket is still draining
463
+ const newSocket = createMockSocket()
464
+ connectSession(room, 'S', newSocket)
465
+ vi.mocked(newSocket.send).mockClear()
466
+
467
+ const oldChunks = chunk(JSON.stringify({ type: 'ping' }), 4)
468
+ const newChunks = chunk(JSON.stringify({ type: 'ping' }), 5)
469
+ expect(oldChunks.length).toBeGreaterThan(1)
470
+ expect(newChunks.length).toBeGreaterThan(1)
471
+ // interleave the two sockets' chunk sequences, as a hibernation host delivers them
472
+ for (let i = 0; i < Math.max(oldChunks.length, newChunks.length); i++) {
473
+ if (oldChunks[i]) room.handleSocketMessage('S', oldChunks[i], oldSocket)
474
+ if (newChunks[i]) room.handleSocketMessage('S', newChunks[i], newSocket)
475
+ }
476
+ // a lone complete message from the old socket mid-sequence is ignored too
477
+ room.handleSocketMessage('S', JSON.stringify({ type: 'ping' }), oldSocket)
478
+
479
+ expect(log.error).not.toHaveBeenCalled()
480
+ expect(newSocket.close).not.toHaveBeenCalled()
481
+ expect(room.getSessions()).toEqual([
482
+ expect.objectContaining({ sessionId: 'S', isConnected: true }),
483
+ ])
484
+ expect(vi.mocked(newSocket.send).mock.calls.map((c) => JSON.parse(c[0]).type)).toEqual([
485
+ 'pong',
486
+ ])
487
+ })
488
+
489
+ it("[SR4][SR5] the listeners attached by handleSocketConnect drop a superseded socket's messages", () => {
490
+ function createListeningSocket() {
491
+ const listeners: Record<string, ((e: any) => void)[]> = {}
492
+ const socket: WebSocketMinimal & { fire(type: string, data: string): void } = {
493
+ send: vi.fn(),
494
+ close: vi.fn(),
495
+ readyState: WebSocket.OPEN,
496
+ addEventListener: vi.fn((type: string, fn: (e: any) => void) => {
497
+ ;(listeners[type] ??= []).push(fn)
498
+ }),
499
+ removeEventListener: vi.fn(),
500
+ fire(type, data) {
501
+ for (const fn of listeners[type] ?? []) fn({ data })
502
+ },
503
+ }
504
+ return socket
505
+ }
506
+ const log: TLSyncLog = { warn: vi.fn(), error: vi.fn() }
507
+ const room = new TLSocketRoom({ log })
508
+ const oldSocket = createListeningSocket()
509
+ connectSession(room, 'S', oldSocket)
510
+ const newSocket = createListeningSocket()
511
+ connectSession(room, 'S', newSocket)
512
+
513
+ const [first, ...rest] = chunk(JSON.stringify({ type: 'ping' }), 5)
514
+ newSocket.fire('message', first)
515
+ // the old socket's tail chunk would otherwise be assembled onto the new socket's sequence
516
+ oldSocket.fire('message', '0_kPAAAjzgAA')
517
+ for (const c of rest) newSocket.fire('message', c)
518
+
519
+ expect(log.error).not.toHaveBeenCalled()
520
+ expect(newSocket.close).not.toHaveBeenCalled()
521
+ expect(vi.mocked(newSocket.send).mock.calls.map((c) => JSON.parse(c[0]).type)).toContain(
522
+ 'pong'
523
+ )
524
+ })
525
+
457
526
  it('[SR5] warns when receiving a message from an unknown session', () => {
458
527
  const log: TLSyncLog = { warn: vi.fn(), error: vi.fn() }
459
528
  const room = new TLSocketRoom({ log })
@@ -585,6 +654,68 @@ describe('28. TLSocketRoom (SR)', () => {
585
654
  expect(room.getSessions()[0].isConnected).toBe(false)
586
655
  expect(socket.close).toHaveBeenCalled()
587
656
  })
657
+
658
+ it.each(['handleSocketClose', 'handleSocketError'] as const)(
659
+ "[SR6][SR12] %s ignores a superseded socket's late event after a same-id reconnect",
660
+ (method) => {
661
+ vi.useFakeTimers()
662
+ try {
663
+ const onSessionSnapshot = vi.fn()
664
+ const room = new TLSocketRoom({ onSessionSnapshot })
665
+ const oldSocket = createMockSocket()
666
+ connectSession(room, 'S', oldSocket)
667
+ const newSocket = createMockSocket()
668
+ connectSession(room, 'S', newSocket)
669
+
670
+ room[method]('S', oldSocket)
671
+
672
+ expect(room.getSessions()).toEqual([
673
+ expect.objectContaining({ sessionId: 'S', isConnected: true }),
674
+ ])
675
+ expect(newSocket.close).not.toHaveBeenCalled()
676
+ // the live session's pending snapshot survives the superseded event
677
+ vi.advanceTimersByTime(5100)
678
+ expect(onSessionSnapshot).toHaveBeenCalledTimes(1)
679
+
680
+ room[method]('S', newSocket)
681
+ expect(room.getSessions()[0].isConnected).toBe(false)
682
+ } finally {
683
+ vi.useRealTimers()
684
+ }
685
+ }
686
+ )
687
+
688
+ it("[SR4][SR6] a reconnect detaches the previous socket's listeners", () => {
689
+ const listeners = new Map<string, (e: any) => void>()
690
+ const oldSocket = createMockSocket({
691
+ addEventListener: vi.fn((type: string, fn: (e: any) => void) => listeners.set(type, fn)),
692
+ removeEventListener: vi.fn((type: string, fn: (e: any) => void) => {
693
+ if (listeners.get(type) === fn) listeners.delete(type)
694
+ }),
695
+ })
696
+ connectSession(room, 'S', oldSocket)
697
+ expect([...listeners.keys()].sort()).toEqual(['close', 'error', 'message'])
698
+
699
+ connectSession(room, 'S', createMockSocket())
700
+
701
+ expect(listeners.size).toBe(0)
702
+ })
703
+
704
+ it.each(['close', 'error'] as const)(
705
+ '[SR4][SR6] firing the attached %s listener cancels the session',
706
+ (type) => {
707
+ const listeners = new Map<string, (e: any) => void>()
708
+ const socket = createMockSocket({
709
+ addEventListener: vi.fn((t: string, fn: (e: any) => void) => listeners.set(t, fn)),
710
+ })
711
+ connectSession(room, 'S', socket)
712
+
713
+ listeners.get(type)!(new Event(type))
714
+
715
+ expect(room.getSessions()[0].isConnected).toBe(false)
716
+ expect(socket.close).toHaveBeenCalled()
717
+ }
718
+ )
588
719
  })
589
720
 
590
721
  describe('session reporting', () => {