@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,12 +1,13 @@
1
- import { promiseWithResolve, sleep } from '@tldraw/utils'
2
- import { vi } from 'vitest'
3
1
  import { atom } from '../Atom'
4
2
  import { computed } from '../Computed'
5
3
  import { react } from '../EffectScheduler'
6
- import { deferAsyncEffects, transact, transaction } from '../transactions'
4
+ import { getGlobalEpoch, transact, transaction } from '../transactions'
7
5
 
8
- describe('transactions', () => {
9
- it('should be abortable', () => {
6
+ // Tests for SPEC.md §11 (transactions), plus rule EP6.
7
+ // Rule IDs like [T4] in test names refer to that document.
8
+
9
+ describe('transactions (T)', () => {
10
+ it('[T2][T3][T4][T6] batch changes, defer effects, and can be rolled back', () => {
10
11
  const firstName = atom('', 'John')
11
12
  const lastName = atom('', 'Doe')
12
13
 
@@ -32,11 +33,13 @@ describe('transactions', () => {
32
33
  firstName.set('Wilbur')
33
34
  expect(numTimesComputed).toBe(1)
34
35
  expect(numTimesReacted).toBe(1)
36
+ // [T6] effects never observe intermediate in-transaction values
35
37
  expect(name).toBe('John Doe')
36
38
  lastName.set('Jones')
37
39
  expect(numTimesComputed).toBe(1)
38
40
  expect(numTimesReacted).toBe(1)
39
41
  expect(name).toBe('John Doe')
42
+ // [T2] reads inside the transaction see the latest values
40
43
  expect(fullName.get()).toBe('Wilbur Jones')
41
44
 
42
45
  expect(numTimesComputed).toBe(2)
@@ -46,7 +49,7 @@ describe('transactions', () => {
46
49
  rollback()
47
50
  })
48
51
 
49
- // computes again
52
+ // [T6] the aborted transaction still flushes effects, which observe the restored values
50
53
  expect(numTimesComputed).toBe(3)
51
54
  expect(numTimesReacted).toBe(2)
52
55
 
@@ -54,7 +57,26 @@ describe('transactions', () => {
54
57
  expect(name).toBe('John Doe')
55
58
  })
56
59
 
57
- it('nested rollbacks work as expected', () => {
60
+ it('[T1] returns the value of the function, even when rolled back', () => {
61
+ expect(transaction(() => 'hello')).toBe('hello')
62
+ expect(transact(() => 42)).toBe(42)
63
+ expect(
64
+ transaction((rollback) => {
65
+ rollback()
66
+ return 'rolled back'
67
+ })
68
+ ).toBe('rolled back')
69
+ })
70
+
71
+ it('[EP6] advances the global epoch when aborted', () => {
72
+ const startEpoch = getGlobalEpoch()
73
+ transaction((rollback) => {
74
+ rollback()
75
+ })
76
+ expect(getGlobalEpoch()).toBeGreaterThan(startEpoch)
77
+ })
78
+
79
+ it('[T7] nested transactions roll back independently', () => {
58
80
  const atomA = atom('', 0)
59
81
  const atomB = atom('', 0)
60
82
 
@@ -148,7 +170,7 @@ describe('transactions', () => {
148
170
  expect(atomB.get()).toBe(-2)
149
171
  })
150
172
 
151
- it('should restore the original even if an inner commits', () => {
173
+ it('[T7] an outer rollback undoes a committed inner transaction', () => {
152
174
  const a = atom('', 'a')
153
175
 
154
176
  transaction((rollback) => {
@@ -160,10 +182,26 @@ describe('transactions', () => {
160
182
 
161
183
  expect(a.get()).toBe('a')
162
184
  })
185
+
186
+ it('[T4] rollback restores computed signals too', () => {
187
+ const firstName = atom('', 'John')
188
+ const lastName = atom('', 'Doe')
189
+
190
+ const fullName = computed('', () => `${firstName.get()} ${lastName.get()}`)
191
+
192
+ transaction((rollback) => {
193
+ firstName.set('Jane')
194
+ lastName.set('Jones')
195
+ expect(fullName.get()).toBe('Jane Jones')
196
+ rollback()
197
+ })
198
+
199
+ expect(fullName.get()).toBe('John Doe')
200
+ })
163
201
  })
164
202
 
165
- describe('transact', () => {
166
- it('executes things in a transaction', () => {
203
+ describe('transact (T)', () => {
204
+ it('[T5] aborts and rethrows if the function throws', () => {
167
205
  const a = atom('', 'a')
168
206
 
169
207
  try {
@@ -180,7 +218,7 @@ describe('transact', () => {
180
218
  expect.assertions(2)
181
219
  })
182
220
 
183
- it('does not create nested transactions', () => {
221
+ it('[T1][T8] joins the current transaction instead of nesting, so an inner throw restores nothing', () => {
184
222
  const a = atom('', 'a')
185
223
 
186
224
  transact(() => {
@@ -202,427 +240,3 @@ describe('transact', () => {
202
240
  expect.assertions(3)
203
241
  })
204
242
  })
205
-
206
- describe('setting atoms during a reaction', () => {
207
- it('should work', () => {
208
- const a = atom('', 0)
209
- const b = atom('', 0)
210
-
211
- react('', () => {
212
- b.set(a.get() + 1)
213
- })
214
-
215
- expect(a.get()).toBe(0)
216
- expect(b.get()).toBe(1)
217
- })
218
-
219
- it('should throw an error if it gets into a loop', () => {
220
- expect(() => {
221
- const a = atom('', 0)
222
-
223
- react('', () => {
224
- a.set(a.get() + 1)
225
- })
226
- }).toThrowErrorMatchingInlineSnapshot(`[Error: Reaction update depth limit exceeded]`)
227
- })
228
-
229
- it('should work with a transaction running', () => {
230
- const a = atom('', 0)
231
-
232
- react('', () => {
233
- transact(() => {
234
- if (a.get() < 10) {
235
- a.set(a.get() + 1)
236
- }
237
- })
238
- })
239
-
240
- expect(a.get()).toBe(10)
241
- })
242
-
243
- it('[regression 1] should allow computeds to be updated properly', () => {
244
- const a = atom('', 0)
245
- const b = atom('', 0)
246
- const c = computed('', () => b.get() * 2)
247
-
248
- let cValue = 0
249
-
250
- react('', () => {
251
- b.set(a.get() + 1)
252
- cValue = c.get()
253
- })
254
-
255
- expect(a.get()).toBe(0)
256
- expect(b.get()).toBe(1)
257
- expect(cValue).toBe(2)
258
-
259
- transact(() => {
260
- a.set(1)
261
- })
262
- expect(cValue).toBe(4)
263
- })
264
-
265
- it('[regression 2] should allow computeds to be updated properly', () => {
266
- const a = atom('', 0)
267
- const b = atom('', 1)
268
- const c = atom('', 0)
269
- const d = computed('', () => a.get() * 2)
270
-
271
- let dValue = 0
272
- react('', () => {
273
- // update a, causes a and d to be traversed (but not updated)
274
- a.set(b.get())
275
- // update c
276
- c.set(a.get())
277
- // make sure that when we get d, it is updated properly
278
- dValue = d.get()
279
- })
280
-
281
- expect(a.get()).toBe(1)
282
- expect(b.get()).toBe(1)
283
- expect(c.get()).toBe(1)
284
-
285
- expect(dValue).toBe(2)
286
-
287
- transact(() => {
288
- b.set(2)
289
- })
290
- expect(dValue).toBe(4)
291
- })
292
- })
293
-
294
- test('it should be possible to run a transaction during a reaction', () => {
295
- const a = atom('', 0)
296
- const b = atom('', 0)
297
-
298
- react('', () => {
299
- transaction(() => {
300
- b.set(a.get() + 1)
301
- })
302
- })
303
-
304
- expect(a.get()).toBe(0)
305
- expect(b.get()).toBe(1)
306
-
307
- a.set(1)
308
-
309
- expect(b.get()).toBe(2)
310
-
311
- transaction(() => {
312
- a.set(2)
313
- expect(b.get()).toBe(2)
314
- })
315
-
316
- expect(b.get()).toBe(3)
317
- })
318
-
319
- test('it should be possible to abort a transaction during a reaction', () => {
320
- const a = atom('', 0)
321
- const b = atom('', 0)
322
-
323
- const unsub = react('', () => {
324
- transaction((rollback) => {
325
- b.set(a.get() + 1)
326
- rollback()
327
- })
328
- expect(b.get()).toBe(0)
329
- })
330
-
331
- expect(a.get()).toBe(0)
332
- expect(b.get()).toBe(0)
333
-
334
- unsub()
335
-
336
- react('', () => {
337
- transaction(() => {
338
- b.set(3)
339
- try {
340
- transaction(() => {
341
- b.set(a.get() + 1)
342
- throw new Error('oops')
343
- })
344
- } catch (e: any) {
345
- expect(e.message).toBe('oops')
346
- } finally {
347
- expect(b.get()).toBe(3)
348
- }
349
- })
350
- expect(b.get()).toBe(3)
351
- })
352
-
353
- expect(a.get()).toBe(0)
354
- expect(b.get()).toBe(3)
355
-
356
- expect.assertions(8)
357
- })
358
-
359
- it('should defer all side effects until the end of the outer transaction', () => {
360
- const a = atom('', 0)
361
- const b = atom('', 0)
362
- const c = atom('', 0)
363
-
364
- const aChanged = vi.fn()
365
- const bChanged = vi.fn()
366
- const cChanged = vi.fn()
367
-
368
- react('', () => {
369
- a.get()
370
- aChanged()
371
- })
372
-
373
- react('', () => {
374
- transaction(() => {
375
- a.set(b.get() + 1)
376
- })
377
- bChanged()
378
- })
379
-
380
- react('', () => {
381
- transaction(() => {
382
- b.set(c.get() + 1)
383
- })
384
- cChanged()
385
- })
386
-
387
- expect(aChanged).toHaveBeenCalledTimes(3)
388
- expect(bChanged).toHaveBeenCalledTimes(2)
389
- expect(cChanged).toHaveBeenCalledTimes(1)
390
-
391
- expect(a.__unsafe__getWithoutCapture()).toBe(2)
392
-
393
- cChanged.mockImplementationOnce(() => {
394
- // b was .set() during c's reaction
395
- expect(b.__unsafe__getWithoutCapture()).toBe(2)
396
- // a was not yet set because the effect was deferred
397
- // util the end of the reaction
398
- expect(a.__unsafe__getWithoutCapture()).toBe(2)
399
- })
400
-
401
- c.set(1)
402
-
403
- expect(a.__unsafe__getWithoutCapture()).toBe(3)
404
- expect(cChanged).toHaveBeenCalledTimes(2)
405
- })
406
-
407
- describe('asyncTransaction', () => {
408
- it('works if kicked off during a reaction', async () => {
409
- const a = atom('', 0)
410
- const b = atom('', 0)
411
-
412
- let txp: any = null
413
-
414
- react('', () => {
415
- a.get()
416
- txp = deferAsyncEffects(async () => {
417
- await sleep(1)
418
- b.set(a.get() + 1)
419
- })
420
- })
421
-
422
- await txp
423
-
424
- expect(a.get()).toBe(0)
425
- expect(b.get()).toBe(1)
426
-
427
- a.set(1)
428
-
429
- await txp
430
-
431
- expect(a.get()).toBe(1)
432
- expect(b.get()).toBe(2)
433
- })
434
-
435
- it('throws an error if kicked off during a sync transaction', async () => {
436
- const a = atom('', 0)
437
- let txp: any = null
438
- transact(() => {
439
- txp = deferAsyncEffects(async () => {
440
- expect(a.get()).toBe(1)
441
- a.set(2)
442
- })
443
- a.set(1)
444
- })
445
-
446
- await expect(txp).rejects.toMatchInlineSnapshot(
447
- `[Error: deferAsyncEffects cannot be called during a sync transaction]`
448
- )
449
- })
450
-
451
- it('can have nested sync transactions', async () => {
452
- const a = atom('', 0)
453
-
454
- await deferAsyncEffects(async () => {
455
- a.set(1)
456
- transaction(() => {
457
- a.set(2)
458
- })
459
- expect(a.get()).toBe(2)
460
- })
461
- expect(a.get()).toBe(2)
462
- })
463
-
464
- it('can have nested async transactions', async () => {
465
- const a = atom('', 0)
466
-
467
- await deferAsyncEffects(async () => {
468
- a.set(1)
469
- await deferAsyncEffects(async () => {
470
- a.set(2)
471
- })
472
- expect(a.get()).toBe(2)
473
- })
474
- expect(a.get()).toBe(2)
475
- })
476
-
477
- it('allows transact to be called inside asyncTransaction', async () => {
478
- const a = atom('', 0)
479
-
480
- await deferAsyncEffects(async () => {
481
- a.set(1)
482
- transact(() => {
483
- a.set(2)
484
- })
485
- expect(a.get()).toBe(2)
486
- })
487
- expect(a.get()).toBe(2)
488
- })
489
-
490
- it('allows overlapping transactions', async () => {
491
- const a = atom('', 0)
492
-
493
- let txp = null
494
-
495
- const p = deferAsyncEffects(async () => {
496
- a.set(1)
497
- const x = promiseWithResolve()
498
- txp = deferAsyncEffects(async () => {
499
- a.set(2)
500
- x.resolve(null)
501
- await sleep(10)
502
- a.set(3)
503
- return 'inner'
504
- })
505
- await x
506
- // inner transactions leak, this can't be avoided without AsyncContext
507
- // but at least we can group effects.
508
- expect(a.get()).toBe(2)
509
- return 'outer'
510
- })
511
-
512
- await expect(p).resolves.toBe('outer')
513
- await expect(txp).resolves.toBe('inner')
514
- expect(a.get()).toBe(3)
515
- })
516
- })
517
-
518
- describe('async tests generated by claude', () => {
519
- // Add these tests to your existing asyncTransaction describe block
520
-
521
- it('should rollback on exception', async () => {
522
- const a = atom('', 0)
523
- const b = atom('', 0)
524
-
525
- await expect(
526
- deferAsyncEffects(async () => {
527
- a.set(1)
528
- b.set(2)
529
- throw new Error('test error')
530
- })
531
- ).rejects.toThrow('test error')
532
-
533
- expect(a.get()).toBe(0)
534
- expect(b.get()).toBe(0)
535
- })
536
-
537
- it('should defer effects until async transaction commits', async () => {
538
- const a = atom('', 0)
539
- const b = atom('', 0)
540
- const effectCalls = vi.fn()
541
-
542
- react('', () => {
543
- a.get()
544
- b.get()
545
- effectCalls()
546
- })
547
-
548
- expect(effectCalls).toHaveBeenCalledTimes(1)
549
-
550
- const txPromise = deferAsyncEffects(async () => {
551
- a.set(1)
552
- expect(effectCalls).toHaveBeenCalledTimes(1) // no effect yet
553
- await sleep(1)
554
- b.set(2)
555
- expect(effectCalls).toHaveBeenCalledTimes(1) // still no effect
556
- })
557
-
558
- await txPromise
559
- expect(effectCalls).toHaveBeenCalledTimes(2) // effect runs after commit
560
- })
561
-
562
- it('should handle computed signals properly in async transactions', async () => {
563
- const a = atom('', 1)
564
- const doubled = computed('', () => a.get() * 2)
565
-
566
- expect(doubled.get()).toBe(2)
567
-
568
- await deferAsyncEffects(async () => {
569
- a.set(5)
570
- // computed should update during transaction
571
- expect(doubled.get()).toBe(10)
572
- await sleep(1)
573
- a.set(10)
574
- expect(doubled.get()).toBe(20)
575
- })
576
-
577
- // computed should update after commit
578
- expect(doubled.get()).toBe(20)
579
- expect(a.get()).toBe(10)
580
- })
581
-
582
- it('should handle multiple concurrent async transactions', async () => {
583
- const a = atom('', 0)
584
- const b = atom('', 0)
585
- const results: number[] = []
586
-
587
- const tx1 = deferAsyncEffects(async () => {
588
- a.set(1)
589
- await sleep(10)
590
- results.push(a.get())
591
- return 'tx1'
592
- })
593
-
594
- const tx2 = deferAsyncEffects(async () => {
595
- b.set(2)
596
- await sleep(5)
597
- results.push(b.get())
598
- return 'tx2'
599
- })
600
-
601
- const [result1, result2] = await Promise.all([tx1, tx2])
602
-
603
- expect(result1).toBe('tx1')
604
- expect(result2).toBe('tx2')
605
- expect(a.get()).toBe(1)
606
- expect(b.get()).toBe(2)
607
- expect(results).toEqual([2, 1])
608
- })
609
-
610
- it('should handle exception in nested async transaction', async () => {
611
- const a = atom('', 0)
612
- const b = atom('', 0)
613
-
614
- await expect(
615
- deferAsyncEffects(async () => {
616
- a.set(1)
617
-
618
- await deferAsyncEffects(async () => {
619
- b.set(2)
620
- throw new Error('inner error')
621
- })
622
- })
623
- ).rejects.toThrow('inner error')
624
-
625
- expect(a.get()).toBe(0) // all changes should be rolled back
626
- expect(b.get()).toBe(0)
627
- })
628
- })