@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,6 +1,9 @@
1
1
  import { localStorageAtom } from '../localStorageAtom'
2
2
  import { getGlobalEpoch } from '../transactions'
3
3
 
4
+ // Tests for SPEC.md §15 (localStorageAtom).
5
+ // Rule IDs like [LS2] in test names refer to that document.
6
+
4
7
  // Mock localStorage
5
8
  const mockLocalStorage = (() => {
6
9
  let store: Record<string, string> = {}
@@ -41,14 +44,14 @@ describe('localStorageAtom', () => {
41
44
  })
42
45
 
43
46
  describe('initialization', () => {
44
- it('should create atom with initial value when localStorage is empty', () => {
47
+ it('[LS1] should create atom with initial value when localStorage is empty', () => {
45
48
  const [atom, cleanup] = localStorageAtom('test-key', 'initial-value')
46
49
 
47
50
  expect(atom.get()).toBe('initial-value')
48
51
  cleanup()
49
52
  })
50
53
 
51
- it('should restore value from localStorage when it exists', () => {
54
+ it('[LS1] should restore value from localStorage when it exists', () => {
52
55
  mockLocalStorage.setItem('test-key', JSON.stringify('stored-value'))
53
56
 
54
57
  const [atom, cleanup] = localStorageAtom('test-key', 'initial-value')
@@ -59,7 +62,7 @@ describe('localStorageAtom', () => {
59
62
  })
60
63
 
61
64
  describe('corrupted localStorage handling', () => {
62
- it('should use initial value and delete corrupted localStorage entry', () => {
65
+ it('[LS2] should use initial value and delete corrupted localStorage entry', () => {
63
66
  mockLocalStorage.setItem('test-key', 'invalid-json')
64
67
 
65
68
  const [atom, cleanup] = localStorageAtom('test-key', 'initial-value')
@@ -69,7 +72,7 @@ describe('localStorageAtom', () => {
69
72
  cleanup()
70
73
  })
71
74
 
72
- it('should handle empty string in localStorage', () => {
75
+ it('[LS2] should handle empty string in localStorage', () => {
73
76
  mockLocalStorage.setItem('test-key', '')
74
77
 
75
78
  const [atom, cleanup] = localStorageAtom('test-key', 'initial-value')
@@ -81,7 +84,7 @@ describe('localStorageAtom', () => {
81
84
  })
82
85
 
83
86
  describe('localStorage synchronization', () => {
84
- it('should save to localStorage when atom value changes', () => {
87
+ it('[LS3] should save to localStorage when atom value changes', () => {
85
88
  const [atom, cleanup] = localStorageAtom('test-key', 'initial')
86
89
 
87
90
  atom.set('new-value')
@@ -90,7 +93,7 @@ describe('localStorageAtom', () => {
90
93
  cleanup()
91
94
  })
92
95
 
93
- it('should update localStorage on multiple changes', () => {
96
+ it('[LS3] should update localStorage on multiple changes', () => {
94
97
  const [atom, cleanup] = localStorageAtom('counter', 0)
95
98
 
96
99
  // Clear initial call from atom creation
@@ -108,8 +111,70 @@ describe('localStorageAtom', () => {
108
111
  })
109
112
  })
110
113
 
114
+ describe('cross-tab synchronization', () => {
115
+ it('[LS4] updates the atom when a storage event for its key arrives', () => {
116
+ const [atom, cleanup] = localStorageAtom('test-key', 'initial')
117
+
118
+ const event = new Event('storage')
119
+ Object.defineProperties(event, {
120
+ key: { value: 'test-key' },
121
+ newValue: { value: JSON.stringify('from-other-tab') },
122
+ })
123
+ window.dispatchEvent(event as StorageEvent)
124
+
125
+ expect(atom.get()).toBe('from-other-tab')
126
+ cleanup()
127
+ })
128
+
129
+ it('[LS4] resets the atom to the initial value when the key is deleted in another tab', () => {
130
+ const [atom, cleanup] = localStorageAtom('test-key', 'initial')
131
+
132
+ atom.set('changed')
133
+
134
+ const event = new Event('storage')
135
+ Object.defineProperties(event, {
136
+ key: { value: 'test-key' },
137
+ newValue: { value: null },
138
+ })
139
+ window.dispatchEvent(event as StorageEvent)
140
+
141
+ expect(atom.get()).toBe('initial')
142
+ cleanup()
143
+ })
144
+
145
+ it('[LS4] ignores storage events for other keys', () => {
146
+ const [atom, cleanup] = localStorageAtom('test-key', 'initial')
147
+
148
+ const event = new Event('storage')
149
+ Object.defineProperties(event, {
150
+ key: { value: 'other-key' },
151
+ newValue: { value: JSON.stringify('other-value') },
152
+ })
153
+ window.dispatchEvent(event as StorageEvent)
154
+
155
+ expect(atom.get()).toBe('initial')
156
+ cleanup()
157
+ })
158
+
159
+ it('[LS4] ignores storage events with unparseable values', () => {
160
+ const [atom, cleanup] = localStorageAtom('test-key', 'initial')
161
+
162
+ atom.set('current')
163
+
164
+ const event = new Event('storage')
165
+ Object.defineProperties(event, {
166
+ key: { value: 'test-key' },
167
+ newValue: { value: 'not json' },
168
+ })
169
+ window.dispatchEvent(event as StorageEvent)
170
+
171
+ expect(atom.get()).toBe('current')
172
+ cleanup()
173
+ })
174
+ })
175
+
111
176
  describe('cleanup functionality', () => {
112
- it('should stop syncing to localStorage after cleanup', () => {
177
+ it('[LS5] should stop syncing to localStorage after cleanup', () => {
113
178
  const [atom, cleanup] = localStorageAtom('test-key', 'initial')
114
179
 
115
180
  // Change value before cleanup - should sync
@@ -128,7 +193,22 @@ describe('localStorageAtom', () => {
128
193
  expect(mockLocalStorage.setItem).not.toHaveBeenCalled()
129
194
  })
130
195
 
131
- it('should allow atom to continue functioning after cleanup', () => {
196
+ it('[LS5] should stop handling storage events after cleanup', () => {
197
+ const [atom, cleanup] = localStorageAtom('test-key', 'initial')
198
+
199
+ cleanup()
200
+
201
+ const event = new Event('storage')
202
+ Object.defineProperties(event, {
203
+ key: { value: 'test-key' },
204
+ newValue: { value: JSON.stringify('from-other-tab') },
205
+ })
206
+ window.dispatchEvent(event as StorageEvent)
207
+
208
+ expect(atom.get()).toBe('initial')
209
+ })
210
+
211
+ it('[LS5] should allow atom to continue functioning after cleanup', () => {
132
212
  const [atom, cleanup] = localStorageAtom('test-key', 'initial')
133
213
 
134
214
  cleanup()
@@ -139,7 +219,7 @@ describe('localStorageAtom', () => {
139
219
  })
140
220
 
141
221
  describe('atom options', () => {
142
- it('should pass through atom options', () => {
222
+ it('[LS5] should pass through atom options', () => {
143
223
  const isEqual = (a: string, b: string) => a.toLowerCase() === b.toLowerCase()
144
224
  const [atom, cleanup] = localStorageAtom('test-key', 'Hello', { isEqual })
145
225
 
@@ -148,7 +228,7 @@ describe('localStorageAtom', () => {
148
228
  cleanup()
149
229
  })
150
230
 
151
- it('should work with history options', () => {
231
+ it('[LS5] should work with history options', () => {
152
232
  const [atom, cleanup] = localStorageAtom('test-key', 0, {
153
233
  historyLength: 3,
154
234
  computeDiff: (a, b) => b - a,
@@ -166,7 +246,7 @@ describe('localStorageAtom', () => {
166
246
  })
167
247
 
168
248
  describe('multiple instances', () => {
169
- it('should handle multiple atoms with different keys', () => {
249
+ it('[LS3] should handle multiple atoms with different keys', () => {
170
250
  const [atom1, cleanup1] = localStorageAtom('key1', 'value1')
171
251
  const [atom2, cleanup2] = localStorageAtom('key2', 'value2')
172
252
 
@@ -0,0 +1,279 @@
1
+ import { vi } from 'vitest'
2
+ import { atom } from '../Atom'
3
+ import { computed } from '../Computed'
4
+ import { react, reactor } from '../EffectScheduler'
5
+ import { transact, transaction } from '../transactions'
6
+
7
+ // Tests for SPEC.md §10 (change propagation and the reaction phase).
8
+ // Rule IDs like [P4] in test names refer to that document.
9
+
10
+ describe('change propagation (P)', () => {
11
+ it('[P1] runs effects synchronously, before set returns', () => {
12
+ const a = atom('', 1)
13
+ let observed = 0
14
+
15
+ react('', () => {
16
+ observed = a.get()
17
+ })
18
+
19
+ a.set(2)
20
+ expect(observed).toBe(2)
21
+ })
22
+
23
+ it('[P2] reaches effects through chains of computeds, but only along listening edges', () => {
24
+ const a = atom('', 1)
25
+ const double = vi.fn(() => a.get() * 2)
26
+ const c1 = computed('', double)
27
+ const c2 = computed('', () => c1.get() + 1)
28
+
29
+ let last = 0
30
+ const stop = react('', () => {
31
+ last = c2.get()
32
+ })
33
+
34
+ expect(last).toBe(3)
35
+
36
+ a.set(2)
37
+ expect(last).toBe(5)
38
+
39
+ stop()
40
+
41
+ // with nothing listening, changes do not propagate (and computeds stay lazy)
42
+ a.set(3)
43
+ expect(last).toBe(5)
44
+ expect(double).toHaveBeenCalledTimes(2)
45
+ })
46
+
47
+ it('[P3] runs an effect once per change in a diamond-shaped graph', () => {
48
+ const a = atom('', 1)
49
+ const left = computed('', () => a.get() + 1)
50
+ const right = computed('', () => a.get() * 2)
51
+
52
+ const effect = vi.fn(() => {
53
+ left.get()
54
+ right.get()
55
+ })
56
+ react('', effect)
57
+
58
+ expect(effect).toHaveBeenCalledTimes(1)
59
+
60
+ a.set(2)
61
+
62
+ expect(effect).toHaveBeenCalledTimes(2)
63
+ })
64
+ })
65
+
66
+ describe('setting atoms during the reaction phase (P)', () => {
67
+ it('[P4] works', () => {
68
+ const a = atom('', 0)
69
+ const b = atom('', 0)
70
+
71
+ react('', () => {
72
+ b.set(a.get() + 1)
73
+ })
74
+
75
+ expect(a.get()).toBe(0)
76
+ expect(b.get()).toBe(1)
77
+ })
78
+
79
+ it('[P5] throws an error if it gets into a loop', () => {
80
+ expect(() => {
81
+ const a = atom('', 0)
82
+
83
+ react('', () => {
84
+ a.set(a.get() + 1)
85
+ })
86
+ }).toThrowErrorMatchingInlineSnapshot(`[Error: Reaction update depth limit exceeded]`)
87
+ })
88
+
89
+ it('[P5] throws when a reactor can not stop setting atom values', () => {
90
+ const a = atom('', 1)
91
+ const r = reactor('', () => {
92
+ if (a.get() < +Infinity) {
93
+ a.update((a) => a + 1)
94
+ }
95
+ })
96
+ expect(() => r.start()).toThrowErrorMatchingInlineSnapshot(
97
+ `[Error: Reaction update depth limit exceeded]`
98
+ )
99
+ })
100
+
101
+ it('[P4][P6] works with a transaction running', () => {
102
+ const a = atom('', 0)
103
+
104
+ react('', () => {
105
+ transact(() => {
106
+ if (a.get() < 10) {
107
+ a.set(a.get() + 1)
108
+ }
109
+ })
110
+ })
111
+
112
+ expect(a.get()).toBe(10)
113
+ })
114
+
115
+ it('[P7][regression 1] should allow computeds to be updated properly', () => {
116
+ const a = atom('', 0)
117
+ const b = atom('', 0)
118
+ const c = computed('', () => b.get() * 2)
119
+
120
+ let cValue = 0
121
+
122
+ react('', () => {
123
+ b.set(a.get() + 1)
124
+ cValue = c.get()
125
+ })
126
+
127
+ expect(a.get()).toBe(0)
128
+ expect(b.get()).toBe(1)
129
+ expect(cValue).toBe(2)
130
+
131
+ transact(() => {
132
+ a.set(1)
133
+ })
134
+ expect(cValue).toBe(4)
135
+ })
136
+
137
+ it('[P7][regression 2] should allow computeds to be updated properly', () => {
138
+ const a = atom('', 0)
139
+ const b = atom('', 1)
140
+ const c = atom('', 0)
141
+ const d = computed('', () => a.get() * 2)
142
+
143
+ let dValue = 0
144
+ react('', () => {
145
+ // update a, causes a and d to be traversed (but not updated)
146
+ a.set(b.get())
147
+ // update c
148
+ c.set(a.get())
149
+ // make sure that when we get d, it is updated properly
150
+ dValue = d.get()
151
+ })
152
+
153
+ expect(a.get()).toBe(1)
154
+ expect(b.get()).toBe(1)
155
+ expect(c.get()).toBe(1)
156
+
157
+ expect(dValue).toBe(2)
158
+
159
+ transact(() => {
160
+ b.set(2)
161
+ })
162
+ expect(dValue).toBe(4)
163
+ })
164
+ })
165
+
166
+ describe('transactions during the reaction phase (P6)', () => {
167
+ it('[P6] it should be possible to run a transaction during a reaction', () => {
168
+ const a = atom('', 0)
169
+ const b = atom('', 0)
170
+
171
+ react('', () => {
172
+ transaction(() => {
173
+ b.set(a.get() + 1)
174
+ })
175
+ })
176
+
177
+ expect(a.get()).toBe(0)
178
+ expect(b.get()).toBe(1)
179
+
180
+ a.set(1)
181
+
182
+ expect(b.get()).toBe(2)
183
+
184
+ transaction(() => {
185
+ a.set(2)
186
+ expect(b.get()).toBe(2)
187
+ })
188
+
189
+ expect(b.get()).toBe(3)
190
+ })
191
+
192
+ it('[P6] it should be possible to abort a transaction during a reaction', () => {
193
+ const a = atom('', 0)
194
+ const b = atom('', 0)
195
+
196
+ const unsub = react('', () => {
197
+ transaction((rollback) => {
198
+ b.set(a.get() + 1)
199
+ rollback()
200
+ })
201
+ expect(b.get()).toBe(0)
202
+ })
203
+
204
+ expect(a.get()).toBe(0)
205
+ expect(b.get()).toBe(0)
206
+
207
+ unsub()
208
+
209
+ react('', () => {
210
+ transaction(() => {
211
+ b.set(3)
212
+ try {
213
+ transaction(() => {
214
+ b.set(a.get() + 1)
215
+ throw new Error('oops')
216
+ })
217
+ } catch (e: any) {
218
+ expect(e.message).toBe('oops')
219
+ } finally {
220
+ expect(b.get()).toBe(3)
221
+ }
222
+ })
223
+ expect(b.get()).toBe(3)
224
+ })
225
+
226
+ expect(a.get()).toBe(0)
227
+ expect(b.get()).toBe(3)
228
+
229
+ expect.assertions(8)
230
+ })
231
+
232
+ it('[P6] defers all side effects until the end of the outer reaction pass', () => {
233
+ const a = atom('', 0)
234
+ const b = atom('', 0)
235
+ const c = atom('', 0)
236
+
237
+ const aChanged = vi.fn()
238
+ const bChanged = vi.fn()
239
+ const cChanged = vi.fn()
240
+
241
+ react('', () => {
242
+ a.get()
243
+ aChanged()
244
+ })
245
+
246
+ react('', () => {
247
+ transaction(() => {
248
+ a.set(b.get() + 1)
249
+ })
250
+ bChanged()
251
+ })
252
+
253
+ react('', () => {
254
+ transaction(() => {
255
+ b.set(c.get() + 1)
256
+ })
257
+ cChanged()
258
+ })
259
+
260
+ expect(aChanged).toHaveBeenCalledTimes(3)
261
+ expect(bChanged).toHaveBeenCalledTimes(2)
262
+ expect(cChanged).toHaveBeenCalledTimes(1)
263
+
264
+ expect(a.__unsafe__getWithoutCapture()).toBe(2)
265
+
266
+ cChanged.mockImplementationOnce(() => {
267
+ // b was .set() during c's reaction
268
+ expect(b.__unsafe__getWithoutCapture()).toBe(2)
269
+ // a was not yet set because the effect was deferred
270
+ // until the end of the reaction
271
+ expect(a.__unsafe__getWithoutCapture()).toBe(2)
272
+ })
273
+
274
+ c.set(1)
275
+
276
+ expect(a.__unsafe__getWithoutCapture()).toBe(3)
277
+ expect(cChanged).toHaveBeenCalledTimes(2)
278
+ })
279
+ })