@tldraw/sync-core 4.2.0-canary.d26849279326 → 4.2.0-canary.d2b6b1219ae5

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.
Files changed (40) hide show
  1. package/dist-cjs/index.d.ts +66 -2
  2. package/dist-cjs/index.js +2 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/RoomSession.js.map +1 -1
  5. package/dist-cjs/lib/TLSyncClient.js +2 -8
  6. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  7. package/dist-cjs/lib/TLSyncRoom.js +35 -9
  8. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  9. package/dist-cjs/lib/chunk.js +4 -4
  10. package/dist-cjs/lib/chunk.js.map +1 -1
  11. package/dist-cjs/lib/diff.js +29 -29
  12. package/dist-cjs/lib/diff.js.map +2 -2
  13. package/dist-cjs/lib/protocol.js +1 -1
  14. package/dist-cjs/lib/protocol.js.map +1 -1
  15. package/dist-esm/index.d.mts +66 -2
  16. package/dist-esm/index.mjs +3 -2
  17. package/dist-esm/index.mjs.map +2 -2
  18. package/dist-esm/lib/RoomSession.mjs.map +1 -1
  19. package/dist-esm/lib/TLSyncClient.mjs +2 -8
  20. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  21. package/dist-esm/lib/TLSyncRoom.mjs +35 -9
  22. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  23. package/dist-esm/lib/chunk.mjs +4 -4
  24. package/dist-esm/lib/chunk.mjs.map +1 -1
  25. package/dist-esm/lib/diff.mjs +29 -29
  26. package/dist-esm/lib/diff.mjs.map +2 -2
  27. package/dist-esm/lib/protocol.mjs +1 -1
  28. package/dist-esm/lib/protocol.mjs.map +1 -1
  29. package/package.json +6 -6
  30. package/src/index.ts +2 -2
  31. package/src/lib/RoomSession.test.ts +3 -0
  32. package/src/lib/RoomSession.ts +28 -42
  33. package/src/lib/TLSyncClient.ts +2 -13
  34. package/src/lib/TLSyncRoom.ts +42 -7
  35. package/src/lib/chunk.ts +4 -4
  36. package/src/lib/diff.ts +55 -32
  37. package/src/lib/protocol.ts +1 -1
  38. package/src/test/TLSocketRoom.test.ts +2 -2
  39. package/src/test/TLSyncRoom.test.ts +22 -21
  40. package/src/test/diff.test.ts +200 -0
package/src/lib/diff.ts CHANGED
@@ -123,11 +123,11 @@ export type ValueOpType = (typeof ValueOpType)[keyof typeof ValueOpType]
123
123
  */
124
124
  export type PutOp = [type: typeof ValueOpType.Put, value: unknown]
125
125
  /**
126
- * Operation that appends new values to the end of an array.
126
+ * Operation that appends new values to the end of an array or string.
127
127
  *
128
128
  * @internal
129
129
  */
130
- export type AppendOp = [type: typeof ValueOpType.Append, values: unknown[], offset: number]
130
+ export type AppendOp = [type: typeof ValueOpType.Append, value: unknown[] | string, offset: number]
131
131
  /**
132
132
  * Operation that applies a nested diff to an object or array.
133
133
  *
@@ -165,6 +165,7 @@ export interface ObjectDiff {
165
165
  *
166
166
  * @param prev - The previous version of the record
167
167
  * @param next - The next version of the record
168
+ * @param legacyAppendMode - If true, string append operations will be converted to Put operations
168
169
  * @returns An ObjectDiff describing the changes, or null if no changes exist
169
170
  *
170
171
  * @example
@@ -181,11 +182,20 @@ export interface ObjectDiff {
181
182
  *
182
183
  * @internal
183
184
  */
184
- export function diffRecord(prev: object, next: object): ObjectDiff | null {
185
- return diffObject(prev, next, new Set(['props']))
185
+ export function diffRecord(
186
+ prev: object,
187
+ next: object,
188
+ legacyAppendMode = false
189
+ ): ObjectDiff | null {
190
+ return diffObject(prev, next, new Set(['props', 'meta']), legacyAppendMode)
186
191
  }
187
192
 
188
- function diffObject(prev: object, next: object, nestedKeys?: Set<string>): ObjectDiff | null {
193
+ function diffObject(
194
+ prev: object,
195
+ next: object,
196
+ nestedKeys: Set<string> | undefined,
197
+ legacyAppendMode: boolean
198
+ ): ObjectDiff | null {
189
199
  if (prev === next) {
190
200
  return null
191
201
  }
@@ -197,26 +207,22 @@ function diffObject(prev: object, next: object, nestedKeys?: Set<string>): Objec
197
207
  result[key] = [ValueOpType.Delete]
198
208
  continue
199
209
  }
200
- // if key is in both places, then compare values
201
- const prevVal = (prev as any)[key]
202
- const nextVal = (next as any)[key]
203
- if (!isEqual(prevVal, nextVal)) {
204
- if (nestedKeys?.has(key) && prevVal && nextVal) {
205
- const diff = diffObject(prevVal, nextVal)
206
- if (diff) {
207
- if (!result) result = {}
208
- result[key] = [ValueOpType.Patch, diff]
209
- }
210
- } else if (Array.isArray(nextVal) && Array.isArray(prevVal)) {
211
- const op = diffArray(prevVal, nextVal)
212
- if (op) {
213
- if (!result) result = {}
214
- result[key] = op
215
- }
216
- } else {
210
+ const prevValue = (prev as any)[key]
211
+ const nextValue = (next as any)[key]
212
+ if (
213
+ nestedKeys?.has(key) ||
214
+ (Array.isArray(prevValue) && Array.isArray(nextValue)) ||
215
+ (typeof prevValue === 'string' && typeof nextValue === 'string')
216
+ ) {
217
+ // if key is in both places, then compare values
218
+ const diff = diffValue(prevValue, nextValue, legacyAppendMode)
219
+ if (diff) {
217
220
  if (!result) result = {}
218
- result[key] = [ValueOpType.Put, nextVal]
221
+ result[key] = diff
219
222
  }
223
+ } else if (!isEqual(prevValue, nextValue)) {
224
+ if (!result) result = {}
225
+ result[key] = [ValueOpType.Put, nextValue]
220
226
  }
221
227
  }
222
228
  for (const key of Object.keys(next)) {
@@ -229,19 +235,29 @@ function diffObject(prev: object, next: object, nestedKeys?: Set<string>): Objec
229
235
  return result
230
236
  }
231
237
 
232
- function diffValue(valueA: unknown, valueB: unknown): ValueOp | null {
238
+ function diffValue(valueA: unknown, valueB: unknown, legacyAppendMode: boolean): ValueOp | null {
233
239
  if (Object.is(valueA, valueB)) return null
234
240
  if (Array.isArray(valueA) && Array.isArray(valueB)) {
235
- return diffArray(valueA, valueB)
241
+ return diffArray(valueA, valueB, legacyAppendMode)
242
+ } else if (typeof valueA === 'string' && typeof valueB === 'string') {
243
+ if (!legacyAppendMode && valueB.startsWith(valueA)) {
244
+ const appendedText = valueB.slice(valueA.length)
245
+ return [ValueOpType.Append, appendedText, valueA.length]
246
+ }
247
+ return [ValueOpType.Put, valueB]
236
248
  } else if (!valueA || !valueB || typeof valueA !== 'object' || typeof valueB !== 'object') {
237
249
  return isEqual(valueA, valueB) ? null : [ValueOpType.Put, valueB]
238
250
  } else {
239
- const diff = diffObject(valueA, valueB)
251
+ const diff = diffObject(valueA, valueB, undefined, legacyAppendMode)
240
252
  return diff ? [ValueOpType.Patch, diff] : null
241
253
  }
242
254
  }
243
255
 
244
- function diffArray(prevArray: unknown[], nextArray: unknown[]): PutOp | AppendOp | PatchOp | null {
256
+ function diffArray(
257
+ prevArray: unknown[],
258
+ nextArray: unknown[],
259
+ legacyAppendMode: boolean
260
+ ): PutOp | AppendOp | PatchOp | null {
245
261
  if (Object.is(prevArray, nextArray)) return null
246
262
  // if lengths are equal, check for patch operation
247
263
  if (prevArray.length === nextArray.length) {
@@ -267,7 +283,7 @@ function diffArray(prevArray: unknown[], nextArray: unknown[]): PutOp | AppendOp
267
283
  if (!prevItem || !nextItem) {
268
284
  diff[i] = [ValueOpType.Put, nextItem]
269
285
  } else if (typeof prevItem === 'object' && typeof nextItem === 'object') {
270
- const op = diffValue(prevItem, nextItem)
286
+ const op = diffValue(prevItem, nextItem, legacyAppendMode)
271
287
  if (op) {
272
288
  diff[i] = op
273
289
  }
@@ -341,12 +357,19 @@ export function applyObjectDiff<T extends object>(object: T, objectDiff: ObjectD
341
357
  break
342
358
  }
343
359
  case ValueOpType.Append: {
344
- const values = op[1]
360
+ const value = op[1]
345
361
  const offset = op[2]
346
- const arr = object[key as keyof T]
347
- if (Array.isArray(arr) && arr.length === offset) {
348
- set(key, [...arr, ...values])
362
+ const currentValue = object[key as keyof T]
363
+ if (Array.isArray(currentValue) && Array.isArray(value) && currentValue.length === offset) {
364
+ set(key, [...currentValue, ...value])
365
+ } else if (
366
+ typeof currentValue === 'string' &&
367
+ typeof value === 'string' &&
368
+ currentValue.length === offset
369
+ ) {
370
+ set(key, currentValue + value)
349
371
  }
372
+ // If validation fails (type mismatch or length mismatch), silently ignore
350
373
  break
351
374
  }
352
375
  case ValueOpType.Patch: {
@@ -1,7 +1,7 @@
1
1
  import { SerializedSchema, UnknownRecord } from '@tldraw/store'
2
2
  import { NetworkDiff, ObjectDiff, RecordOpType } from './diff'
3
3
 
4
- const TLSYNC_PROTOCOL_VERSION = 7
4
+ const TLSYNC_PROTOCOL_VERSION = 8
5
5
 
6
6
  /**
7
7
  * Gets the current tldraw sync protocol version number.
@@ -159,7 +159,7 @@ describe(TLSocketRoom, () => {
159
159
  type: 'connect' as const,
160
160
  connectRequestId: 'connect-1',
161
161
  lastServerClock: 0,
162
- protocolVersion: 7,
162
+ protocolVersion: 8,
163
163
  schema: store.schema.serialize(),
164
164
  }
165
165
  room.handleSocketMessage(sessionId1, JSON.stringify(connectRequest1))
@@ -168,7 +168,7 @@ describe(TLSocketRoom, () => {
168
168
  type: 'connect' as const,
169
169
  connectRequestId: 'connect-2',
170
170
  lastServerClock: 0,
171
- protocolVersion: 7,
171
+ protocolVersion: 8,
172
172
  schema: store.schema.serialize(),
173
173
  }
174
174
  room.handleSocketMessage(sessionId2, JSON.stringify(connectRequest2))
@@ -313,27 +313,28 @@ describe('TLSyncRoom.updateStore', () => {
313
313
  expect(documentClock).toBeLessThan(room.documentClock)
314
314
 
315
315
  expect(socketA.__lastMessage).toMatchInlineSnapshot(`
316
- {
317
- "data": [
318
- {
319
- "diff": {
320
- "document:document": [
321
- "patch",
322
- {
323
- "name": [
324
- "put",
325
- "My lovely document",
326
- ],
327
- },
328
- ],
329
- },
330
- "serverClock": 1,
331
- "type": "patch",
332
- },
333
- ],
334
- "type": "data",
335
- }
336
- `)
316
+ {
317
+ "data": [
318
+ {
319
+ "diff": {
320
+ "document:document": [
321
+ "patch",
322
+ {
323
+ "name": [
324
+ "append",
325
+ "My lovely document",
326
+ 0,
327
+ ],
328
+ },
329
+ ],
330
+ },
331
+ "serverClock": 1,
332
+ "type": "patch",
333
+ },
334
+ ],
335
+ "type": "data",
336
+ }
337
+ `)
337
338
  expect(socketB.__lastMessage).toEqual(socketA.__lastMessage)
338
339
  })
339
340
 
@@ -408,6 +408,155 @@ describe('array diffing comprehensive', () => {
408
408
  })
409
409
  })
410
410
 
411
+ describe('string appending', () => {
412
+ describe('basic string appending', () => {
413
+ it('should handle string appends', () => {
414
+ const prev = { text: 'Hello' }
415
+ const next = { text: 'Hello world' }
416
+
417
+ expect(diffRecord(prev, next)).toEqual({
418
+ text: [ValueOpType.Append, ' world', 5],
419
+ })
420
+ })
421
+
422
+ it('should handle empty string to non-empty', () => {
423
+ const prev = { text: '' }
424
+ const next = { text: 'Hello' }
425
+
426
+ expect(diffRecord(prev, next)).toEqual({
427
+ text: [ValueOpType.Append, 'Hello', 0],
428
+ })
429
+ })
430
+
431
+ it('should use put when string is replaced (not appended)', () => {
432
+ const prev = { text: 'Hello' }
433
+ const next = { text: 'Goodbye' }
434
+
435
+ expect(diffRecord(prev, next)).toEqual({
436
+ text: [ValueOpType.Put, 'Goodbye'],
437
+ })
438
+ })
439
+
440
+ it('should use put when string is shortened', () => {
441
+ const prev = { text: 'Hello world' }
442
+ const next = { text: 'Hello' }
443
+
444
+ expect(diffRecord(prev, next)).toEqual({
445
+ text: [ValueOpType.Put, 'Hello'],
446
+ })
447
+ })
448
+
449
+ it('should handle identical strings', () => {
450
+ const prev = { text: 'Hello' }
451
+ const next = { text: 'Hello' }
452
+
453
+ expect(diffRecord(prev, next)).toBeNull()
454
+ })
455
+
456
+ it('should handle large text append', () => {
457
+ const prev = { text: 'Start' }
458
+ const longText = ' '.repeat(1000) + 'end'
459
+ const next = { text: 'Start' + longText }
460
+
461
+ const diff = diffRecord(prev, next)
462
+ expect(diff).toEqual({
463
+ text: [ValueOpType.Append, longText, 5],
464
+ })
465
+ })
466
+ })
467
+
468
+ describe('string appending in nested props', () => {
469
+ it('should handle string appending in nested props', () => {
470
+ const prev = { id: 'test:1', props: { label: 'Hello' } }
471
+ const next = { id: 'test:1', props: { label: 'Hello world' } }
472
+
473
+ expect(diffRecord(prev, next)).toEqual({
474
+ props: [ValueOpType.Patch, { label: [ValueOpType.Append, ' world', 5] }],
475
+ })
476
+ })
477
+
478
+ it('should combine string appending with other property changes', () => {
479
+ const prev = { text: 'Hello', x: 100 }
480
+ const next = { text: 'Hello world', x: 200 }
481
+
482
+ expect(diffRecord(prev, next)).toEqual({
483
+ text: [ValueOpType.Append, ' world', 5],
484
+ x: [ValueOpType.Put, 200],
485
+ })
486
+ })
487
+ })
488
+
489
+ describe('apply string appending', () => {
490
+ it('should apply append operations correctly', () => {
491
+ const obj = { text: 'Hello' }
492
+ const diff: ObjectDiff = {
493
+ text: [ValueOpType.Append, ' world', 5],
494
+ }
495
+
496
+ const result = applyObjectDiff(obj, diff)
497
+ expect(result).toEqual({ text: 'Hello world' })
498
+ expect(result).not.toBe(obj)
499
+ })
500
+
501
+ it('should handle append from empty string', () => {
502
+ const obj = { text: '' }
503
+ const diff: ObjectDiff = {
504
+ text: [ValueOpType.Append, 'Hello', 0],
505
+ }
506
+
507
+ const result = applyObjectDiff(obj, diff)
508
+ expect(result).toEqual({ text: 'Hello' })
509
+ })
510
+
511
+ it('should ignore append operation with wrong offset', () => {
512
+ const obj = { text: 'Hello' }
513
+ const diff: ObjectDiff = {
514
+ text: [ValueOpType.Append, ' world', 10], // Wrong offset
515
+ }
516
+
517
+ const result = applyObjectDiff(obj, diff)
518
+ expect(result).toBe(obj) // No change, same reference
519
+ })
520
+
521
+ it('should ignore append operation on non-string value', () => {
522
+ const obj = { text: 123 }
523
+ const diff: ObjectDiff = {
524
+ text: [ValueOpType.Append, ' world', 3],
525
+ }
526
+
527
+ const result = applyObjectDiff(obj, diff)
528
+ expect(result).toBe(obj) // No change, same reference
529
+ })
530
+
531
+ it('should handle multiple stream operations', () => {
532
+ const obj = { a: 'Hello', b: 'Foo' }
533
+ const diff: ObjectDiff = {
534
+ a: [ValueOpType.Append, ' world', 5],
535
+ b: [ValueOpType.Append, 'bar', 3],
536
+ }
537
+
538
+ const result = applyObjectDiff(obj, diff)
539
+ expect(result).toEqual({ a: 'Hello world', b: 'Foobar' })
540
+ })
541
+
542
+ it('should integrate with network diff workflow', () => {
543
+ const prev = { id: 'shape:1', type: 'text', text: 'Hello' }
544
+ const next = { id: 'shape:1', type: 'text', text: 'Hello world' }
545
+
546
+ const recordsDiff = {
547
+ added: {},
548
+ updated: { 'shape:1': [prev, next] },
549
+ removed: {},
550
+ }
551
+
552
+ const networkDiff = getNetworkDiff(recordsDiff)
553
+ expect(networkDiff).toEqual({
554
+ 'shape:1': [RecordOpType.Patch, { text: [ValueOpType.Append, ' world', 5] }],
555
+ })
556
+ })
557
+ })
558
+ })
559
+
411
560
  describe('applyObjectDiff comprehensive', () => {
412
561
  describe('basic operations', () => {
413
562
  it('should create new object when changes are needed', () => {
@@ -582,3 +731,54 @@ describe('complex scenarios', () => {
582
731
  })
583
732
  })
584
733
  })
734
+
735
+ describe('nested key primitive value bug', () => {
736
+ it('should handle string changes in nested keys', () => {
737
+ // This tests the bug where nested keys (like 'props') with primitive values
738
+ // are silently dropped instead of being diffed properly
739
+ const prev = { id: 'shape:1', props: 'hello' }
740
+ const next = { id: 'shape:1', props: 'world' }
741
+
742
+ const diff = diffRecord(prev, next)
743
+
744
+ // The diff should contain a 'put' operation for props
745
+ expect(diff).toEqual({
746
+ props: [ValueOpType.Put, 'world'],
747
+ })
748
+ })
749
+
750
+ it('should handle string appending in nested keys', () => {
751
+ const prev = { id: 'shape:1', props: 'hello' }
752
+ const next = { id: 'shape:1', props: 'hello world' }
753
+
754
+ const diff = diffRecord(prev, next)
755
+
756
+ // The diff should contain an 'append' operation for props
757
+ expect(diff).toEqual({
758
+ props: [ValueOpType.Append, ' world', 5],
759
+ })
760
+ })
761
+
762
+ it('should handle number changes in nested keys', () => {
763
+ const prev = { id: 'shape:1', props: 42 }
764
+ const next = { id: 'shape:1', props: 100 }
765
+
766
+ const diff = diffRecord(prev, next)
767
+
768
+ expect(diff).toEqual({
769
+ props: [ValueOpType.Put, 100],
770
+ })
771
+ })
772
+
773
+ it('should still handle object changes in nested keys normally', () => {
774
+ const prev = { id: 'shape:1', props: { color: 'red' } }
775
+ const next = { id: 'shape:1', props: { color: 'blue' } }
776
+
777
+ const diff = diffRecord(prev, next)
778
+
779
+ // Objects in nested keys should still use patch
780
+ expect(diff).toEqual({
781
+ props: [ValueOpType.Patch, { color: [ValueOpType.Put, 'blue'] }],
782
+ })
783
+ })
784
+ })