@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.
@@ -0,0 +1,84 @@
1
+ import { vi } from 'vitest'
2
+ import { atom } from '../Atom'
3
+ import { whyAmIRunning } from '../capture'
4
+ import { computed } from '../Computed'
5
+ import { react, reactor } from '../EffectScheduler'
6
+
7
+ // Tests for SPEC.md §13 (debugging aids).
8
+ // Rule IDs like [D2] in test names refer to that document.
9
+
10
+ describe('whyAmIRunning (D)', () => {
11
+ it('[D2] throws when called outside of a reactive context', () => {
12
+ expect(() => whyAmIRunning()).toThrow('whyAmIRunning() called outside of a reactive context')
13
+ })
14
+
15
+ it('[D3] logs which ancestor atoms caused an effect to run', () => {
16
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {})
17
+
18
+ const name = atom('name', 'Bob')
19
+ const stop = react('greeting', () => {
20
+ whyAmIRunning()
21
+ name.get()
22
+ })
23
+
24
+ expect(log).not.toHaveBeenCalled()
25
+
26
+ name.set('Alice')
27
+
28
+ expect(log).toHaveBeenCalledTimes(1)
29
+ expect(log.mock.calls[0][0]).toContain('Effect(greeting) is executing because:')
30
+ expect(log.mock.calls[0][0]).toContain('Atom(name) changed')
31
+
32
+ stop()
33
+ log.mockRestore()
34
+ })
35
+
36
+ it('[D3] logs which ancestors caused a computed to recompute, including nested computeds', () => {
37
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {})
38
+
39
+ const a = atom('a', 1)
40
+ const double = computed('double', () => a.get() * 2)
41
+ const quadruple = computed('quadruple', () => {
42
+ whyAmIRunning()
43
+ return double.get() * 2
44
+ })
45
+
46
+ const stop = react('r', () => {
47
+ quadruple.get()
48
+ })
49
+
50
+ expect(log).not.toHaveBeenCalled()
51
+
52
+ a.set(2)
53
+
54
+ expect(log).toHaveBeenCalledTimes(1)
55
+ expect(log.mock.calls[0][0]).toContain('Computed(quadruple) is recomputing because:')
56
+ expect(log.mock.calls[0][0]).toContain('Computed(double) changed')
57
+ expect(log.mock.calls[0][0]).toContain('Atom(a) changed')
58
+
59
+ stop()
60
+ log.mockRestore()
61
+ })
62
+
63
+ it('[D3] logs that an effect was executed manually when no ancestors changed', () => {
64
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {})
65
+
66
+ const a = atom('a', 1)
67
+ const r = reactor('manual', () => {
68
+ whyAmIRunning()
69
+ a.get()
70
+ })
71
+
72
+ r.start()
73
+ expect(log).not.toHaveBeenCalled()
74
+
75
+ r.stop()
76
+ r.start({ force: true })
77
+
78
+ expect(log).toHaveBeenCalledTimes(1)
79
+ expect(log.mock.calls[0][0]).toContain('Effect(manual) was executed manually.')
80
+
81
+ r.stop()
82
+ log.mockRestore()
83
+ })
84
+ })
@@ -0,0 +1,232 @@
1
+ import { promiseWithResolve, sleep } from '@tldraw/utils'
2
+ import { vi } from 'vitest'
3
+ import { atom } from '../Atom'
4
+ import { computed } from '../Computed'
5
+ import { react } from '../EffectScheduler'
6
+ import { deferAsyncEffects, transact, transaction } from '../transactions'
7
+
8
+ // Tests for SPEC.md §12 (async transactions: deferAsyncEffects).
9
+ // Rule IDs like [AT3] in test names refer to that document.
10
+
11
+ describe('deferAsyncEffects (AT)', () => {
12
+ it('[AT1] defers effects until the async transaction commits', async () => {
13
+ const a = atom('', 0)
14
+ const b = atom('', 0)
15
+ const effectCalls = vi.fn()
16
+
17
+ react('', () => {
18
+ a.get()
19
+ b.get()
20
+ effectCalls()
21
+ })
22
+
23
+ expect(effectCalls).toHaveBeenCalledTimes(1)
24
+
25
+ const txPromise = deferAsyncEffects(async () => {
26
+ a.set(1)
27
+ expect(effectCalls).toHaveBeenCalledTimes(1) // no effect yet
28
+ await sleep(1)
29
+ b.set(2)
30
+ expect(effectCalls).toHaveBeenCalledTimes(1) // still no effect
31
+ })
32
+
33
+ await txPromise
34
+ expect(effectCalls).toHaveBeenCalledTimes(2) // effect runs after commit
35
+ })
36
+
37
+ it('[AT1] keeps computed signals up to date during the async transaction', async () => {
38
+ const a = atom('', 1)
39
+ const doubled = computed('', () => a.get() * 2)
40
+
41
+ expect(doubled.get()).toBe(2)
42
+
43
+ await deferAsyncEffects(async () => {
44
+ a.set(5)
45
+ // computed should update during transaction
46
+ expect(doubled.get()).toBe(10)
47
+ await sleep(1)
48
+ a.set(10)
49
+ expect(doubled.get()).toBe(20)
50
+ })
51
+
52
+ // computed should update after commit
53
+ expect(doubled.get()).toBe(20)
54
+ expect(a.get()).toBe(10)
55
+ })
56
+
57
+ it('[AT2] throws if kicked off during a sync transaction', async () => {
58
+ const a = atom('', 0)
59
+ let txp: any = null
60
+ transact(() => {
61
+ txp = deferAsyncEffects(async () => {
62
+ expect(a.get()).toBe(1)
63
+ a.set(2)
64
+ })
65
+ a.set(1)
66
+ })
67
+
68
+ await expect(txp).rejects.toMatchInlineSnapshot(
69
+ `[Error: deferAsyncEffects cannot be called during a sync transaction]`
70
+ )
71
+ })
72
+
73
+ it('[AT2] allows sync transactions inside the async body', async () => {
74
+ const a = atom('', 0)
75
+
76
+ await deferAsyncEffects(async () => {
77
+ a.set(1)
78
+ transaction(() => {
79
+ a.set(2)
80
+ })
81
+ expect(a.get()).toBe(2)
82
+ })
83
+ expect(a.get()).toBe(2)
84
+ })
85
+
86
+ it('[AT2] allows transact inside the async body', async () => {
87
+ const a = atom('', 0)
88
+
89
+ await deferAsyncEffects(async () => {
90
+ a.set(1)
91
+ transact(() => {
92
+ a.set(2)
93
+ })
94
+ expect(a.get()).toBe(2)
95
+ })
96
+ expect(a.get()).toBe(2)
97
+ })
98
+
99
+ it('[AT3] rolls back all changes on exception', async () => {
100
+ const a = atom('', 0)
101
+ const b = atom('', 0)
102
+
103
+ await expect(
104
+ deferAsyncEffects(async () => {
105
+ a.set(1)
106
+ b.set(2)
107
+ throw new Error('test error')
108
+ })
109
+ ).rejects.toThrow('test error')
110
+
111
+ expect(a.get()).toBe(0)
112
+ expect(b.get()).toBe(0)
113
+ })
114
+
115
+ it('[AT3][AT4] rolls back everything when a nested async transaction throws', async () => {
116
+ const a = atom('', 0)
117
+ const b = atom('', 0)
118
+
119
+ await expect(
120
+ deferAsyncEffects(async () => {
121
+ a.set(1)
122
+
123
+ await deferAsyncEffects(async () => {
124
+ b.set(2)
125
+ throw new Error('inner error')
126
+ })
127
+ })
128
+ ).rejects.toThrow('inner error')
129
+
130
+ expect(a.get()).toBe(0) // all changes should be rolled back
131
+ expect(b.get()).toBe(0)
132
+ })
133
+
134
+ it('[AT4] nested async transactions join the outer one', async () => {
135
+ const a = atom('', 0)
136
+
137
+ await deferAsyncEffects(async () => {
138
+ a.set(1)
139
+ await deferAsyncEffects(async () => {
140
+ a.set(2)
141
+ })
142
+ expect(a.get()).toBe(2)
143
+ })
144
+ expect(a.get()).toBe(2)
145
+ })
146
+
147
+ it('[AT4] concurrent async transactions are merged', async () => {
148
+ const a = atom('', 0)
149
+ const b = atom('', 0)
150
+ const results: number[] = []
151
+
152
+ const tx1 = deferAsyncEffects(async () => {
153
+ a.set(1)
154
+ await sleep(10)
155
+ results.push(a.get())
156
+ return 'tx1'
157
+ })
158
+
159
+ const tx2 = deferAsyncEffects(async () => {
160
+ b.set(2)
161
+ await sleep(5)
162
+ results.push(b.get())
163
+ return 'tx2'
164
+ })
165
+
166
+ const [result1, result2] = await Promise.all([tx1, tx2])
167
+
168
+ expect(result1).toBe('tx1')
169
+ expect(result2).toBe('tx2')
170
+ expect(a.get()).toBe(1)
171
+ expect(b.get()).toBe(2)
172
+ expect(results).toEqual([2, 1])
173
+ })
174
+
175
+ it('[AT4] overlapping transactions leak state to each other but group their effects', async () => {
176
+ const a = atom('', 0)
177
+
178
+ let txp = null
179
+
180
+ const p = deferAsyncEffects(async () => {
181
+ a.set(1)
182
+ const x = promiseWithResolve()
183
+ txp = deferAsyncEffects(async () => {
184
+ a.set(2)
185
+ x.resolve(null)
186
+ await sleep(10)
187
+ a.set(3)
188
+ return 'inner'
189
+ })
190
+ await x
191
+ // inner transactions leak, this can't be avoided without AsyncContext
192
+ // but at least we can group effects.
193
+ expect(a.get()).toBe(2)
194
+ return 'outer'
195
+ })
196
+
197
+ await expect(p).resolves.toBe('outer')
198
+ await expect(txp).resolves.toBe('inner')
199
+ expect(a.get()).toBe(3)
200
+ })
201
+
202
+ it('[AT5] waits for the reaction phase to finish if kicked off during a reaction', async () => {
203
+ const a = atom('', 0)
204
+ const b = atom('', 0)
205
+
206
+ let txp: any = null
207
+
208
+ react('', () => {
209
+ a.get()
210
+ txp = deferAsyncEffects(async () => {
211
+ await sleep(1)
212
+ b.set(a.get() + 1)
213
+ })
214
+ })
215
+
216
+ await txp
217
+
218
+ expect(a.get()).toBe(0)
219
+ expect(b.get()).toBe(1)
220
+
221
+ a.set(1)
222
+
223
+ await txp
224
+
225
+ expect(a.get()).toBe(1)
226
+ expect(b.get()).toBe(2)
227
+ })
228
+
229
+ it('[AT6] resolves to the return value of the function', async () => {
230
+ await expect(deferAsyncEffects(async () => 'value')).resolves.toBe('value')
231
+ })
232
+ })
@@ -1,41 +1,15 @@
1
1
  import { atom } from '../Atom'
2
- import { computed } from '../Computed'
2
+ import { computed, isUninitialized } from '../Computed'
3
3
  import { EffectScheduler, react } from '../EffectScheduler'
4
4
  import { haveParentsChanged } from '../helpers'
5
5
  import { getGlobalEpoch, transact } from '../transactions'
6
6
  import { RESET_VALUE } from '../types'
7
7
 
8
- describe('reactors that error', () => {
9
- it('will not roll back the atom value', () => {
10
- const a = atom('', 1)
11
- react('', () => {
12
- if (a.get() === 2) throw new Error('test')
13
- })
14
- expect(() => a.set(2)).toThrowErrorMatchingInlineSnapshot(`[Error: test]`)
15
- expect(a.get()).toBe(2)
16
- })
17
- it('will not roll back the changes in a transaction', () => {
18
- const a = atom('', 1)
19
- const b = atom('', 2)
8
+ // Tests for SPEC.md §7 (errors in computed signals) and rule CE6 (effects that throw).
9
+ // Rule IDs like [CE1] in test names refer to that document.
20
10
 
21
- react('', () => {
22
- if (a.get() + b.get() === 4) throw new Error('test')
23
- })
24
-
25
- expect(() =>
26
- transact(() => {
27
- a.set(3)
28
- b.set(1)
29
- })
30
- ).toThrowErrorMatchingInlineSnapshot(`[Error: test]`)
31
-
32
- expect(a.get()).toBe(3)
33
- expect(b.get()).toBe(1)
34
- })
35
- })
36
-
37
- describe('derivations that error', () => {
38
- it('will cache thrown values', () => {
11
+ describe('computed signals that throw (CE)', () => {
12
+ it('[CE1] cache thrown values until a parent changes', () => {
39
13
  let numComputations = 0
40
14
  const a = atom('', 1)
41
15
  const b = computed('', () => {
@@ -62,7 +36,7 @@ describe('derivations that error', () => {
62
36
  expect(b.get()).toBe(3)
63
37
  })
64
38
 
65
- it('will not trigger effects if they continue to error', () => {
39
+ it('[CE2] entering the error state notifies effects, but consecutive errors do not', () => {
66
40
  const a = atom('', 1)
67
41
  let numComputations = 0
68
42
  const b = computed('', () => {
@@ -85,21 +59,45 @@ describe('derivations that error', () => {
85
59
 
86
60
  a.set(2)
87
61
 
62
+ // entering the error state is a change
88
63
  expect(numReactions).toBe(2)
89
64
  expect(numComputations).toBe(2)
90
65
 
91
66
  a.set(4)
92
67
 
68
+ // erroring again while already in the error state is not a change
93
69
  expect(numComputations).toBe(3)
94
70
  expect(numReactions).toBe(2)
95
71
 
96
72
  a.set(3)
97
73
 
74
+ // recovering is a change
98
75
  expect(numComputations).toBe(4)
99
76
  expect(numReactions).toBe(3)
100
77
  })
101
78
 
102
- it('clears the history buffer when an error is thrown', () => {
79
+ it('[CE3] discard the previous value: recovery receives UNINITIALIZED as previousValue', () => {
80
+ const a = atom('', 1)
81
+ const previousValues: unknown[] = []
82
+ const b = computed('', (prev) => {
83
+ previousValues.push(prev)
84
+ if (a.get() === 2) throw new Error('test')
85
+ return a.get()
86
+ })
87
+
88
+ expect(b.get()).toBe(1)
89
+
90
+ a.set(2)
91
+ expect(() => b.get()).toThrowErrorMatchingInlineSnapshot(`[Error: test]`)
92
+
93
+ a.set(3)
94
+ expect(b.get()).toBe(3)
95
+
96
+ expect(previousValues.map(isUninitialized)).toEqual([true, false, true])
97
+ expect(previousValues[1]).toBe(1)
98
+ })
99
+
100
+ it('[CE4] clear the history buffer when an error is thrown', () => {
103
101
  const a = atom('', 1)
104
102
  const b = computed(
105
103
  '',
@@ -142,24 +140,54 @@ describe('derivations that error', () => {
142
140
  expect(b.getDiffSince(errorEpoch)).toEqual(RESET_VALUE)
143
141
  expect(b.getDiffSince(errorEpoch + 1)).toEqual([1])
144
142
  })
143
+
144
+ it('[CE5] haveParentsChanged will not throw if one of the parents is throwing', () => {
145
+ const a = atom('', 1)
146
+ const scheduler = new EffectScheduler('', () => {
147
+ a.get()
148
+ throw new Error('test')
149
+ })
150
+ expect(() => {
151
+ scheduler.attach()
152
+ scheduler.execute()
153
+ }).toThrowErrorMatchingInlineSnapshot(`[Error: test]`)
154
+
155
+ expect(haveParentsChanged(scheduler)).toBe(false)
156
+
157
+ expect(() => a.set(2)).toThrowErrorMatchingInlineSnapshot(`[Error: test]`)
158
+
159
+ // haveParentsChanged should still be false because it already
160
+ // executed the effect and it errored
161
+ expect(haveParentsChanged(scheduler)).toBe(false)
162
+ })
145
163
  })
146
164
 
147
- test('haveParentsChanged will not throw if one of the parents is throwing', () => {
148
- const a = atom('', 1)
149
- const scheduler = new EffectScheduler('', () => {
150
- a.get()
151
- throw new Error('test')
165
+ describe('effects that throw (CE6)', () => {
166
+ it('[CE6] will not roll back the atom value', () => {
167
+ const a = atom('', 1)
168
+ react('', () => {
169
+ if (a.get() === 2) throw new Error('test')
170
+ })
171
+ expect(() => a.set(2)).toThrowErrorMatchingInlineSnapshot(`[Error: test]`)
172
+ expect(a.get()).toBe(2)
152
173
  })
153
- expect(() => {
154
- scheduler.attach()
155
- scheduler.execute()
156
- }).toThrowErrorMatchingInlineSnapshot(`[Error: test]`)
157
174
 
158
- expect(haveParentsChanged(scheduler)).toBe(false)
175
+ it('[CE6] will not roll back the changes in a transaction', () => {
176
+ const a = atom('', 1)
177
+ const b = atom('', 2)
159
178
 
160
- expect(() => a.set(2)).toThrowErrorMatchingInlineSnapshot(`[Error: test]`)
179
+ react('', () => {
180
+ if (a.get() + b.get() === 4) throw new Error('test')
181
+ })
161
182
 
162
- // haveParentsChanged should still be false because it already
163
- // executed the effect and it errored
164
- expect(haveParentsChanged(scheduler)).toBe(false)
183
+ expect(() =>
184
+ transact(() => {
185
+ a.set(3)
186
+ b.set(1)
187
+ })
188
+ ).toThrowErrorMatchingInlineSnapshot(`[Error: test]`)
189
+
190
+ expect(a.get()).toBe(3)
191
+ expect(b.get()).toBe(1)
192
+ })
165
193
  })
@@ -1,4 +1,4 @@
1
- import { times } from 'lodash'
1
+ import times from 'lodash/times'
2
2
  import { Atom, atom, isAtom } from '../Atom'
3
3
  import { Computed, computed, isComputed } from '../Computed'
4
4
  import { Reactor, reactor } from '../EffectScheduler'
@@ -0,0 +1,49 @@
1
+ import { atom, isAtom } from '../Atom'
2
+ import { computed, getComputedInstance, isComputed } from '../Computed'
3
+ import { isSignal } from '../isSignal'
4
+
5
+ // Tests for SPEC.md §14 (type guards and module duplication).
6
+ // Rule G2 (singleton sharing across module copies) is covered at the unit level in helpers.test.ts.
7
+
8
+ describe('type guards (G1)', () => {
9
+ const a = atom('a', 1)
10
+ const c = computed('c', () => a.get() * 2)
11
+
12
+ class Foo {
13
+ x = atom('x', 1)
14
+ @computed
15
+ getY() {
16
+ return this.x.get()
17
+ }
18
+ }
19
+ const decorated = getComputedInstance(new Foo(), 'getY')
20
+
21
+ const nonSignals = [null, undefined, 0, 1, NaN, 'hello', {}, [], () => {}, Symbol('s')]
22
+
23
+ it('[G1] isAtom is true exactly for atoms', () => {
24
+ expect(isAtom(a)).toBe(true)
25
+ expect(isAtom(c)).toBe(false)
26
+ expect(isAtom(decorated)).toBe(false)
27
+ for (const value of nonSignals) {
28
+ expect(isAtom(value)).toBe(false)
29
+ }
30
+ })
31
+
32
+ it('[G1] isComputed is true exactly for computed signals', () => {
33
+ expect(isComputed(c)).toBe(true)
34
+ expect(isComputed(decorated)).toBe(true)
35
+ expect(isComputed(a)).toBe(false)
36
+ for (const value of nonSignals) {
37
+ expect(isComputed(value)).toBe(false)
38
+ }
39
+ })
40
+
41
+ it('[G1] isSignal is true exactly for atoms and computed signals', () => {
42
+ expect(isSignal(a)).toBe(true)
43
+ expect(isSignal(c)).toBe(true)
44
+ expect(isSignal(decorated)).toBe(true)
45
+ for (const value of nonSignals) {
46
+ expect(isSignal(value)).toBe(false)
47
+ }
48
+ })
49
+ })