@tldraw/state 5.1.1 → 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,50 +1,297 @@
1
+ import { vi } from 'vitest'
1
2
  import { atom } from '../Atom'
2
- import { EffectScheduler } from '../EffectScheduler'
3
+ import { computed } from '../Computed'
4
+ import { EffectScheduler, react, reactor } from '../EffectScheduler'
5
+ import { advanceGlobalEpoch, transact } from '../transactions'
6
+ import { RESET_VALUE } from '../types'
3
7
 
4
- describe(EffectScheduler, () => {
5
- test('when you detach and reattach, it retains the parents without rerunning', () => {
6
- const a = atom('a', 1)
7
- let numReactions = 0
8
- const scheduler = new EffectScheduler('test', () => {
8
+ // Tests for SPEC.md §9 (effects: EffectScheduler, react, reactor).
9
+ // Rule IDs like [E3] in test names refer to that document.
10
+
11
+ describe('react (E)', () => {
12
+ it('[E1] runs immediately, re-runs on changes, and stops when unsubscribed', () => {
13
+ const a = atom('', 234)
14
+
15
+ let val = 0
16
+ const stop = react('', () => {
17
+ val = a.get()
18
+ })
19
+
20
+ expect(val).toBe(234)
21
+
22
+ a.set(939)
23
+
24
+ expect(val).toBe(939)
25
+
26
+ stop()
27
+
28
+ a.set(2342)
29
+
30
+ expect(val).toBe(939)
31
+ expect(a.get()).toBe(2342)
32
+ })
33
+
34
+ it('[E1] detaches from its parents when stopped', () => {
35
+ const a = atom('', 1)
36
+
37
+ const rfn = vi.fn(() => {
9
38
  a.get()
10
- numReactions++
11
39
  })
12
- scheduler.attach()
13
- scheduler.execute()
14
- expect(numReactions).toBe(1)
40
+ const stop = react('', rfn)
41
+
42
+ expect(a.children.isEmpty).toBe(false)
43
+
44
+ a.set(8)
45
+
46
+ expect(rfn).toHaveBeenCalledTimes(2)
47
+
48
+ stop()
49
+
50
+ expect(a.children.isEmpty).toBe(true)
51
+
15
52
  a.set(2)
16
- expect(numReactions).toBe(2)
17
- scheduler.detach()
18
- expect(numReactions).toBe(2)
19
- scheduler.attach()
20
- expect(numReactions).toBe(2)
21
53
  a.set(3)
22
- expect(numReactions).toBe(3)
54
+
55
+ expect(rfn).toHaveBeenCalledTimes(2)
23
56
  })
24
57
 
25
- test('when you detach and reattach, it retains the parents while rerunning if the parent has changed', () => {
26
- const a = atom('a', 1)
27
- let numReactions = 0
28
- const scheduler = new EffectScheduler('test', () => {
58
+ it('[E4] does not re-run when a parent is set to an equal value', () => {
59
+ const a = atom('', 'x')
60
+ const effect = vi.fn(() => {
29
61
  a.get()
30
- numReactions++
31
62
  })
32
- scheduler.attach()
33
- scheduler.execute()
34
- expect(numReactions).toBe(1)
35
- a.set(2)
36
- expect(numReactions).toBe(2)
37
- scheduler.detach()
63
+ const stop = react('', effect)
64
+
65
+ expect(effect).toHaveBeenCalledTimes(1)
66
+
67
+ a.set('x')
68
+
69
+ expect(effect).toHaveBeenCalledTimes(1)
70
+ stop()
71
+ })
72
+
73
+ it('[E4] does not re-run when a computed parent recomputes to an equal value', () => {
74
+ const a = atom('', 1.2)
75
+ const floored = computed('', () => Math.floor(a.get()))
76
+ const effect = vi.fn(() => {
77
+ floored.get()
78
+ })
79
+ const stop = react('', effect)
80
+
81
+ expect(effect).toHaveBeenCalledTimes(1)
82
+
83
+ a.set(1.5)
84
+
85
+ expect(effect).toHaveBeenCalledTimes(1)
86
+
87
+ a.set(2.3)
88
+
89
+ expect(effect).toHaveBeenCalledTimes(2)
90
+ stop()
91
+ })
92
+
93
+ it('[E5] passes the epoch of the previous run, enabling incremental effects', () => {
94
+ const a = atom('', 1, { historyLength: 5, computeDiff: (a, b) => b - a })
95
+
96
+ const collected: Array<number[] | RESET_VALUE> = []
97
+ const stop = react('', (lastReactedEpoch) => {
98
+ collected.push(a.getDiffSince(lastReactedEpoch))
99
+ })
100
+
101
+ // the first run has no previous epoch, so the effect must initialize from scratch
102
+ expect(collected).toEqual([RESET_VALUE])
103
+
38
104
  a.set(3)
39
- expect(numReactions).toBe(2)
40
- scheduler.attach()
41
- scheduler.execute()
42
- expect(numReactions).toBe(3)
43
- a.set(4)
44
- expect(numReactions).toBe(4)
105
+ expect(collected).toEqual([RESET_VALUE, [+2]])
106
+
107
+ a.set(6)
108
+ expect(collected).toEqual([RESET_VALUE, [+2], [+3]])
109
+
110
+ stop()
111
+ })
112
+ })
113
+
114
+ describe('reactor (E)', () => {
115
+ it('[E2] can be started and stopped', () => {
116
+ const a = atom('', 1)
117
+ const r = reactor('', () => {
118
+ a.get()
119
+ })
120
+ expect(r.scheduler.isActivelyListening).toBe(false)
121
+ r.start()
122
+ expect(r.scheduler.isActivelyListening).toBe(true)
123
+ r.stop()
124
+ expect(r.scheduler.isActivelyListening).toBe(false)
125
+ r.start()
126
+ expect(r.scheduler.isActivelyListening).toBe(true)
127
+ })
128
+
129
+ it('[E2] start() does not re-run the effect if nothing changed while stopped', () => {
130
+ const a = atom('', 1)
131
+
132
+ const rfn = vi.fn(() => {
133
+ a.get()
134
+ })
135
+
136
+ const r = reactor('', rfn)
137
+ r.start()
138
+
139
+ expect(rfn).toHaveBeenCalledTimes(1)
140
+
141
+ r.stop()
142
+
143
+ r.start()
144
+
145
+ expect(rfn).toHaveBeenCalledTimes(1)
146
+ })
147
+
148
+ it('[E2] start() re-runs the effect if a parent changed while stopped', () => {
149
+ const a = atom('', 1)
150
+
151
+ const rfn = vi.fn(() => {
152
+ a.get()
153
+ })
154
+
155
+ const r = reactor('', rfn)
156
+ r.start()
157
+
158
+ expect(rfn).toHaveBeenCalledTimes(1)
159
+
160
+ r.stop()
161
+ a.set(2)
162
+
163
+ r.start()
164
+
165
+ expect(rfn).toHaveBeenCalledTimes(2)
45
166
  })
46
167
 
47
- test('when an effect is scheduled it increments a schedule count, even if the effect never runs', () => {
168
+ it('[E2] start({force: true}) re-runs the effect even if nothing has changed', () => {
169
+ const a = atom('', 1)
170
+
171
+ const rfn = vi.fn(() => {
172
+ a.get()
173
+ })
174
+
175
+ const r = reactor('', rfn)
176
+ r.start()
177
+
178
+ expect(rfn).toHaveBeenCalledTimes(1)
179
+
180
+ r.stop()
181
+
182
+ r.start({ force: true })
183
+
184
+ expect(rfn).toHaveBeenCalledTimes(2)
185
+ })
186
+
187
+ it('[E3] runs once per state change, even when several of its parents changed', () => {
188
+ const atomA = atom('', 1)
189
+ const atomB = atom('', 1)
190
+
191
+ const rfn = vi.fn(() => {
192
+ atomA.get()
193
+ atomB.get()
194
+ })
195
+ const r = reactor('', rfn)
196
+
197
+ r.start()
198
+ expect(rfn).toHaveBeenCalledTimes(1)
199
+
200
+ transact(() => {
201
+ atomA.set(2)
202
+ atomB.set(2)
203
+ })
204
+
205
+ expect(rfn).toHaveBeenCalledTimes(2)
206
+ })
207
+
208
+ it('[E9] maybeScheduleEffect is a no-op when the reactor is stopped', () => {
209
+ const a = atom('', 1)
210
+ const rfn = vi.fn(() => {
211
+ a.get()
212
+ })
213
+ const r = reactor('', rfn)
214
+
215
+ r.scheduler.maybeScheduleEffect()
216
+
217
+ expect(rfn).not.toHaveBeenCalled()
218
+ })
219
+
220
+ it('[E9] maybeScheduleEffect is a no-op when the parents have not changed', () => {
221
+ const a = atom('', 1)
222
+ const rfn = vi
223
+ .fn(() => {
224
+ a.get()
225
+ })
226
+ .mockName('rfn')
227
+ const r = reactor('', rfn)
228
+
229
+ r.start()
230
+ expect(rfn).toHaveBeenCalledTimes(1)
231
+
232
+ advanceGlobalEpoch()
233
+ r.scheduler.maybeScheduleEffect()
234
+ expect(rfn).toHaveBeenCalledTimes(1)
235
+ })
236
+ })
237
+
238
+ describe('custom scheduling (E6, E7)', () => {
239
+ it('[E6] a custom scheduleEffect decouples scheduling from execution', () => {
240
+ const a = atom('', 1)
241
+ const scheduled: Array<() => void> = []
242
+ let runs = 0
243
+
244
+ react(
245
+ '',
246
+ () => {
247
+ a.get()
248
+ runs++
249
+ },
250
+ {
251
+ scheduleEffect: (execute) => {
252
+ scheduled.push(execute)
253
+ },
254
+ }
255
+ )
256
+
257
+ // the initial run of react() is also routed through scheduleEffect
258
+ expect(scheduled).toHaveLength(1)
259
+ expect(runs).toBe(0)
260
+
261
+ scheduled.shift()!()
262
+ expect(runs).toBe(1)
263
+
264
+ a.set(2)
265
+
266
+ expect(scheduled).toHaveLength(1)
267
+ expect(runs).toBe(1)
268
+
269
+ scheduled.shift()!()
270
+ expect(runs).toBe(2)
271
+ })
272
+
273
+ it('[E6] reactor.start() with a custom scheduler only schedules, it does not execute', () => {
274
+ let numSchedules = 0
275
+ let numExecutes = 0
276
+
277
+ const r = reactor(
278
+ '',
279
+ () => {
280
+ numExecutes++
281
+ },
282
+ {
283
+ scheduleEffect: () => {
284
+ numSchedules++
285
+ },
286
+ }
287
+ )
288
+ r.start()
289
+
290
+ expect(numSchedules).toBe(1)
291
+ expect(numExecutes).toBe(0)
292
+ })
293
+
294
+ it('[E6] scheduleCount counts scheduling events even if the effect never executes', () => {
48
295
  const a = atom('a', 1)
49
296
  let numReactions = 0
50
297
  let numSchedules = 0
@@ -79,4 +326,78 @@ describe(EffectScheduler, () => {
79
326
  expect(numSchedules).toBe(2)
80
327
  expect(numReactions).toBe(1)
81
328
  })
329
+
330
+ it('[E7] a deferred execute callback is a no-op if the effect was detached in the meantime', () => {
331
+ const a = atom('a', 1)
332
+ const scheduled: Array<() => void> = []
333
+ let runs = 0
334
+
335
+ const r = reactor(
336
+ '',
337
+ () => {
338
+ a.get()
339
+ runs++
340
+ },
341
+ {
342
+ scheduleEffect: (execute) => {
343
+ scheduled.push(execute)
344
+ },
345
+ }
346
+ )
347
+ r.start()
348
+ scheduled.shift()!()
349
+ expect(runs).toBe(1)
350
+
351
+ a.set(2)
352
+ expect(scheduled).toHaveLength(1)
353
+
354
+ r.stop()
355
+ scheduled.shift()!()
356
+
357
+ expect(runs).toBe(1)
358
+ })
359
+ })
360
+
361
+ describe('EffectScheduler attach/detach (E8)', () => {
362
+ it('[E8] retains parents across detach/attach without re-running if nothing changed', () => {
363
+ const a = atom('a', 1)
364
+ let numReactions = 0
365
+ const scheduler = new EffectScheduler('test', () => {
366
+ a.get()
367
+ numReactions++
368
+ })
369
+ scheduler.attach()
370
+ scheduler.execute()
371
+ expect(numReactions).toBe(1)
372
+ a.set(2)
373
+ expect(numReactions).toBe(2)
374
+ scheduler.detach()
375
+ expect(numReactions).toBe(2)
376
+ scheduler.attach()
377
+ expect(numReactions).toBe(2)
378
+ a.set(3)
379
+ expect(numReactions).toBe(3)
380
+ })
381
+
382
+ it('[E8] does not observe changes while detached, but sees them after re-attaching and executing', () => {
383
+ const a = atom('a', 1)
384
+ let numReactions = 0
385
+ const scheduler = new EffectScheduler('test', () => {
386
+ a.get()
387
+ numReactions++
388
+ })
389
+ scheduler.attach()
390
+ scheduler.execute()
391
+ expect(numReactions).toBe(1)
392
+ a.set(2)
393
+ expect(numReactions).toBe(2)
394
+ scheduler.detach()
395
+ a.set(3)
396
+ expect(numReactions).toBe(2)
397
+ scheduler.attach()
398
+ scheduler.execute()
399
+ expect(numReactions).toBe(3)
400
+ a.set(4)
401
+ expect(numReactions).toBe(4)
402
+ })
82
403
  })
@@ -1,8 +1,11 @@
1
1
  import { HistoryBuffer } from '../HistoryBuffer'
2
2
  import { RESET_VALUE } from '../types'
3
3
 
4
+ // Tests for SPEC.md §17 (HistoryBuffer, internal).
5
+ // Rule IDs like [HB2] in test names refer to that document.
6
+
4
7
  describe('HistoryBuffer', () => {
5
- it('should wrap around', () => {
8
+ it('[HB2][HB3] should wrap around', () => {
6
9
  const buf = new HistoryBuffer<string>(3)
7
10
  buf.pushEntry(0, 1, 'a')
8
11
  expect(buf.getChangesSince(0)).toEqual(['a'])
@@ -39,7 +42,21 @@ describe('HistoryBuffer', () => {
39
42
  expect(buf.getChangesSince(5)).toEqual([])
40
43
  })
41
44
 
42
- it('will clear if you push RESET_VALUE', () => {
45
+ it('[HB2] returns RESET_VALUE when the buffer is empty', () => {
46
+ const buf = new HistoryBuffer<string>(3)
47
+ expect(buf.getChangesSince(0)).toEqual(RESET_VALUE)
48
+ })
49
+
50
+ it('[HB1] ignores undefined diffs', () => {
51
+ const buf = new HistoryBuffer<string>(3)
52
+ buf.pushEntry(0, 1, 'a')
53
+ buf.pushEntry(1, 2, undefined as any)
54
+
55
+ expect(buf.getChangesSince(0)).toEqual(['a'])
56
+ expect(buf.getChangesSince(1)).toEqual([])
57
+ })
58
+
59
+ it('[HB1] will clear if you push RESET_VALUE', () => {
43
60
  const buf = new HistoryBuffer<string>(10)
44
61
  buf.pushEntry(0, 1, 'a')
45
62
  buf.pushEntry(1, 2, 'b')