@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.
@@ -836,4 +836,595 @@ describe('TLSyncClient', () => {
836
836
  // Should track the latest server clock internally
837
837
  })
838
838
  })
839
+
840
+ describe('Offline and Reconnection Behavior', () => {
841
+ beforeEach(() => {
842
+ client = createClient()
843
+ socket.mockServerMessage(createConnectMessage())
844
+ socket.clearSentMessages()
845
+ })
846
+
847
+ it('does not send changes made while offline', () => {
848
+ // Go offline
849
+ socket.mockConnectionStatus('offline')
850
+ socket.clearSentMessages()
851
+
852
+ // Make changes while offline
853
+ const pageId = PageRecordType.createId()
854
+ store.put([
855
+ PageRecordType.create({
856
+ id: pageId,
857
+ name: 'Offline Page',
858
+ index: 'a1' as any,
859
+ }),
860
+ ])
861
+
862
+ vi.advanceTimersByTime(100)
863
+
864
+ // Should not have sent any messages
865
+ expect(socket.getSentMessages()).toHaveLength(0)
866
+
867
+ // But the page should exist locally
868
+ expect(store.has(pageId)).toBe(true)
869
+ })
870
+
871
+ it('re-applies offline changes after reconnection and pushes them to server', () => {
872
+ // Make a change while offline - this is a truly speculative change
873
+ socket.mockConnectionStatus('offline')
874
+ socket.clearSentMessages()
875
+
876
+ const pageId = PageRecordType.createId()
877
+ store.put([
878
+ PageRecordType.create({
879
+ id: pageId,
880
+ name: 'Offline Page',
881
+ index: 'a1' as any,
882
+ }),
883
+ ])
884
+ vi.advanceTimersByTime(100)
885
+
886
+ // Page exists locally, no messages sent (offline)
887
+ expect(store.has(pageId)).toBe(true)
888
+ expect(socket.getSentMessages()).toHaveLength(0)
889
+
890
+ // Come back online
891
+ socket.mockConnectionStatus('online')
892
+
893
+ // Get the connect message
894
+ const connectMsg = socket.getSentMessages().find((m) => m.type === 'connect')
895
+ expect(connectMsg).toBeDefined()
896
+
897
+ // Clear messages before server response
898
+ socket.clearSentMessages()
899
+
900
+ // Server responds with wipe_all (simulating fresh sync)
901
+ socket.mockServerMessage(
902
+ createConnectMessage({
903
+ connectRequestId: (connectMsg as any).connectRequestId,
904
+ hydrationType: 'wipe_all',
905
+ diff: {}, // Server has no pages
906
+ })
907
+ )
908
+ vi.advanceTimersByTime(100)
909
+
910
+ // The page should still exist locally
911
+ expect(store.has(pageId)).toBe(true)
912
+ expect(store.get(pageId)?.name).toBe('Offline Page')
913
+
914
+ // The speculative change should have been pushed to the server
915
+ const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
916
+ const pushWithPage = messages.find(
917
+ (msg) => msg.type === 'push' && msg.diff && msg.diff[pageId]
918
+ )
919
+ expect(pushWithPage).toBeDefined()
920
+ expect(pushWithPage!.diff![pageId][0]).toBe(RecordOpType.Put)
921
+ expect((pushWithPage!.diff![pageId][1] as any).name).toBe('Offline Page')
922
+ })
923
+ })
924
+
925
+ describe('Push Coalescing (Multiple push() calls → single network message)', () => {
926
+ /**
927
+ * These tests verify that multiple store changes (each triggering push())
928
+ * get coalesced into a single TLPushRequest when the throttle fires.
929
+ *
930
+ * We enable RAF mode so the throttle actually delays execution,
931
+ * allowing changes to accumulate before sending.
932
+ */
933
+
934
+ let rafCallbacks: Array<FrameRequestCallback>
935
+ let rafId: number
936
+
937
+ function flushOneRaf() {
938
+ if (rafCallbacks.length > 0) {
939
+ const callbacks = rafCallbacks.splice(0, rafCallbacks.length)
940
+ const now = performance.now()
941
+ for (const callback of callbacks) {
942
+ callback(now)
943
+ }
944
+ }
945
+ }
946
+
947
+ function flushThrottle() {
948
+ // FpsScheduler needs: advance time + flush RAF (potentially twice for tick + flush)
949
+ // Also need to clear any stale callbacks and keep flushing until stable
950
+ for (let i = 0; i < 10 && rafCallbacks.length > 0; i++) {
951
+ vi.advanceTimersByTime(100)
952
+ flushOneRaf()
953
+ }
954
+ }
955
+
956
+ beforeEach(() => {
957
+ // Reset timer to a known state to avoid pollution from previous tests
958
+ vi.setSystemTime(0)
959
+
960
+ // Force RAF behavior so throttle actually delays
961
+ // @ts-expect-error - testing flag
962
+ globalThis.__FORCE_RAF_IN_TESTS__ = true
963
+
964
+ rafCallbacks = []
965
+ rafId = 0
966
+
967
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
968
+ const id = ++rafId
969
+ rafCallbacks.push(callback)
970
+ return id
971
+ })
972
+
973
+ vi.stubGlobal('cancelAnimationFrame', (_id: number) => {})
974
+
975
+ // Create client with RAF mode enabled
976
+ client = createClient()
977
+ socket.mockServerMessage(createConnectMessage())
978
+
979
+ // Flush initial setup and clear
980
+ flushThrottle()
981
+ socket.clearSentMessages()
982
+ })
983
+
984
+ afterEach(() => {
985
+ // @ts-expect-error - testing flag
986
+ delete globalThis.__FORCE_RAF_IN_TESTS__
987
+ vi.unstubAllGlobals()
988
+ })
989
+
990
+ it('coalesces 5 store.put() calls into 1 push request', () => {
991
+ expect((client as any).isConnectedToRoom).toBe(true)
992
+
993
+ // Make 5 separate store changes synchronously
994
+ const pageIds: string[] = []
995
+ for (let i = 0; i < 5; i++) {
996
+ const pageId = PageRecordType.createId()
997
+ pageIds.push(pageId)
998
+ store.put([
999
+ PageRecordType.create({
1000
+ id: pageId,
1001
+ name: `Page ${i}`,
1002
+ index: `a${i}` as any,
1003
+ }),
1004
+ ])
1005
+ }
1006
+
1007
+ // Before throttle fires: no messages should be sent yet
1008
+ expect(socket.getSentMessages()).toHaveLength(0)
1009
+
1010
+ // Flush the throttle
1011
+ flushThrottle()
1012
+
1013
+ // Should have sent exactly ONE push request containing all 5 pages
1014
+ const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
1015
+ expect(messages).toHaveLength(1)
1016
+ expect(messages[0].type).toBe('push')
1017
+
1018
+ // The single message should contain all 5 page IDs
1019
+ const diff = messages[0].diff || {}
1020
+ expect(Object.keys(diff)).toHaveLength(5)
1021
+ for (const pageId of pageIds) {
1022
+ expect(diff[pageId]).toBeDefined()
1023
+ }
1024
+ })
1025
+
1026
+ it('coalesces create + multiple updates into 1 push with final state', () => {
1027
+ expect((client as any).isConnectedToRoom).toBe(true)
1028
+
1029
+ const pageId = PageRecordType.createId()
1030
+
1031
+ // Create and update the same record multiple times synchronously
1032
+ store.put([
1033
+ PageRecordType.create({
1034
+ id: pageId,
1035
+ name: 'Version 1',
1036
+ index: 'a1' as any,
1037
+ }),
1038
+ ])
1039
+ store.update(pageId, (p) => ({ ...p, name: 'Version 2' }))
1040
+ store.update(pageId, (p) => ({ ...p, name: 'Version 3' }))
1041
+ store.update(pageId, (p) => ({ ...p, name: 'Final Version' }))
1042
+
1043
+ // Before throttle fires: no messages
1044
+ expect(socket.getSentMessages()).toHaveLength(0)
1045
+
1046
+ // Flush the throttle
1047
+ flushThrottle()
1048
+
1049
+ // Should have exactly ONE push request
1050
+ const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
1051
+ expect(messages).toHaveLength(1)
1052
+
1053
+ // The diff should have the final state
1054
+ const diff = messages[0].diff!
1055
+ expect(diff[pageId]).toBeDefined()
1056
+ expect(diff[pageId][0]).toBe(RecordOpType.Put)
1057
+ expect((diff[pageId][1] as any).name).toBe('Final Version')
1058
+ })
1059
+
1060
+ it('coalesces create + delete into no diff (cancels out)', () => {
1061
+ const pageId = PageRecordType.createId()
1062
+
1063
+ // Create then immediately delete
1064
+ store.put([
1065
+ PageRecordType.create({
1066
+ id: pageId,
1067
+ name: 'Ephemeral',
1068
+ index: 'a1' as any,
1069
+ }),
1070
+ ])
1071
+ store.remove([pageId])
1072
+
1073
+ // Before throttle fires: no messages
1074
+ expect(socket.getSentMessages()).toHaveLength(0)
1075
+
1076
+ // Flush the throttle
1077
+ flushThrottle()
1078
+
1079
+ // Either no message, or message with empty/no diff for this page
1080
+ const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
1081
+ if (messages.length > 0) {
1082
+ const diff = messages[0].diff || {}
1083
+ // The page should NOT be in the diff (add + remove = no-op)
1084
+ expect(diff[pageId]).toBeUndefined()
1085
+ }
1086
+ })
1087
+
1088
+ it('coalesces multiple presence updates into 1 push with final presence (first is Put)', () => {
1089
+ // Multiple presence updates synchronously - first presence ever sent
1090
+ presence.set({
1091
+ id: 'presence:u1' as any,
1092
+ typeName: 'instance_presence',
1093
+ cursor: { x: 0, y: 0 },
1094
+ } as any)
1095
+ presence.set({
1096
+ id: 'presence:u1' as any,
1097
+ typeName: 'instance_presence',
1098
+ cursor: { x: 50, y: 50 },
1099
+ } as any)
1100
+ presence.set({
1101
+ id: 'presence:u1' as any,
1102
+ typeName: 'instance_presence',
1103
+ cursor: { x: 100, y: 100 },
1104
+ } as any)
1105
+
1106
+ // Before throttle fires: no messages
1107
+ expect(socket.getSentMessages()).toHaveLength(0)
1108
+
1109
+ // Flush the throttle
1110
+ flushThrottle()
1111
+
1112
+ // Should have exactly ONE push with final presence
1113
+ const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
1114
+ expect(messages).toHaveLength(1)
1115
+ expect(messages[0].presence).toBeDefined()
1116
+ // First presence is always a Put (full record)
1117
+ expect(messages[0].presence![0]).toBe(RecordOpType.Put)
1118
+ expect((messages[0].presence![1] as any).cursor).toEqual({ x: 100, y: 100 })
1119
+ })
1120
+
1121
+ it('sends subsequent presence updates as Patch after initial Put', () => {
1122
+ // Send initial presence
1123
+ presence.set({
1124
+ id: 'presence:u1' as any,
1125
+ typeName: 'instance_presence',
1126
+ cursor: { x: 0, y: 0 },
1127
+ } as any)
1128
+ flushThrottle()
1129
+
1130
+ // Verify first presence was a Put
1131
+ const firstMessages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
1132
+ expect(firstMessages).toHaveLength(1)
1133
+ expect(firstMessages[0].presence![0]).toBe(RecordOpType.Put)
1134
+
1135
+ socket.clearSentMessages()
1136
+
1137
+ // Now send multiple presence updates
1138
+ presence.set({
1139
+ id: 'presence:u1' as any,
1140
+ typeName: 'instance_presence',
1141
+ cursor: { x: 50, y: 50 },
1142
+ } as any)
1143
+ presence.set({
1144
+ id: 'presence:u1' as any,
1145
+ typeName: 'instance_presence',
1146
+ cursor: { x: 100, y: 100 },
1147
+ } as any)
1148
+
1149
+ // Before throttle fires: no messages
1150
+ expect(socket.getSentMessages()).toHaveLength(0)
1151
+
1152
+ // Flush the throttle
1153
+ flushThrottle()
1154
+
1155
+ // Should have exactly ONE push with final presence as a Patch
1156
+ const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
1157
+ expect(messages).toHaveLength(1)
1158
+ expect(messages[0].presence).toBeDefined()
1159
+ // Subsequent presence updates should be Patches (only changed fields)
1160
+ expect(messages[0].presence![0]).toBe(RecordOpType.Patch)
1161
+ // The patch should contain the cursor update
1162
+ expect((messages[0].presence![1] as any).cursor).toBeDefined()
1163
+ })
1164
+
1165
+ it('coalesces document changes + presence into 1 push request', () => {
1166
+ const pageId = PageRecordType.createId()
1167
+
1168
+ // Document change
1169
+ store.put([
1170
+ PageRecordType.create({
1171
+ id: pageId,
1172
+ name: 'Test Page',
1173
+ index: 'a1' as any,
1174
+ }),
1175
+ ])
1176
+
1177
+ // Presence change
1178
+ presence.set({
1179
+ id: 'presence:u1' as any,
1180
+ typeName: 'instance_presence',
1181
+ cursor: { x: 42, y: 42 },
1182
+ } as any)
1183
+
1184
+ // Before throttle fires: no messages
1185
+ expect(socket.getSentMessages()).toHaveLength(0)
1186
+
1187
+ // Flush the throttle
1188
+ flushThrottle()
1189
+
1190
+ // Should have exactly ONE push with both document and presence
1191
+ const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
1192
+ expect(messages).toHaveLength(1)
1193
+
1194
+ // Has document diff
1195
+ expect(messages[0].diff).toBeDefined()
1196
+ expect(messages[0].diff![pageId]).toBeDefined()
1197
+
1198
+ // Has presence
1199
+ expect(messages[0].presence).toBeDefined()
1200
+ })
1201
+ })
1202
+
1203
+ describe('Rebase Behavior', () => {
1204
+ beforeEach(() => {
1205
+ client = createClient()
1206
+ socket.mockServerMessage(createConnectMessage())
1207
+ socket.clearSentMessages()
1208
+ })
1209
+
1210
+ it('preserves local changes when receiving server patches for other records', () => {
1211
+ // Make a local change
1212
+ const pageId = PageRecordType.createId()
1213
+ store.put([
1214
+ PageRecordType.create({
1215
+ id: pageId,
1216
+ name: 'Local Page',
1217
+ index: 'a1' as any,
1218
+ }),
1219
+ ])
1220
+ vi.advanceTimersByTime(100)
1221
+
1222
+ // Receive a server patch for a different record
1223
+ const serverPageId = PageRecordType.createId()
1224
+ socket.mockServerMessage({
1225
+ type: 'data',
1226
+ data: [
1227
+ {
1228
+ type: 'patch',
1229
+ serverClock: 2,
1230
+ diff: {
1231
+ [serverPageId]: [
1232
+ RecordOpType.Put,
1233
+ PageRecordType.create({
1234
+ id: serverPageId,
1235
+ name: 'Server Page',
1236
+ index: 'a2' as any,
1237
+ }),
1238
+ ],
1239
+ },
1240
+ },
1241
+ ],
1242
+ })
1243
+ vi.advanceTimersByTime(100)
1244
+
1245
+ // Both local and server pages should coexist
1246
+ expect(store.has(pageId)).toBe(true)
1247
+ expect(store.get(pageId)?.name).toBe('Local Page')
1248
+ expect(store.has(serverPageId)).toBe(true)
1249
+ expect(store.get(serverPageId)?.name).toBe('Server Page')
1250
+ })
1251
+ })
1252
+
1253
+ describe('Solo Mode FPS Optimization', () => {
1254
+ it('does not send presence in solo mode', () => {
1255
+ presenceMode.set('solo')
1256
+ client = createClient()
1257
+ socket.mockServerMessage(createConnectMessage())
1258
+ socket.clearSentMessages()
1259
+
1260
+ const presenceRecord = {
1261
+ id: 'presence:user1' as any,
1262
+ typeName: 'instance_presence',
1263
+ cursor: { x: 100, y: 200 },
1264
+ } as TestRecord
1265
+ presence.set(presenceRecord)
1266
+
1267
+ vi.advanceTimersByTime(2000)
1268
+
1269
+ // Should not have sent any presence updates
1270
+ const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
1271
+ const presenceMessages = messages.filter((msg) => msg.presence)
1272
+ expect(presenceMessages).toHaveLength(0)
1273
+ })
1274
+
1275
+ it('still sends document changes in solo mode', () => {
1276
+ presenceMode.set('solo')
1277
+ client = createClient()
1278
+ socket.mockServerMessage(createConnectMessage())
1279
+ socket.clearSentMessages()
1280
+
1281
+ const pageId = PageRecordType.createId()
1282
+ store.put([
1283
+ PageRecordType.create({
1284
+ id: pageId,
1285
+ name: 'Solo Page',
1286
+ index: 'a1' as any,
1287
+ }),
1288
+ ])
1289
+
1290
+ vi.advanceTimersByTime(2000)
1291
+
1292
+ // Should have sent the document change
1293
+ const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
1294
+ const docMessages = messages.filter((msg) => msg.diff && Object.keys(msg.diff).length > 0)
1295
+ expect(docMessages.length).toBeGreaterThan(0)
1296
+ })
1297
+ })
1298
+
1299
+ describe('Edge Cases', () => {
1300
+ beforeEach(() => {
1301
+ client = createClient()
1302
+ socket.mockServerMessage(createConnectMessage())
1303
+ socket.clearSentMessages()
1304
+ })
1305
+
1306
+ it('handles update-then-delete of server record correctly', () => {
1307
+ // Create a page via server
1308
+ const pageId = PageRecordType.createId()
1309
+ socket.mockServerMessage({
1310
+ type: 'data',
1311
+ data: [
1312
+ {
1313
+ type: 'patch',
1314
+ serverClock: 2,
1315
+ diff: {
1316
+ [pageId]: [
1317
+ RecordOpType.Put,
1318
+ PageRecordType.create({
1319
+ id: pageId,
1320
+ name: 'Server Page',
1321
+ index: 'a1' as any,
1322
+ }),
1323
+ ],
1324
+ },
1325
+ },
1326
+ ],
1327
+ })
1328
+ vi.advanceTimersByTime(100)
1329
+ socket.clearSentMessages()
1330
+
1331
+ // Update then delete
1332
+ store.update(pageId, (p) => ({ ...p, name: 'Updated' }))
1333
+ vi.advanceTimersByTime(100)
1334
+ socket.clearSentMessages()
1335
+
1336
+ store.remove([pageId])
1337
+ vi.advanceTimersByTime(100)
1338
+
1339
+ // Page should not exist locally
1340
+ expect(store.has(pageId)).toBe(false)
1341
+
1342
+ // Should have sent a remove operation
1343
+ const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
1344
+ const removeMsg = messages.find(
1345
+ (msg) => msg.diff && msg.diff[pageId] && msg.diff[pageId][0] === RecordOpType.Remove
1346
+ )
1347
+ expect(removeMsg).toBeDefined()
1348
+ })
1349
+
1350
+ it('handles delete-then-recreate of server record with same ID', () => {
1351
+ // Create a page via server
1352
+ const pageId = PageRecordType.createId()
1353
+ socket.mockServerMessage({
1354
+ type: 'data',
1355
+ data: [
1356
+ {
1357
+ type: 'patch',
1358
+ serverClock: 2,
1359
+ diff: {
1360
+ [pageId]: [
1361
+ RecordOpType.Put,
1362
+ PageRecordType.create({
1363
+ id: pageId,
1364
+ name: 'Original',
1365
+ index: 'a1' as any,
1366
+ }),
1367
+ ],
1368
+ },
1369
+ },
1370
+ ],
1371
+ })
1372
+ vi.advanceTimersByTime(100)
1373
+ socket.clearSentMessages()
1374
+
1375
+ // Delete then recreate with same ID
1376
+ store.remove([pageId])
1377
+ store.put([
1378
+ PageRecordType.create({
1379
+ id: pageId,
1380
+ name: 'Recreated',
1381
+ index: 'a1' as any,
1382
+ }),
1383
+ ])
1384
+ vi.advanceTimersByTime(100)
1385
+
1386
+ // Page should exist with new name
1387
+ expect(store.has(pageId)).toBe(true)
1388
+ expect(store.get(pageId)?.name).toBe('Recreated')
1389
+ })
1390
+
1391
+ it('sends patches (not full puts) for updates to server records', () => {
1392
+ // Create a page via server
1393
+ const pageId = PageRecordType.createId()
1394
+ socket.mockServerMessage({
1395
+ type: 'data',
1396
+ data: [
1397
+ {
1398
+ type: 'patch',
1399
+ serverClock: 2,
1400
+ diff: {
1401
+ [pageId]: [
1402
+ RecordOpType.Put,
1403
+ PageRecordType.create({
1404
+ id: pageId,
1405
+ name: 'Server Name',
1406
+ index: 'a1' as any,
1407
+ }),
1408
+ ],
1409
+ },
1410
+ },
1411
+ ],
1412
+ })
1413
+ vi.advanceTimersByTime(100)
1414
+ socket.clearSentMessages()
1415
+
1416
+ // Local user updates only the name
1417
+ store.update(pageId, (p) => ({ ...p, name: 'Local Name' }))
1418
+ vi.advanceTimersByTime(100)
1419
+
1420
+ // Should send a Patch operation (not Put)
1421
+ const messages = socket.getSentMessages() as TLPushRequest<TestRecord>[]
1422
+ const pushWithPage = messages.find((msg) => msg.diff && msg.diff[pageId])
1423
+ expect(pushWithPage).toBeDefined()
1424
+
1425
+ const op = pushWithPage!.diff![pageId]
1426
+ expect(op[0]).toBe(RecordOpType.Patch)
1427
+ expect((op[1] as any).name).toBeDefined()
1428
+ })
1429
+ })
839
1430
  })