@tldraw/sync-core
Version:
tldraw infinite canvas SDK (multiplayer sync).
785 lines (666 loc) • 18.7 kB
text/typescript
import { describe, expect, it } from 'vitest'
import {
applyObjectDiff,
diffRecord,
getNetworkDiff,
RecordOpType,
ValueOpType,
type ObjectDiff,
} from '../lib/diff'
describe('nested arrays', () => {
it('should be patchable at the end', () => {
const a = {
arr: [
[1, 2, 3],
[4, 5, 6],
],
}
const b = {
arr: [
[1, 2, 3],
[4, 5, 6, 7, 8],
],
}
expect(diffRecord(a, b)).toMatchInlineSnapshot(`
{
"arr": [
"patch",
{
"1": [
"append",
[
7,
8,
],
3,
],
},
],
}
`)
})
it('should be patchable at the beginning', () => {
const a = {
arr: [
[1, 2, 3],
[4, 5, 6],
],
}
const b = {
arr: [
[1, 2, 3, 4, 5, 6],
[4, 5, 6],
],
}
expect(diffRecord(a, b)).toMatchInlineSnapshot(`
{
"arr": [
"patch",
{
"0": [
"append",
[
4,
5,
6,
],
3,
],
},
],
}
`)
})
})
describe('objects inside arrays', () => {
it('should be patchable if only item changes', () => {
const a = {
arr: [
{ a: 1, b: 2, c: 3 },
{ a: 4, b: 5, c: 6 },
],
}
const b = {
arr: [
{ a: 1, b: 2, c: 3 },
{ a: 4, b: 5, c: 7 },
],
}
expect(diffRecord(a, b)).toMatchInlineSnapshot(`
{
"arr": [
"patch",
{
"1": [
"patch",
{
"c": [
"put",
7,
],
},
],
},
],
}
`)
})
it('should return a put op if many items change', () => {
const a = {
arr: [
{ a: 1, b: 2, c: 3 },
{ a: 4, b: 5, c: 6 },
],
}
const b = {
arr: [
{ a: 1, b: 2, c: 5 },
{ a: 4, b: 5, c: 7 },
],
}
expect(diffRecord(a, b)).toMatchInlineSnapshot(`
{
"arr": [
"put",
[
{
"a": 1,
"b": 2,
"c": 5,
},
{
"a": 4,
"b": 5,
"c": 7,
},
],
],
}
`)
})
})
test('deleting things from a record', () => {
const a = {
a: 1,
b: 2,
c: 3,
}
const b = {
a: 1,
b: 2,
}
const patch = diffRecord(a, b)
expect(patch).toMatchInlineSnapshot(`
{
"c": [
"delete",
],
}
`)
expect(applyObjectDiff(a, patch!)).toEqual(b)
})
test('adding things things to a record', () => {
const a = {
a: 1,
b: 2,
}
const b = {
a: 1,
b: 2,
c: 3,
}
const patch = diffRecord(a, b)
expect(patch).toMatchInlineSnapshot(`
{
"c": [
"put",
3,
],
}
`)
expect(applyObjectDiff(a, patch!)).toEqual(b)
})
describe('getNetworkDiff', () => {
it('should return null for empty diff', () => {
const diff = { added: {}, updated: {}, removed: {} }
expect(getNetworkDiff(diff)).toBeNull()
})
it('should handle added records', () => {
const record = { id: 'test:1', type: 'test', data: 'value' }
const diff = {
added: { 'test:1': record },
updated: {},
removed: {},
}
const networkDiff = getNetworkDiff(diff)
expect(networkDiff).toEqual({
'test:1': [RecordOpType.Put, record],
})
})
it('should handle removed records', () => {
const diff = {
added: {},
updated: {},
removed: { 'test:1': { id: 'test:1', type: 'test' } },
}
const networkDiff = getNetworkDiff(diff)
expect(networkDiff).toEqual({
'test:1': [RecordOpType.Remove],
})
})
it('should handle updated records with patches', () => {
const prev = { id: 'test:1', type: 'test', x: 100, y: 200 }
const next = { id: 'test:1', type: 'test', x: 150, y: 200 }
const diff = {
added: {},
updated: { 'test:1': [prev, next] },
removed: {},
}
const networkDiff = getNetworkDiff(diff)
expect(networkDiff).toEqual({
'test:1': [RecordOpType.Patch, { x: [ValueOpType.Put, 150] }],
})
})
it('should skip updates when no diff exists', () => {
const record = { id: 'test:1', type: 'test', x: 100 }
const diff = {
added: {},
updated: { 'test:1': [record, record] },
removed: {},
}
const networkDiff = getNetworkDiff(diff)
expect(networkDiff).toBeNull()
})
it('should handle mixed operations', () => {
const addedRecord = { id: 'test:1', type: 'test', data: 'new' }
const prevRecord = { id: 'test:2', type: 'test', x: 100 }
const nextRecord = { id: 'test:2', type: 'test', x: 200 }
const removedRecord = { id: 'test:3', type: 'test' }
const diff = {
added: { 'test:1': addedRecord },
updated: { 'test:2': [prevRecord, nextRecord] },
removed: { 'test:3': removedRecord },
}
const networkDiff = getNetworkDiff(diff)
expect(networkDiff).toEqual({
'test:1': [RecordOpType.Put, addedRecord],
'test:2': [RecordOpType.Patch, { x: [ValueOpType.Put, 200] }],
'test:3': [RecordOpType.Remove],
})
})
})
describe('diffRecord comprehensive tests', () => {
it('should return null for identical records', () => {
const record = { id: 'test:1', x: 100, y: 200 }
expect(diffRecord(record, record)).toBeNull()
})
it('should handle simple property changes', () => {
const prev = { id: 'test:1', x: 100, y: 200 }
const next = { id: 'test:1', x: 150, y: 200 }
expect(diffRecord(prev, next)).toEqual({
x: [ValueOpType.Put, 150],
})
})
it('should handle nested props changes', () => {
const prev = { id: 'test:1', props: { color: 'red', size: 'medium' } }
const next = { id: 'test:1', props: { color: 'blue', size: 'medium' } }
expect(diffRecord(prev, next)).toEqual({
props: [ValueOpType.Patch, { color: [ValueOpType.Put, 'blue'] }],
})
})
it('should handle adding nested props', () => {
const prev = { id: 'test:1', props: { color: 'red' } }
const next = { id: 'test:1', props: { color: 'red', size: 'large' } }
expect(diffRecord(prev, next)).toEqual({
props: [ValueOpType.Patch, { size: [ValueOpType.Put, 'large'] }],
})
})
it('should handle removing nested props', () => {
const prev = { id: 'test:1', props: { color: 'red', size: 'large' } }
const next = { id: 'test:1', props: { color: 'red' } }
expect(diffRecord(prev, next)).toEqual({
props: [ValueOpType.Patch, { size: [ValueOpType.Delete] }],
})
})
it('should handle multiple property changes', () => {
const prev = { id: 'test:1', x: 100, y: 200, rotation: 0 }
const next = { id: 'test:1', x: 150, y: 250, rotation: 45 }
expect(diffRecord(prev, next)).toEqual({
x: [ValueOpType.Put, 150],
y: [ValueOpType.Put, 250],
rotation: [ValueOpType.Put, 45],
})
})
it('should handle null and undefined values', () => {
const prev = { id: 'test:1', optional: 'value', nullable: null }
const next = { id: 'test:1', optional: undefined, nullable: 'value' }
const diff = diffRecord(prev, next)
expect(diff).toBeTruthy()
expect(diff!.optional).toEqual([ValueOpType.Put, undefined])
expect(diff!.nullable).toEqual([ValueOpType.Put, 'value'])
})
})
describe('array diffing comprehensive', () => {
describe('simple arrays', () => {
it('should handle identical arrays', () => {
const prev = { arr: [1, 2, 3] }
const next = { arr: [1, 2, 3] }
expect(diffRecord(prev, next)).toBeNull()
})
it('should handle array appends', () => {
const prev = { arr: [1, 2, 3] }
const next = { arr: [1, 2, 3, 4, 5] }
expect(diffRecord(prev, next)).toEqual({
arr: [ValueOpType.Append, [4, 5], 3],
})
})
it('should replace array when prefix changes', () => {
const prev = { arr: [1, 2, 3] }
const next = { arr: [1, 3, 4] }
expect(diffRecord(prev, next)).toEqual({
arr: [ValueOpType.Put, [1, 3, 4]],
})
})
it('should patch few items in same-length arrays', () => {
const prev = { arr: [1, 2, 3, 4, 5] }
const next = { arr: [1, 9, 3, 4, 5] }
expect(diffRecord(prev, next)).toEqual({
arr: [ValueOpType.Patch, { '1': [ValueOpType.Put, 9] }],
})
})
it('should replace array when too many items change', () => {
const prev = { arr: [1, 2, 3, 4, 5] }
const next = { arr: [6, 7, 8, 9, 10] }
expect(diffRecord(prev, next)).toEqual({
arr: [ValueOpType.Put, [6, 7, 8, 9, 10]],
})
})
})
describe('empty arrays', () => {
it('should handle empty to non-empty', () => {
const prev = { arr: [] }
const next = { arr: [1, 2, 3] }
expect(diffRecord(prev, next)).toEqual({
arr: [ValueOpType.Append, [1, 2, 3], 0],
})
})
it('should handle non-empty to empty', () => {
const prev = { arr: [1, 2, 3] }
const next = { arr: [] }
expect(diffRecord(prev, next)).toEqual({
arr: [ValueOpType.Put, []],
})
})
})
})
describe('string appending', () => {
describe('basic string appending', () => {
it('should handle string appends', () => {
const prev = { text: 'Hello' }
const next = { text: 'Hello world' }
expect(diffRecord(prev, next)).toEqual({
text: [ValueOpType.Append, ' world', 5],
})
})
it('should handle empty string to non-empty', () => {
const prev = { text: '' }
const next = { text: 'Hello' }
expect(diffRecord(prev, next)).toEqual({
text: [ValueOpType.Append, 'Hello', 0],
})
})
it('should use put when string is replaced (not appended)', () => {
const prev = { text: 'Hello' }
const next = { text: 'Goodbye' }
expect(diffRecord(prev, next)).toEqual({
text: [ValueOpType.Put, 'Goodbye'],
})
})
it('should use put when string is shortened', () => {
const prev = { text: 'Hello world' }
const next = { text: 'Hello' }
expect(diffRecord(prev, next)).toEqual({
text: [ValueOpType.Put, 'Hello'],
})
})
it('should handle identical strings', () => {
const prev = { text: 'Hello' }
const next = { text: 'Hello' }
expect(diffRecord(prev, next)).toBeNull()
})
it('should handle large text append', () => {
const prev = { text: 'Start' }
const longText = ' '.repeat(1000) + 'end'
const next = { text: 'Start' + longText }
const diff = diffRecord(prev, next)
expect(diff).toEqual({
text: [ValueOpType.Append, longText, 5],
})
})
})
describe('string appending in nested props', () => {
it('should handle string appending in nested props', () => {
const prev = { id: 'test:1', props: { label: 'Hello' } }
const next = { id: 'test:1', props: { label: 'Hello world' } }
expect(diffRecord(prev, next)).toEqual({
props: [ValueOpType.Patch, { label: [ValueOpType.Append, ' world', 5] }],
})
})
it('should combine string appending with other property changes', () => {
const prev = { text: 'Hello', x: 100 }
const next = { text: 'Hello world', x: 200 }
expect(diffRecord(prev, next)).toEqual({
text: [ValueOpType.Append, ' world', 5],
x: [ValueOpType.Put, 200],
})
})
})
describe('apply string appending', () => {
it('should apply append operations correctly', () => {
const obj = { text: 'Hello' }
const diff: ObjectDiff = {
text: [ValueOpType.Append, ' world', 5],
}
const result = applyObjectDiff(obj, diff)
expect(result).toEqual({ text: 'Hello world' })
expect(result).not.toBe(obj)
})
it('should handle append from empty string', () => {
const obj = { text: '' }
const diff: ObjectDiff = {
text: [ValueOpType.Append, 'Hello', 0],
}
const result = applyObjectDiff(obj, diff)
expect(result).toEqual({ text: 'Hello' })
})
it('should ignore append operation with wrong offset', () => {
const obj = { text: 'Hello' }
const diff: ObjectDiff = {
text: [ValueOpType.Append, ' world', 10], // Wrong offset
}
const result = applyObjectDiff(obj, diff)
expect(result).toBe(obj) // No change, same reference
})
it('should ignore append operation on non-string value', () => {
const obj = { text: 123 }
const diff: ObjectDiff = {
text: [ValueOpType.Append, ' world', 3],
}
const result = applyObjectDiff(obj, diff)
expect(result).toBe(obj) // No change, same reference
})
it('should handle multiple stream operations', () => {
const obj = { a: 'Hello', b: 'Foo' }
const diff: ObjectDiff = {
a: [ValueOpType.Append, ' world', 5],
b: [ValueOpType.Append, 'bar', 3],
}
const result = applyObjectDiff(obj, diff)
expect(result).toEqual({ a: 'Hello world', b: 'Foobar' })
})
it('should integrate with network diff workflow', () => {
const prev = { id: 'shape:1', type: 'text', text: 'Hello' }
const next = { id: 'shape:1', type: 'text', text: 'Hello world' }
const recordsDiff = {
added: {},
updated: { 'shape:1': [prev, next] },
removed: {},
}
const networkDiff = getNetworkDiff(recordsDiff)
expect(networkDiff).toEqual({
'shape:1': [RecordOpType.Patch, { text: [ValueOpType.Append, ' world', 5] }],
})
})
})
})
describe('applyObjectDiff comprehensive', () => {
describe('basic operations', () => {
it('should create new object when changes are needed', () => {
const obj = { a: 1, b: 2 }
const diff: ObjectDiff = { a: [ValueOpType.Put, 5] }
const result = applyObjectDiff(obj, diff)
expect(result).not.toBe(obj)
expect(result).toEqual({ a: 5, b: 2 })
})
it('should handle put operations', () => {
const obj = { a: 1, b: 2 }
const diff: ObjectDiff = {
a: [ValueOpType.Put, 10],
c: [ValueOpType.Put, 30],
}
const result = applyObjectDiff(obj, diff)
expect(result).toEqual({ a: 10, b: 2, c: 30 })
})
it('should handle delete operations', () => {
const obj = { a: 1, b: 2, c: 3 }
const diff: ObjectDiff = { b: [ValueOpType.Delete] }
const result = applyObjectDiff(obj, diff)
expect(result).toEqual({ a: 1, c: 3 })
expect('b' in result).toBe(false)
})
})
describe('nested patch operations', () => {
it('should handle nested object patches', () => {
const obj = { a: 1, nested: { x: 10, y: 20 } }
const diff: ObjectDiff = {
nested: [ValueOpType.Patch, { x: [ValueOpType.Put, 100] }],
}
const result = applyObjectDiff(obj, diff)
expect(result).toEqual({ a: 1, nested: { x: 100, y: 20 } })
expect(result.nested).not.toBe(obj.nested)
})
it('should handle deeply nested patches', () => {
const obj = {
level1: {
level2: {
level3: { value: 'old' },
},
},
}
const diff: ObjectDiff = {
level1: [
ValueOpType.Patch,
{
level2: [
ValueOpType.Patch,
{
level3: [
ValueOpType.Patch,
{
value: [ValueOpType.Put, 'new'],
},
],
},
],
},
],
}
const result = applyObjectDiff(obj, diff)
expect(result.level1.level2.level3.value).toBe('new')
})
})
describe('array operations', () => {
it('should handle array append operations', () => {
const obj = { arr: [1, 2, 3] }
const diff: ObjectDiff = {
arr: [ValueOpType.Append, [4, 5], 3],
}
const result = applyObjectDiff(obj, diff)
expect(result).toEqual({ arr: [1, 2, 3, 4, 5] })
})
it('should handle array patch operations', () => {
const obj = { arr: [{ a: 1 }, { b: 2 }, { c: 3 }] }
const diff: ObjectDiff = {
arr: [
ValueOpType.Patch,
{
'1': [ValueOpType.Patch, { b: [ValueOpType.Put, 20] }],
},
],
}
const result = applyObjectDiff(obj, diff)
expect(result.arr[1]).toEqual({ b: 20 })
expect(result.arr[0]).toBe(obj.arr[0]) // Unchanged items should be same reference
})
})
describe('edge cases', () => {
it('should handle empty diffs', () => {
const obj = { a: 1, b: 2 }
const diff: ObjectDiff = {}
const result = applyObjectDiff(obj, diff)
expect(result).toBe(obj) // Should be same reference
})
})
})
describe('complex scenarios', () => {
it('should handle shape-like record updates', () => {
const prev = {
id: 'shape:123',
type: 'geo',
x: 100,
y: 200,
props: {
color: 'red',
size: 'medium',
geo: 'rectangle',
},
meta: {},
}
const next = {
id: 'shape:123',
type: 'geo',
x: 150,
y: 200,
props: {
color: 'blue',
size: 'medium',
geo: 'rectangle',
},
meta: { timestamp: Date.now() },
}
const diff = diffRecord(prev, next)
expect(diff).toBeTruthy()
expect(diff!.x).toEqual([ValueOpType.Put, 150])
expect(diff!.props).toEqual([ValueOpType.Patch, { color: [ValueOpType.Put, 'blue'] }])
expect(diff!.meta).toBeTruthy()
// Apply the diff and verify result
const result = applyObjectDiff(prev, diff!)
expect(result).toEqual(next)
})
it('should handle complete network diff workflow', () => {
const shape1 = { id: 'shape:1', type: 'geo', x: 100 }
const shape2prev = { id: 'shape:2', type: 'geo', x: 200 }
const shape2next = { id: 'shape:2', type: 'geo', x: 300 }
const shape3 = { id: 'shape:3', type: 'geo', x: 400 }
const recordsDiff = {
added: { 'shape:1': shape1 },
updated: { 'shape:2': [shape2prev, shape2next] },
removed: { 'shape:3': shape3 },
}
const networkDiff = getNetworkDiff(recordsDiff)
expect(networkDiff).toEqual({
'shape:1': [RecordOpType.Put, shape1],
'shape:2': [RecordOpType.Patch, { x: [ValueOpType.Put, 300] }],
'shape:3': [RecordOpType.Remove],
})
})
})
describe('nested key primitive value bug', () => {
it('should handle string changes in nested keys', () => {
// This tests the bug where nested keys (like 'props') with primitive values
// are silently dropped instead of being diffed properly
const prev = { id: 'shape:1', props: 'hello' }
const next = { id: 'shape:1', props: 'world' }
const diff = diffRecord(prev, next)
// The diff should contain a 'put' operation for props
expect(diff).toEqual({
props: [ValueOpType.Put, 'world'],
})
})
it('should handle string appending in nested keys', () => {
const prev = { id: 'shape:1', props: 'hello' }
const next = { id: 'shape:1', props: 'hello world' }
const diff = diffRecord(prev, next)
// The diff should contain an 'append' operation for props
expect(diff).toEqual({
props: [ValueOpType.Append, ' world', 5],
})
})
it('should handle number changes in nested keys', () => {
const prev = { id: 'shape:1', props: 42 }
const next = { id: 'shape:1', props: 100 }
const diff = diffRecord(prev, next)
expect(diff).toEqual({
props: [ValueOpType.Put, 100],
})
})
it('should still handle object changes in nested keys normally', () => {
const prev = { id: 'shape:1', props: { color: 'red' } }
const next = { id: 'shape:1', props: { color: 'blue' } }
const diff = diffRecord(prev, next)
// Objects in nested keys should still use patch
expect(diff).toEqual({
props: [ValueOpType.Patch, { color: [ValueOpType.Put, 'blue'] }],
})
})
})