@tldraw/state 5.1.0 → 5.2.0-canary.019da1aa690a

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.
@@ -1,199 +1,203 @@
1
1
  import { atom } from '../Atom'
2
- import { reactor } from '../EffectScheduler'
3
- import { getGlobalEpoch, transact, transaction } from '../transactions'
4
- import { RESET_VALUE } from '../types'
2
+ import { computed } from '../Computed'
3
+ import { react } from '../EffectScheduler'
4
+ import { getGlobalEpoch } from '../transactions'
5
5
 
6
- describe('atoms', () => {
7
- it('contain data', () => {
8
- const a = atom('', 1)
6
+ // Tests for SPEC.md §2 (the epoch clock), §3 (equality), and §4 (atoms).
7
+ // Rule IDs like [EP3] in test names refer to that document.
9
8
 
10
- expect(a.get()).toBe(1)
11
- })
12
- it('can be updated', () => {
13
- const a = atom('', 1)
9
+ describe('the epoch clock (EP)', () => {
10
+ it('[EP1] is shared by all atoms', () => {
11
+ const startEpoch = getGlobalEpoch()
12
+ const a = atom('a', 1)
13
+ const b = atom('b', 1)
14
14
 
15
15
  a.set(2)
16
+ expect(getGlobalEpoch()).toBe(startEpoch + 1)
16
17
 
17
- expect(a.get()).toBe(2)
18
+ b.set(2)
19
+ expect(getGlobalEpoch()).toBe(startEpoch + 2)
18
20
  })
19
- it('will not advance the global epoch on creation', () => {
21
+
22
+ it('[EP2] does not advance when signals are created', () => {
20
23
  const startEpoch = getGlobalEpoch()
21
- atom('', 3)
24
+ atom('a', 3)
25
+ computed('c', () => 1)
22
26
  expect(getGlobalEpoch()).toBe(startEpoch)
23
27
  })
24
- it('will advance the global epoch on .set', () => {
28
+
29
+ it('[EP3] advances by exactly one when an atom is set to a new value', () => {
25
30
  const startEpoch = getGlobalEpoch()
26
- const a = atom('', 3)
31
+ const a = atom('a', 3)
27
32
  a.set(4)
28
33
  expect(getGlobalEpoch()).toBe(startEpoch + 1)
29
34
  })
30
- it('can store history', () => {
31
- const a = atom('', 1, { historyLength: 3, computeDiff: (a, b) => b - a })
32
35
 
36
+ it('[EP4] does not advance when an atom is set to an equal value', () => {
37
+ const a = atom('a', 3)
33
38
  const startEpoch = getGlobalEpoch()
39
+ a.set(3)
40
+ expect(getGlobalEpoch()).toBe(startEpoch)
41
+ })
34
42
 
35
- expect(a.getDiffSince(startEpoch)).toEqual([])
43
+ it('[EP5] is recorded on the atom as lastChangedEpoch when it changes', () => {
44
+ const a = atom('a', 1)
36
45
 
37
- a.set(5)
46
+ a.set(2)
47
+ const changedEpoch = getGlobalEpoch()
48
+ expect(a.lastChangedEpoch).toBe(changedEpoch)
38
49
 
39
- expect(a.getDiffSince(startEpoch)).toEqual([+4])
50
+ // other atoms changing does not move this atom's lastChangedEpoch
51
+ const b = atom('b', 1)
52
+ b.set(2)
53
+ expect(a.lastChangedEpoch).toBe(changedEpoch)
54
+ })
55
+ })
40
56
 
41
- a.set(10)
57
+ describe('equality (EQ)', () => {
58
+ it('[EQ1] treats === and Object.is values as equal by default', () => {
59
+ const value = { hello: true }
60
+ const a = atom('a', value)
61
+ const startEpoch = getGlobalEpoch()
42
62
 
43
- expect(a.getDiffSince(startEpoch)).toEqual([+4, +5])
63
+ a.set(value)
64
+ expect(getGlobalEpoch()).toBe(startEpoch)
44
65
 
45
- a.set(20)
66
+ const n = atom('n', NaN)
67
+ n.set(NaN)
68
+ expect(getGlobalEpoch()).toBe(startEpoch)
69
+ })
46
70
 
47
- expect(a.getDiffSince(startEpoch)).toEqual([+4, +5, +10])
71
+ it('[EQ1] consults the old value’s equals method by default', () => {
72
+ class Box {
73
+ constructor(public value: number) {}
74
+ equals(other: unknown) {
75
+ return other instanceof Box && other.value === this.value
76
+ }
77
+ }
48
78
 
49
- a.set(30)
79
+ const original = new Box(1)
80
+ const a = atom('a', original)
50
81
 
51
- // will be RESET_VALUE because we don't have enough history
52
- expect(a.getDiffSince(startEpoch)).toEqual(RESET_VALUE)
82
+ a.set(new Box(1))
83
+ expect(a.get()).toBe(original)
84
+
85
+ const different = new Box(2)
86
+ a.set(different)
87
+ expect(a.get()).toBe(different)
53
88
  })
54
- it('has history independent of other atoms', () => {
55
- const a = atom('', 1, { historyLength: 3, computeDiff: (a, b) => b - a })
56
- const b = atom('', 1, { historyLength: 3, computeDiff: (a, b) => b - a })
57
89
 
58
- const startEpoch = getGlobalEpoch()
90
+ it('[EQ2] does not consult the new value’s equals method', () => {
91
+ const oldValue = { x: 1 }
92
+ const newValue = { x: 1, equals: () => true }
59
93
 
60
- b.set(-5)
61
- b.set(-10)
62
- b.set(-20)
63
- expect(b.getDiffSince(startEpoch)).toEqual([-6, -5, -10])
64
- expect(b.getDiffSince(getGlobalEpoch())).toEqual([])
94
+ const a = atom('a', oldValue)
95
+ a.set(newValue)
65
96
 
66
- expect(a.getDiffSince(startEpoch)).toEqual([])
67
- a.set(5)
68
- expect(a.getDiffSince(startEpoch)).toEqual([+4])
69
- expect(b.getDiffSince(startEpoch)).toEqual([-6, -5, -10])
70
- expect(b.getDiffSince(getGlobalEpoch())).toEqual([])
97
+ expect(a.get()).toBe(newValue)
71
98
  })
72
- it('still updates history during transactions', () => {
73
- const a = atom('', 1, { historyLength: 3, computeDiff: (a, b) => b - a })
74
99
 
75
- const startEpoch = getGlobalEpoch()
100
+ it('[EQ3] uses a custom isEqual instead of default equality when provided', () => {
101
+ const foo = { hello: true }
102
+ const bar = { hello: true }
76
103
 
77
- transact(() => {
78
- expect(a.getDiffSince(startEpoch)).toEqual([])
104
+ const a = atom('a', foo)
79
105
 
80
- a.set(5)
106
+ a.set(bar)
81
107
 
82
- expect(a.getDiffSince(startEpoch)).toEqual([+4])
108
+ expect(a.get()).toBe(bar)
83
109
 
84
- a.set(10)
110
+ const b = atom('b', foo, { isEqual: (a, b) => a.hello === b.hello })
85
111
 
86
- expect(a.getDiffSince(startEpoch)).toEqual([+4, +5])
112
+ b.set(bar)
87
113
 
88
- a.set(20)
114
+ expect(b.get()).toBe(foo)
115
+ })
89
116
 
90
- expect(a.getDiffSince(startEpoch)).toEqual([+4, +5, +10])
117
+ it('[EQ4] makes setting an equal value a complete no-op', () => {
118
+ const initial = { x: 1 }
119
+ const a = atom('a', initial, {
120
+ isEqual: (a, b) => a.x === b.x,
121
+ historyLength: 5,
122
+ computeDiff: () => 'diff',
91
123
  })
92
124
 
93
- expect(a.getDiffSince(startEpoch)).toEqual([+4, +5, +10])
94
- })
95
- it('will clear the history if the transaction aborts', () => {
96
- const a = atom('', 1, { historyLength: 3, computeDiff: (a, b) => b - a })
125
+ const effect = vi.fn(() => {
126
+ a.get()
127
+ })
128
+ const stop = react('r', effect)
129
+ expect(effect).toHaveBeenCalledTimes(1)
97
130
 
98
131
  const startEpoch = getGlobalEpoch()
132
+ const lastChanged = a.lastChangedEpoch
99
133
 
100
- transaction((rollback) => {
101
- expect(a.getDiffSince(startEpoch)).toEqual([])
134
+ a.set({ x: 1 })
102
135
 
103
- a.set(5)
136
+ expect(getGlobalEpoch()).toBe(startEpoch)
137
+ expect(a.lastChangedEpoch).toBe(lastChanged)
138
+ expect(a.get()).toBe(initial)
139
+ expect(a.getDiffSince(startEpoch)).toEqual([])
140
+ expect(effect).toHaveBeenCalledTimes(1)
104
141
 
105
- expect(a.getDiffSince(startEpoch)).toEqual([+4])
142
+ stop()
143
+ })
144
+ })
106
145
 
107
- rollback()
108
- })
146
+ describe('atoms (A)', () => {
147
+ it('[A1] contain data', () => {
148
+ const a = atom('', 1)
109
149
 
110
- expect(a.getDiffSince(startEpoch)).toEqual(RESET_VALUE)
150
+ expect(a.get()).toBe(1)
111
151
  })
112
- it('supports an update operation', () => {
113
- const startEpoch = getGlobalEpoch()
152
+
153
+ it('[A2] can be updated with set', () => {
114
154
  const a = atom('', 1)
115
155
 
116
- a.update((value) => value + 1)
156
+ a.set(2)
117
157
 
118
158
  expect(a.get()).toBe(2)
119
- expect(getGlobalEpoch()).toBe(startEpoch + 1)
120
159
  })
121
- it('supports passing diffs in .set', () => {
122
- const a = atom('', 1, { historyLength: 3 })
123
-
124
- const startEpoch = getGlobalEpoch()
125
160
 
126
- a.set(5, +4)
127
- expect(a.getDiffSince(startEpoch)).toEqual([+4])
161
+ it('[A3] set returns the value of the atom after the call', () => {
162
+ const a = atom('', 1)
128
163
 
129
- a.set(6, +1)
130
- expect(a.getDiffSince(startEpoch)).toEqual([+4, +1])
164
+ expect(a.set(2)).toBe(2)
165
+ // setting an equal value returns the unchanged current value
166
+ expect(a.set(2)).toBe(2)
131
167
  })
132
- it('does not push history if nothing changed', () => {
133
- const a = atom('', 1, { historyLength: 3 })
134
168
 
169
+ it('[A4] update(fn) sets the atom to fn(currentValue)', () => {
135
170
  const startEpoch = getGlobalEpoch()
171
+ const a = atom('', 1)
136
172
 
137
- a.set(5, +4)
138
- expect(a.getDiffSince(startEpoch)).toEqual([+4])
139
- a.set(5, +4)
140
- expect(a.getDiffSince(startEpoch)).toEqual([+4])
141
- })
142
- it('clears the history buffer if you fail to provide a diff', () => {
143
- const a = atom('', 1, { historyLength: 3 })
144
- const startEpoch = getGlobalEpoch()
145
-
146
- a.set(5, +4)
147
-
148
- expect(a.getDiffSince(startEpoch)).toEqual([+4])
149
-
150
- a.set(6)
173
+ a.update((value) => value + 1)
151
174
 
152
- expect(a.getDiffSince(startEpoch)).toEqual(RESET_VALUE)
175
+ expect(a.get()).toBe(2)
176
+ expect(getGlobalEpoch()).toBe(startEpoch + 1)
153
177
  })
154
- })
155
178
 
156
- describe('reacting to atoms', () => {
157
- it('should work', async () => {
158
- const a = atom('', 234)
159
-
160
- let val = 0
161
- const r = reactor('', () => {
162
- val = a.get()
179
+ it('[A5] __unsafe__getWithoutCapture returns the value without capturing a dependency', () => {
180
+ const a = atom('a', 1)
181
+ const effect = vi.fn(() => {
182
+ a.__unsafe__getWithoutCapture()
163
183
  })
184
+ const stop = react('r', effect)
164
185
 
165
- expect(val).toBe(0)
166
-
167
- r.start()
168
-
169
- expect(val).toBe(234)
170
-
171
- a.set(939)
172
-
173
- expect(val).toBe(939)
174
-
175
- r.stop()
186
+ expect(effect).toHaveBeenCalledTimes(1)
176
187
 
177
- a.set(2342)
188
+ a.set(2)
178
189
 
179
- expect(val).toBe(939)
180
- expect(a.get()).toBe(2342)
190
+ expect(effect).toHaveBeenCalledTimes(1)
191
+ stop()
181
192
  })
182
- })
183
-
184
- test('isEqual can provide custom equality checks', () => {
185
- const foo = { hello: true }
186
- const bar = { hello: true }
187
-
188
- const a = atom('a', foo)
189
193
 
190
- a.set(bar)
194
+ it('[A6] are independent of each other', () => {
195
+ const a = atom('a', 1)
196
+ const b = atom('b', 10)
191
197
 
192
- expect(a.get()).toBe(bar)
193
-
194
- const b = atom('b', foo, { isEqual: (a, b) => a.hello === b.hello })
195
-
196
- b.set(bar)
198
+ a.set(2)
197
199
 
198
- expect(b.get()).toBe(foo)
200
+ expect(b.get()).toBe(10)
201
+ expect(b.lastChangedEpoch).toBeLessThan(a.lastChangedEpoch)
202
+ })
199
203
  })
@@ -7,10 +7,207 @@ import {
7
7
  unsafe__withoutCapture,
8
8
  } from '../capture'
9
9
  import { computed } from '../Computed'
10
- import { react } from '../EffectScheduler'
10
+ import { react, reactor } from '../EffectScheduler'
11
11
  import { advanceGlobalEpoch, getGlobalEpoch } from '../transactions'
12
12
  import { Child } from '../types'
13
13
 
14
+ // Tests for SPEC.md §5 (dependency capture).
15
+ // Rule IDs like [CAP2] in test names refer to that document.
16
+
17
+ describe('dependency capture (CAP)', () => {
18
+ it('[CAP1][CAP3] captures each dereferenced signal once, in first-dereference order', () => {
19
+ const a = atom('a', 1)
20
+ const b = atom('b', 2)
21
+
22
+ const r = reactor('r', () => {
23
+ a.get()
24
+ a.get()
25
+ b.get()
26
+ a.get()
27
+ })
28
+ r.start()
29
+
30
+ expect(r.scheduler.parents).toEqual([a, b])
31
+ r.stop()
32
+ })
33
+
34
+ it('[CAP2] the parent set is exactly the signals dereferenced in the latest run', () => {
35
+ const which = atom('which', true)
36
+ const b = atom('b', 1)
37
+ const c = atom('c', 2)
38
+
39
+ let runs = 0
40
+ const stop = react('r', () => {
41
+ runs++
42
+ if (which.get()) {
43
+ b.get()
44
+ } else {
45
+ c.get()
46
+ }
47
+ })
48
+ expect(runs).toBe(1)
49
+
50
+ // c is not currently a parent
51
+ c.set(3)
52
+ expect(runs).toBe(1)
53
+
54
+ which.set(false)
55
+ expect(runs).toBe(2)
56
+
57
+ // b is no longer a parent after the latest run
58
+ b.set(10)
59
+ expect(runs).toBe(2)
60
+
61
+ // c now is
62
+ c.set(4)
63
+ expect(runs).toBe(3)
64
+
65
+ stop()
66
+ })
67
+
68
+ it('[CAP4] capture contexts nest: an effect captures a computed, not its parents', () => {
69
+ const a = atom('a', 1)
70
+ const double = computed('double', () => a.get() * 2)
71
+
72
+ const r = reactor('r', () => {
73
+ double.get()
74
+ })
75
+ r.start()
76
+
77
+ expect(r.scheduler.parents).toEqual([double])
78
+ expect(double.parents).toEqual([a])
79
+ r.stop()
80
+ })
81
+
82
+ it('[CAP7] liveness propagates transitively up the graph', () => {
83
+ const a = atom('a', 1)
84
+ const c = computed('c', () => a.get())
85
+
86
+ // lazily dereferencing a computed does not attach anything
87
+ c.get()
88
+ expect(a.children.isEmpty).toBe(true)
89
+ expect(c.children.isEmpty).toBe(true)
90
+
91
+ const r = reactor('r', () => {
92
+ c.get()
93
+ })
94
+ r.start()
95
+
96
+ expect(a.children.isEmpty).toBe(false)
97
+ expect(c.children.isEmpty).toBe(false)
98
+
99
+ r.stop()
100
+
101
+ expect(a.children.isEmpty).toBe(true)
102
+ expect(c.children.isEmpty).toBe(true)
103
+ })
104
+
105
+ it('[CAP6] dereferencing signals outside a capture context captures nothing', () => {
106
+ expect(() => {
107
+ maybeCaptureParent(atom('', 1))
108
+ }).not.toThrow()
109
+ })
110
+ })
111
+
112
+ describe('unsafe__withoutCapture (CAP5)', () => {
113
+ it('[CAP5] short-circuits the current capture frame in a computed', () => {
114
+ const atomA = atom('a', 1)
115
+ const atomB = atom('b', 1)
116
+ const atomC = atom('c', 1)
117
+
118
+ const child = computed('', () => {
119
+ return atomA.get() + atomB.get() + unsafe__withoutCapture(() => atomC.get())
120
+ })
121
+
122
+ let lastValue: number | undefined
123
+ let numReactions = 0
124
+
125
+ react('', () => {
126
+ numReactions++
127
+ lastValue = child.get()
128
+ })
129
+
130
+ expect(lastValue).toBe(3)
131
+ expect(numReactions).toBe(1)
132
+
133
+ atomA.set(2)
134
+
135
+ expect(lastValue).toBe(4)
136
+ expect(numReactions).toBe(2)
137
+
138
+ atomB.set(2)
139
+
140
+ expect(lastValue).toBe(5)
141
+ expect(numReactions).toBe(3)
142
+
143
+ atomC.set(2)
144
+
145
+ // The reaction should not have run because C was not captured
146
+ expect(lastValue).toBe(5)
147
+ expect(numReactions).toBe(3)
148
+ })
149
+
150
+ it('[CAP5] short-circuits the current capture frame in an effect', () => {
151
+ const atomA = atom('a', 1)
152
+ const atomB = atom('b', 1)
153
+ const atomC = atom('c', 1)
154
+
155
+ let lastValue: number | undefined
156
+ let numReactions = 0
157
+
158
+ react('', () => {
159
+ numReactions++
160
+ lastValue = atomA.get() + atomB.get() + unsafe__withoutCapture(() => atomC.get())
161
+ })
162
+
163
+ expect(lastValue).toBe(3)
164
+ expect(numReactions).toBe(1)
165
+
166
+ atomA.set(2)
167
+
168
+ expect(lastValue).toBe(4)
169
+ expect(numReactions).toBe(2)
170
+
171
+ atomB.set(2)
172
+
173
+ expect(lastValue).toBe(5)
174
+ expect(numReactions).toBe(3)
175
+
176
+ atomC.set(2)
177
+
178
+ // The reaction should not have run because C was not captured
179
+ expect(lastValue).toBe(5)
180
+ expect(numReactions).toBe(3)
181
+ })
182
+
183
+ it('[CAP5] restores the capture context even if the wrapped function throws', () => {
184
+ const a = atom('a', 1)
185
+ let runs = 0
186
+
187
+ const stop = react('r', () => {
188
+ runs++
189
+ try {
190
+ unsafe__withoutCapture(() => {
191
+ throw new Error('oops')
192
+ })
193
+ } catch {
194
+ // ignore
195
+ }
196
+ // captured only if the capture context was restored
197
+ a.get()
198
+ })
199
+
200
+ expect(runs).toBe(1)
201
+
202
+ a.set(2)
203
+
204
+ expect(runs).toBe(2)
205
+ stop()
206
+ })
207
+ })
208
+
209
+ // Internal contract of startCapturingParents / maybeCaptureParent / stopCapturingParents [CAP8].
210
+
14
211
  const emptyChild = (props: Partial<Child> = {}) =>
15
212
  ({
16
213
  parentEpochs: [],
@@ -21,8 +218,8 @@ const emptyChild = (props: Partial<Child> = {}) =>
21
218
  ...props,
22
219
  }) as Child
23
220
 
24
- describe('capturing parents', () => {
25
- it('can be started and stopped', () => {
221
+ describe('capturing parents (CAP8, internal)', () => {
222
+ it('[CAP8] can be started and stopped', () => {
26
223
  const a = atom('', 1)
27
224
  const startEpoch = getGlobalEpoch()
28
225
 
@@ -42,7 +239,7 @@ describe('capturing parents', () => {
42
239
  expect(child.parents).toEqual([a])
43
240
  })
44
241
 
45
- it('can handle several parents', () => {
242
+ it('[CAP8] can handle several parents', () => {
46
243
  const atomA = atom('', 1)
47
244
  const atomAEpoch = getGlobalEpoch()
48
245
  advanceGlobalEpoch() // let's say time has passed
@@ -75,7 +272,7 @@ describe('capturing parents', () => {
75
272
  expect(child.parents).toEqual([atomA, atomB, atomC])
76
273
  })
77
274
 
78
- it('will reorder if parents are captured in different orders each time', () => {
275
+ it('[CAP8] will reorder if parents are captured in different orders each time', () => {
79
276
  const atomA = atom('', 1)
80
277
  advanceGlobalEpoch() // let's say time has passed
81
278
  const atomB = atom('', 1)
@@ -109,7 +306,7 @@ describe('capturing parents', () => {
109
306
  expect(child.parents).toEqual([atomA, atomC, atomB])
110
307
  })
111
308
 
112
- it('will shrink the parent arrays if the number of captured parents shrinks', () => {
309
+ it('[CAP8] will shrink the parent arrays if the number of captured parents shrinks', () => {
113
310
  const atomA = atom('', 1)
114
311
  const atomAEpoch = getGlobalEpoch()
115
312
  advanceGlobalEpoch() // let's say time has passed
@@ -159,82 +356,4 @@ describe('capturing parents', () => {
159
356
  expect(child.parents).toBe(originalParents)
160
357
  expect(child.parentEpochs).toBe(originalParentEpochs)
161
358
  })
162
-
163
- it('doesnt do anything if you dont start capturing', () => {
164
- expect(() => {
165
- maybeCaptureParent(atom('', 1))
166
- }).not.toThrow()
167
- })
168
- })
169
-
170
- describe(unsafe__withoutCapture, () => {
171
- it('allows executing comptuer code in a context that short-circuits the current capture frame', () => {
172
- const atomA = atom('a', 1)
173
- const atomB = atom('b', 1)
174
- const atomC = atom('c', 1)
175
-
176
- const child = computed('', () => {
177
- return atomA.get() + atomB.get() + unsafe__withoutCapture(() => atomC.get())
178
- })
179
-
180
- let lastValue: number | undefined
181
- let numReactions = 0
182
-
183
- react('', () => {
184
- numReactions++
185
- lastValue = child.get()
186
- })
187
-
188
- expect(lastValue).toBe(3)
189
- expect(numReactions).toBe(1)
190
-
191
- atomA.set(2)
192
-
193
- expect(lastValue).toBe(4)
194
- expect(numReactions).toBe(2)
195
-
196
- atomB.set(2)
197
-
198
- expect(lastValue).toBe(5)
199
- expect(numReactions).toBe(3)
200
-
201
- atomC.set(2)
202
-
203
- // The reaction should not have run because C was not captured
204
- expect(lastValue).toBe(5)
205
- expect(numReactions).toBe(3)
206
- })
207
-
208
- it('allows executing reactor code in a context that short-circuits the current capture frame', () => {
209
- const atomA = atom('a', 1)
210
- const atomB = atom('b', 1)
211
- const atomC = atom('c', 1)
212
-
213
- let lastValue: number | undefined
214
- let numReactions = 0
215
-
216
- react('', () => {
217
- numReactions++
218
- lastValue = atomA.get() + atomB.get() + unsafe__withoutCapture(() => atomC.get())
219
- })
220
-
221
- expect(lastValue).toBe(3)
222
- expect(numReactions).toBe(1)
223
-
224
- atomA.set(2)
225
-
226
- expect(lastValue).toBe(4)
227
- expect(numReactions).toBe(2)
228
-
229
- atomB.set(2)
230
-
231
- expect(lastValue).toBe(5)
232
- expect(numReactions).toBe(3)
233
-
234
- atomC.set(2)
235
-
236
- // The reaction should not have run because C was not captured
237
- expect(lastValue).toBe(5)
238
- expect(numReactions).toBe(3)
239
- })
240
359
  })