@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.
- package/DOCS.md +563 -0
- package/README.md +9 -1
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/Computed.js +1 -1
- package/dist-cjs/lib/Computed.js.map +2 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/Computed.mjs +1 -1
- package/dist-esm/lib/Computed.mjs.map +2 -2
- package/package.json +8 -4
- package/src/lib/Computed.ts +1 -1
- package/src/lib/__tests__/{arraySet.test.ts → ArraySet.test.ts} +83 -0
- package/src/lib/__tests__/EffectScheduler.test.ts +355 -34
- package/src/lib/__tests__/HistoryBuffer.test.ts +19 -2
- package/src/lib/__tests__/atom.test.ts +132 -128
- package/src/lib/__tests__/capture.test.ts +203 -84
- package/src/lib/__tests__/computed.test.ts +163 -438
- package/src/lib/__tests__/debug.test.ts +84 -0
- package/src/lib/__tests__/deferAsyncEffects.test.ts +232 -0
- package/src/lib/__tests__/errors.test.ts +75 -47
- package/src/lib/__tests__/fuzz.tlstate.test.ts +1 -1
- package/src/lib/__tests__/guards.test.ts +49 -0
- package/src/lib/__tests__/helpers.test.ts +46 -58
- package/src/lib/__tests__/history.test.ts +524 -0
- package/src/lib/__tests__/localStorageAtom.test.ts +91 -11
- package/src/lib/__tests__/propagation.test.ts +279 -0
- package/src/lib/__tests__/transactions.test.ts +49 -435
- package/src/lib/__tests__/reactor.test.ts +0 -197
|
@@ -5,35 +5,32 @@ import { reactor } from '../EffectScheduler'
|
|
|
5
5
|
import { attach, detach, equals, hasReactors, haveParentsChanged, singleton } from '../helpers'
|
|
6
6
|
import { Child } from '../types'
|
|
7
7
|
|
|
8
|
+
// Unit tests for the internal helpers behind SPEC.md rules EQ1/EQ2 (equals),
|
|
9
|
+
// CAP7 (attach/detach), and G2 (singleton).
|
|
10
|
+
|
|
11
|
+
const makeChild = (): Child => ({
|
|
12
|
+
parents: [],
|
|
13
|
+
parentEpochs: [],
|
|
14
|
+
parentSet: new ArraySet(),
|
|
15
|
+
name: 'test-child',
|
|
16
|
+
lastTraversedEpoch: 0,
|
|
17
|
+
isActivelyListening: true,
|
|
18
|
+
__debug_ancestor_epochs__: null,
|
|
19
|
+
})
|
|
20
|
+
|
|
8
21
|
describe('helpers', () => {
|
|
9
22
|
describe('haveParentsChanged', () => {
|
|
10
23
|
it('returns false when no parents exist', () => {
|
|
11
|
-
|
|
12
|
-
parents: [],
|
|
13
|
-
parentEpochs: [],
|
|
14
|
-
parentSet: new ArraySet(),
|
|
15
|
-
name: 'test-child',
|
|
16
|
-
lastTraversedEpoch: 0,
|
|
17
|
-
isActivelyListening: true,
|
|
18
|
-
__debug_ancestor_epochs__: null,
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
expect(haveParentsChanged(child)).toBe(false)
|
|
24
|
+
expect(haveParentsChanged(makeChild())).toBe(false)
|
|
22
25
|
})
|
|
23
26
|
|
|
24
27
|
it('returns true when parent epoch has changed', () => {
|
|
25
28
|
const parentAtom = atom('parent', 1)
|
|
26
29
|
const oldEpoch = parentAtom.lastChangedEpoch
|
|
27
30
|
|
|
28
|
-
const child
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
parentSet: new ArraySet(),
|
|
32
|
-
name: 'test-child',
|
|
33
|
-
lastTraversedEpoch: 0,
|
|
34
|
-
isActivelyListening: true,
|
|
35
|
-
__debug_ancestor_epochs__: null,
|
|
36
|
-
}
|
|
31
|
+
const child = makeChild()
|
|
32
|
+
child.parents.push(parentAtom)
|
|
33
|
+
child.parentEpochs.push(oldEpoch)
|
|
37
34
|
|
|
38
35
|
// Change the parent, which should update its epoch
|
|
39
36
|
parentAtom.set(2)
|
|
@@ -44,18 +41,10 @@ describe('helpers', () => {
|
|
|
44
41
|
it('returns true when any parent has changed among multiple parents', () => {
|
|
45
42
|
const parent1 = atom('parent1', 1)
|
|
46
43
|
const parent2 = atom('parent2', 2)
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
parents: [parent1, parent2],
|
|
52
|
-
parentEpochs: [oldEpoch1, oldEpoch2],
|
|
53
|
-
parentSet: new ArraySet(),
|
|
54
|
-
name: 'test-child',
|
|
55
|
-
lastTraversedEpoch: 0,
|
|
56
|
-
isActivelyListening: true,
|
|
57
|
-
__debug_ancestor_epochs__: null,
|
|
58
|
-
}
|
|
44
|
+
|
|
45
|
+
const child = makeChild()
|
|
46
|
+
child.parents.push(parent1, parent2)
|
|
47
|
+
child.parentEpochs.push(parent1.lastChangedEpoch, parent2.lastChangedEpoch)
|
|
59
48
|
|
|
60
49
|
// Change only the second parent
|
|
61
50
|
parent2.set(3)
|
|
@@ -64,18 +53,10 @@ describe('helpers', () => {
|
|
|
64
53
|
})
|
|
65
54
|
})
|
|
66
55
|
|
|
67
|
-
describe('detach', () => {
|
|
56
|
+
describe('detach [CAP7]', () => {
|
|
68
57
|
it('removes child from parent children when attached', () => {
|
|
69
58
|
const parent = atom('parent', 1)
|
|
70
|
-
const child
|
|
71
|
-
parents: [],
|
|
72
|
-
parentEpochs: [],
|
|
73
|
-
parentSet: new ArraySet(),
|
|
74
|
-
name: 'test-child',
|
|
75
|
-
lastTraversedEpoch: 0,
|
|
76
|
-
isActivelyListening: true,
|
|
77
|
-
__debug_ancestor_epochs__: null,
|
|
78
|
-
}
|
|
59
|
+
const child = makeChild()
|
|
79
60
|
|
|
80
61
|
parent.children.add(child)
|
|
81
62
|
expect(parent.children.size()).toBe(1)
|
|
@@ -86,18 +67,10 @@ describe('helpers', () => {
|
|
|
86
67
|
})
|
|
87
68
|
})
|
|
88
69
|
|
|
89
|
-
describe('attach', () => {
|
|
70
|
+
describe('attach [CAP7]', () => {
|
|
90
71
|
it('adds child to parent children when not already attached', () => {
|
|
91
72
|
const parent = atom('parent', 1)
|
|
92
|
-
const child
|
|
93
|
-
parents: [],
|
|
94
|
-
parentEpochs: [],
|
|
95
|
-
parentSet: new ArraySet(),
|
|
96
|
-
name: 'test-child',
|
|
97
|
-
lastTraversedEpoch: 0,
|
|
98
|
-
isActivelyListening: true,
|
|
99
|
-
__debug_ancestor_epochs__: null,
|
|
100
|
-
}
|
|
73
|
+
const child = makeChild()
|
|
101
74
|
|
|
102
75
|
expect(parent.children.size()).toBe(0)
|
|
103
76
|
|
|
@@ -108,20 +81,20 @@ describe('helpers', () => {
|
|
|
108
81
|
})
|
|
109
82
|
})
|
|
110
83
|
|
|
111
|
-
describe('equals', () => {
|
|
112
|
-
it('returns true for identical references and Object.is cases', () => {
|
|
84
|
+
describe('equals [EQ1, EQ2]', () => {
|
|
85
|
+
it('[EQ1] returns true for identical references and Object.is cases', () => {
|
|
113
86
|
const obj = { a: 1 }
|
|
114
87
|
expect(equals(obj, obj)).toBe(true)
|
|
115
88
|
expect(equals(1, 1)).toBe(true)
|
|
116
89
|
expect(equals(NaN, NaN)).toBe(true)
|
|
117
90
|
})
|
|
118
91
|
|
|
119
|
-
it('returns false for different values', () => {
|
|
92
|
+
it('[EQ1] returns false for different values', () => {
|
|
120
93
|
expect(equals(1, 2)).toBe(false)
|
|
121
94
|
expect(equals({ id: 1 }, { id: 1 })).toBe(false)
|
|
122
95
|
})
|
|
123
96
|
|
|
124
|
-
it('uses custom equals method when available', () => {
|
|
97
|
+
it('[EQ1] uses the first value’s custom equals method when available', () => {
|
|
125
98
|
const obj1 = {
|
|
126
99
|
id: 1,
|
|
127
100
|
equals: (other: any) => other && other.id === 1,
|
|
@@ -130,10 +103,17 @@ describe('helpers', () => {
|
|
|
130
103
|
|
|
131
104
|
expect(equals(obj1, obj2)).toBe(true)
|
|
132
105
|
})
|
|
106
|
+
|
|
107
|
+
it('[EQ2] does not consult the second value’s equals method', () => {
|
|
108
|
+
const obj1 = { id: 1 }
|
|
109
|
+
const obj2 = { id: 1, equals: () => true }
|
|
110
|
+
|
|
111
|
+
expect(equals(obj1, obj2)).toBe(false)
|
|
112
|
+
})
|
|
133
113
|
})
|
|
134
114
|
|
|
135
|
-
describe('singleton', () => {
|
|
136
|
-
it('returns same instance on subsequent calls with same key', () => {
|
|
115
|
+
describe('singleton [G2]', () => {
|
|
116
|
+
it('returns the same instance on subsequent calls with the same key', () => {
|
|
137
117
|
const init = vi.fn(() => ({ value: 42 }))
|
|
138
118
|
|
|
139
119
|
const instance1 = singleton('test-singleton', init)
|
|
@@ -142,6 +122,14 @@ describe('helpers', () => {
|
|
|
142
122
|
expect(init).toHaveBeenCalledTimes(1)
|
|
143
123
|
expect(instance1).toBe(instance2)
|
|
144
124
|
})
|
|
125
|
+
|
|
126
|
+
it('stores the instance on globalThis so duplicate module copies share it', () => {
|
|
127
|
+
const instance = singleton('test-singleton-global', () => ({ value: 1 }))
|
|
128
|
+
|
|
129
|
+
expect((globalThis as any)[Symbol.for('com.tldraw.state/test-singleton-global')]).toBe(
|
|
130
|
+
instance
|
|
131
|
+
)
|
|
132
|
+
})
|
|
145
133
|
})
|
|
146
134
|
|
|
147
135
|
describe('hasReactors', () => {
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
import { vi } from 'vitest'
|
|
2
|
+
import { atom } from '../Atom'
|
|
3
|
+
import { Computed, UNINITIALIZED, computed, isUninitialized, withDiff } from '../Computed'
|
|
4
|
+
import { react } from '../EffectScheduler'
|
|
5
|
+
import { EMPTY_ARRAY, assertNever } from '../helpers'
|
|
6
|
+
import { getGlobalEpoch, transact, transaction } from '../transactions'
|
|
7
|
+
import { RESET_VALUE, Signal } from '../types'
|
|
8
|
+
|
|
9
|
+
// Tests for SPEC.md §8 (history and diffs).
|
|
10
|
+
// Rule IDs like [H2] in test names refer to that document.
|
|
11
|
+
|
|
12
|
+
describe('atom history (H)', () => {
|
|
13
|
+
it('[H1] only exists when historyLength is provided', () => {
|
|
14
|
+
const computeDiff = vi.fn((a: number, b: number) => b - a)
|
|
15
|
+
const a = atom<number, number>('', 1, { computeDiff })
|
|
16
|
+
|
|
17
|
+
const startEpoch = getGlobalEpoch()
|
|
18
|
+
|
|
19
|
+
a.set(2)
|
|
20
|
+
|
|
21
|
+
expect(computeDiff).not.toHaveBeenCalled()
|
|
22
|
+
expect(a.getDiffSince(startEpoch)).toBe(RESET_VALUE)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('[H2][H5] records computeDiff diffs and returns RESET_VALUE when the buffer is exceeded', () => {
|
|
26
|
+
const a = atom('', 1, { historyLength: 3, computeDiff: (a, b) => b - a })
|
|
27
|
+
|
|
28
|
+
const startEpoch = getGlobalEpoch()
|
|
29
|
+
|
|
30
|
+
expect(a.getDiffSince(startEpoch)).toEqual([])
|
|
31
|
+
|
|
32
|
+
a.set(5)
|
|
33
|
+
|
|
34
|
+
expect(a.getDiffSince(startEpoch)).toEqual([+4])
|
|
35
|
+
|
|
36
|
+
a.set(10)
|
|
37
|
+
|
|
38
|
+
expect(a.getDiffSince(startEpoch)).toEqual([+4, +5])
|
|
39
|
+
|
|
40
|
+
a.set(20)
|
|
41
|
+
|
|
42
|
+
expect(a.getDiffSince(startEpoch)).toEqual([+4, +5, +10])
|
|
43
|
+
|
|
44
|
+
a.set(30)
|
|
45
|
+
|
|
46
|
+
// will be RESET_VALUE because we don't have enough history
|
|
47
|
+
expect(a.getDiffSince(startEpoch)).toEqual(RESET_VALUE)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('[H2] prefers an explicit diff passed to set over computeDiff', () => {
|
|
51
|
+
const computeDiff = vi.fn((a: number, b: number) => b - a)
|
|
52
|
+
const a = atom<number, number>('', 1, { historyLength: 3, computeDiff })
|
|
53
|
+
|
|
54
|
+
const startEpoch = getGlobalEpoch()
|
|
55
|
+
|
|
56
|
+
a.set(5, +100)
|
|
57
|
+
expect(computeDiff).not.toHaveBeenCalled()
|
|
58
|
+
expect(a.getDiffSince(startEpoch)).toEqual([+100])
|
|
59
|
+
|
|
60
|
+
a.set(6)
|
|
61
|
+
expect(computeDiff).toHaveBeenCalledTimes(1)
|
|
62
|
+
expect(a.getDiffSince(startEpoch)).toEqual([+100, +1])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('[H2] passes the previous and current epochs to computeDiff', () => {
|
|
66
|
+
const calls: Array<[number, number, number, number]> = []
|
|
67
|
+
const a = atom('', 1, {
|
|
68
|
+
historyLength: 3,
|
|
69
|
+
computeDiff: (prev, next, lastEpoch, currentEpoch) => {
|
|
70
|
+
calls.push([prev, next, lastEpoch, currentEpoch])
|
|
71
|
+
return next - prev
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const epochBeforeSet = a.lastChangedEpoch
|
|
76
|
+
a.set(5)
|
|
77
|
+
|
|
78
|
+
expect(calls).toEqual([[1, 5, epochBeforeSet, getGlobalEpoch()]])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('[H4] clears the history buffer if no diff can be determined', () => {
|
|
82
|
+
const a = atom('', 1, { historyLength: 3 })
|
|
83
|
+
const startEpoch = getGlobalEpoch()
|
|
84
|
+
|
|
85
|
+
a.set(5, +4)
|
|
86
|
+
|
|
87
|
+
expect(a.getDiffSince(startEpoch)).toEqual([+4])
|
|
88
|
+
|
|
89
|
+
// no explicit diff and no computeDiff: the diff is RESET_VALUE, which wipes history
|
|
90
|
+
a.set(6)
|
|
91
|
+
|
|
92
|
+
expect(a.getDiffSince(startEpoch)).toEqual(RESET_VALUE)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('[H5] returns the shared EMPTY_ARRAY instance when nothing changed since the epoch', () => {
|
|
96
|
+
const a = atom('', 1, { historyLength: 3, computeDiff: (a, b) => b - a })
|
|
97
|
+
|
|
98
|
+
a.set(2)
|
|
99
|
+
|
|
100
|
+
expect(a.getDiffSince(getGlobalEpoch())).toBe(EMPTY_ARRAY)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('[H6] getDiffSince captures the signal as a dependency', () => {
|
|
104
|
+
const a = atom('', 1, { historyLength: 3, computeDiff: (a, b) => b - a })
|
|
105
|
+
|
|
106
|
+
const effect = vi.fn((lastReactedEpoch: number) => {
|
|
107
|
+
a.getDiffSince(lastReactedEpoch)
|
|
108
|
+
})
|
|
109
|
+
const stop = react('r', effect)
|
|
110
|
+
|
|
111
|
+
expect(effect).toHaveBeenCalledTimes(1)
|
|
112
|
+
|
|
113
|
+
a.set(2)
|
|
114
|
+
|
|
115
|
+
expect(effect).toHaveBeenCalledTimes(2)
|
|
116
|
+
stop()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('[A6] is independent of other atoms’ history', () => {
|
|
120
|
+
const a = atom('', 1, { historyLength: 3, computeDiff: (a, b) => b - a })
|
|
121
|
+
const b = atom('', 1, { historyLength: 3, computeDiff: (a, b) => b - a })
|
|
122
|
+
|
|
123
|
+
const startEpoch = getGlobalEpoch()
|
|
124
|
+
|
|
125
|
+
b.set(-5)
|
|
126
|
+
b.set(-10)
|
|
127
|
+
b.set(-20)
|
|
128
|
+
expect(b.getDiffSince(startEpoch)).toEqual([-6, -5, -10])
|
|
129
|
+
expect(b.getDiffSince(getGlobalEpoch())).toEqual([])
|
|
130
|
+
|
|
131
|
+
expect(a.getDiffSince(startEpoch)).toEqual([])
|
|
132
|
+
a.set(5)
|
|
133
|
+
expect(a.getDiffSince(startEpoch)).toEqual([+4])
|
|
134
|
+
expect(b.getDiffSince(startEpoch)).toEqual([-6, -5, -10])
|
|
135
|
+
expect(b.getDiffSince(getGlobalEpoch())).toEqual([])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('[H7] keeps recording inside transactions', () => {
|
|
139
|
+
const a = atom('', 1, { historyLength: 3, computeDiff: (a, b) => b - a })
|
|
140
|
+
|
|
141
|
+
const startEpoch = getGlobalEpoch()
|
|
142
|
+
|
|
143
|
+
transact(() => {
|
|
144
|
+
expect(a.getDiffSince(startEpoch)).toEqual([])
|
|
145
|
+
|
|
146
|
+
a.set(5)
|
|
147
|
+
|
|
148
|
+
expect(a.getDiffSince(startEpoch)).toEqual([+4])
|
|
149
|
+
|
|
150
|
+
a.set(10)
|
|
151
|
+
|
|
152
|
+
expect(a.getDiffSince(startEpoch)).toEqual([+4, +5])
|
|
153
|
+
|
|
154
|
+
a.set(20)
|
|
155
|
+
|
|
156
|
+
expect(a.getDiffSince(startEpoch)).toEqual([+4, +5, +10])
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
expect(a.getDiffSince(startEpoch)).toEqual([+4, +5, +10])
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('[H7][T9] is cleared when a transaction aborts', () => {
|
|
163
|
+
const a = atom('', 1, { historyLength: 3, computeDiff: (a, b) => b - a })
|
|
164
|
+
|
|
165
|
+
const startEpoch = getGlobalEpoch()
|
|
166
|
+
|
|
167
|
+
transaction((rollback) => {
|
|
168
|
+
expect(a.getDiffSince(startEpoch)).toEqual([])
|
|
169
|
+
|
|
170
|
+
a.set(5)
|
|
171
|
+
|
|
172
|
+
expect(a.getDiffSince(startEpoch)).toEqual([+4])
|
|
173
|
+
|
|
174
|
+
rollback()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
expect(a.getDiffSince(startEpoch)).toEqual(RESET_VALUE)
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
describe('computed history (H)', () => {
|
|
182
|
+
it('[H3][H5] records computeDiff diffs and returns RESET_VALUE when the buffer is exceeded', () => {
|
|
183
|
+
const startEpoch = getGlobalEpoch()
|
|
184
|
+
const a = atom('', 1)
|
|
185
|
+
|
|
186
|
+
const derivation = computed('', () => a.get() * 2, {
|
|
187
|
+
historyLength: 3,
|
|
188
|
+
computeDiff: (a, b) => {
|
|
189
|
+
return b - a
|
|
190
|
+
},
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
derivation.get()
|
|
194
|
+
|
|
195
|
+
expect(derivation.getDiffSince(startEpoch)).toHaveLength(0)
|
|
196
|
+
|
|
197
|
+
a.set(2)
|
|
198
|
+
|
|
199
|
+
expect(derivation.getDiffSince(startEpoch)).toEqual([+2])
|
|
200
|
+
|
|
201
|
+
a.set(3)
|
|
202
|
+
|
|
203
|
+
expect(derivation.getDiffSince(startEpoch)).toEqual([+2, +2])
|
|
204
|
+
|
|
205
|
+
a.set(5)
|
|
206
|
+
|
|
207
|
+
expect(derivation.getDiffSince(startEpoch)).toEqual([+2, +2, +4])
|
|
208
|
+
|
|
209
|
+
a.set(6)
|
|
210
|
+
// should fail now because we don't have enough history
|
|
211
|
+
expect(derivation.getDiffSince(startEpoch)).toEqual(RESET_VALUE)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('[H3][H8] prefers a withDiff diff over computeDiff, and get() unwraps the value', () => {
|
|
215
|
+
const a = atom('', 1)
|
|
216
|
+
const computeDiff = vi.fn((prev: number, next: number) => next - prev)
|
|
217
|
+
|
|
218
|
+
const derivation = computed(
|
|
219
|
+
'',
|
|
220
|
+
(prev: number | UNINITIALIZED) => {
|
|
221
|
+
const next = a.get() * 2
|
|
222
|
+
if (isUninitialized(prev)) return next
|
|
223
|
+
return withDiff(next, `+${next - prev}`)
|
|
224
|
+
},
|
|
225
|
+
{ historyLength: 3, computeDiff: computeDiff as any }
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
expect(derivation.get()).toBe(2)
|
|
229
|
+
|
|
230
|
+
const startEpoch = getGlobalEpoch()
|
|
231
|
+
|
|
232
|
+
a.set(2)
|
|
233
|
+
|
|
234
|
+
expect(derivation.get()).toBe(4)
|
|
235
|
+
expect(derivation.getDiffSince(startEpoch)).toEqual(['+2'])
|
|
236
|
+
expect(computeDiff).not.toHaveBeenCalled()
|
|
237
|
+
|
|
238
|
+
a.set(5)
|
|
239
|
+
|
|
240
|
+
expect(derivation.get()).toBe(10)
|
|
241
|
+
expect(derivation.getDiffSince(startEpoch)).toEqual(['+2', '+6'])
|
|
242
|
+
expect(computeDiff).not.toHaveBeenCalled()
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('[H3] records no history entry for the first computation', () => {
|
|
246
|
+
const a = atom('', 1)
|
|
247
|
+
const derivation = computed('', () => a.get() * 2, {
|
|
248
|
+
historyLength: 3,
|
|
249
|
+
computeDiff: (a, b) => b - a,
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
const startEpoch = getGlobalEpoch()
|
|
253
|
+
expect(derivation.get()).toBe(2)
|
|
254
|
+
expect(derivation.getDiffSince(startEpoch)).toEqual([])
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('[C5] does not record history when the recomputed value is equal', () => {
|
|
258
|
+
const startEpoch = getGlobalEpoch()
|
|
259
|
+
const a = atom('', 1)
|
|
260
|
+
|
|
261
|
+
const floor = vi.fn((n: number) => Math.floor(n))
|
|
262
|
+
const derivation = computed('', () => floor(a.get()), {
|
|
263
|
+
historyLength: 3,
|
|
264
|
+
computeDiff: (a, b) => {
|
|
265
|
+
return b - a
|
|
266
|
+
},
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
expect(derivation.get()).toBe(1)
|
|
270
|
+
expect(derivation.getDiffSince(startEpoch)).toHaveLength(0)
|
|
271
|
+
|
|
272
|
+
a.set(1.2)
|
|
273
|
+
|
|
274
|
+
expect(derivation.get()).toBe(1)
|
|
275
|
+
expect(derivation.getDiffSince(startEpoch)).toHaveLength(0)
|
|
276
|
+
expect(floor).toHaveBeenCalledTimes(2)
|
|
277
|
+
|
|
278
|
+
a.set(1.9)
|
|
279
|
+
|
|
280
|
+
expect(derivation.get()).toBe(1)
|
|
281
|
+
expect(derivation.getDiffSince(startEpoch)).toHaveLength(0)
|
|
282
|
+
expect(floor).toHaveBeenCalledTimes(3)
|
|
283
|
+
|
|
284
|
+
a.set(2.3)
|
|
285
|
+
|
|
286
|
+
expect(derivation.get()).toBe(2)
|
|
287
|
+
expect(derivation.getDiffSince(startEpoch)).toEqual([+1])
|
|
288
|
+
expect(floor).toHaveBeenCalledTimes(4)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('[H7] is not cleared by an aborted transaction: the round trip is recorded as ordinary entries', () => {
|
|
292
|
+
const a = atom('', 1)
|
|
293
|
+
const b = atom('', 1)
|
|
294
|
+
|
|
295
|
+
const c = computed('', () => a.get() + b.get(), {
|
|
296
|
+
historyLength: 3,
|
|
297
|
+
computeDiff: (a, b) => b - a,
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
const startEpoch = getGlobalEpoch()
|
|
301
|
+
|
|
302
|
+
transaction((rollback) => {
|
|
303
|
+
expect(c.getDiffSince(startEpoch)).toEqual([])
|
|
304
|
+
a.set(2)
|
|
305
|
+
b.set(2)
|
|
306
|
+
expect(c.getDiffSince(startEpoch)).toEqual([+2])
|
|
307
|
+
rollback()
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
expect(c.getDiffSince(startEpoch)).toEqual([2, -2])
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('[H5] returns RESET_VALUE for an epoch before the first computation', () => {
|
|
314
|
+
const a = atom('', 1)
|
|
315
|
+
const b = atom('', 1)
|
|
316
|
+
|
|
317
|
+
const c = computed('', () => a.get() + b.get(), {
|
|
318
|
+
historyLength: 3,
|
|
319
|
+
computeDiff: (a, b) => b - a,
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
expect(c.getDiffSince(getGlobalEpoch() - 1)).toEqual(RESET_VALUE)
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
// The incremental computation pattern that the history system exists to support:
|
|
327
|
+
// a derived record map that applies upstream diffs instead of recomputing from scratch.
|
|
328
|
+
// This exercises [C4], [H5], and [H6] together.
|
|
329
|
+
|
|
330
|
+
type Difference =
|
|
331
|
+
| {
|
|
332
|
+
type: 'CHANGE'
|
|
333
|
+
path: string[]
|
|
334
|
+
value: any
|
|
335
|
+
oldValue: any
|
|
336
|
+
}
|
|
337
|
+
| { type: 'CREATE'; path: string[]; value: any }
|
|
338
|
+
| { type: 'REMOVE'; path: string[]; oldValue: any }
|
|
339
|
+
|
|
340
|
+
function getIncrementalRecordMapper<In, Out>(
|
|
341
|
+
obj: Signal<Record<string, In>, Difference[]>,
|
|
342
|
+
mapper: (t: In, k: string) => Out
|
|
343
|
+
): Computed<Record<string, Out>> {
|
|
344
|
+
function computeFromScratch() {
|
|
345
|
+
const input = obj.get()
|
|
346
|
+
return Object.fromEntries(Object.entries(input).map(([k, v]) => [k, mapper(v, k)]))
|
|
347
|
+
}
|
|
348
|
+
return computed('', (previousValue, lastComputedEpoch) => {
|
|
349
|
+
if (isUninitialized(previousValue)) {
|
|
350
|
+
return computeFromScratch()
|
|
351
|
+
}
|
|
352
|
+
const diff = obj.getDiffSince(lastComputedEpoch)
|
|
353
|
+
if (diff === RESET_VALUE) {
|
|
354
|
+
return computeFromScratch()
|
|
355
|
+
}
|
|
356
|
+
if (diff.length === 0) {
|
|
357
|
+
return previousValue
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const newUpstream = obj.get()
|
|
361
|
+
|
|
362
|
+
const result = { ...previousValue } as Record<string, Out>
|
|
363
|
+
|
|
364
|
+
const changedKeys = new Set<string>()
|
|
365
|
+
for (const change of diff.flat()) {
|
|
366
|
+
const key = change.path[0] as string
|
|
367
|
+
if (changedKeys.has(key)) {
|
|
368
|
+
continue
|
|
369
|
+
}
|
|
370
|
+
switch (change.type) {
|
|
371
|
+
case 'CHANGE':
|
|
372
|
+
case 'CREATE':
|
|
373
|
+
changedKeys.add(key)
|
|
374
|
+
if (key in newUpstream) {
|
|
375
|
+
result[key] = mapper(newUpstream[key], change.path[0] as string)
|
|
376
|
+
} else {
|
|
377
|
+
// key was removed later in this patch
|
|
378
|
+
}
|
|
379
|
+
break
|
|
380
|
+
case 'REMOVE':
|
|
381
|
+
if (key in result) {
|
|
382
|
+
delete result[key]
|
|
383
|
+
}
|
|
384
|
+
break
|
|
385
|
+
default:
|
|
386
|
+
assertNever(change)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return result
|
|
391
|
+
})
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
describe('incremental derivations', () => {
|
|
395
|
+
it('[C4][H5][H6] can apply upstream diffs instead of recomputing from scratch', () => {
|
|
396
|
+
type NumberMap = Record<string, number>
|
|
397
|
+
|
|
398
|
+
const nodes = atom<NumberMap, Difference[]>(
|
|
399
|
+
'',
|
|
400
|
+
{
|
|
401
|
+
a: 1,
|
|
402
|
+
b: 2,
|
|
403
|
+
c: 3,
|
|
404
|
+
d: 4,
|
|
405
|
+
e: 5,
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
historyLength: 10,
|
|
409
|
+
computeDiff: (valA, valB) => {
|
|
410
|
+
const result: Difference[] = []
|
|
411
|
+
for (const keyA in valA) {
|
|
412
|
+
if (!(keyA in valB)) {
|
|
413
|
+
result.push({
|
|
414
|
+
type: 'REMOVE',
|
|
415
|
+
oldValue: valA[keyA],
|
|
416
|
+
path: [keyA],
|
|
417
|
+
})
|
|
418
|
+
} else if (valA[keyA] != valB[keyA]) {
|
|
419
|
+
result.push({
|
|
420
|
+
type: 'CHANGE',
|
|
421
|
+
oldValue: valA[keyA],
|
|
422
|
+
path: [keyA],
|
|
423
|
+
value: valB[keyA],
|
|
424
|
+
})
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
for (const keyB in valB) {
|
|
429
|
+
if (!(keyB in valA)) {
|
|
430
|
+
result.push({
|
|
431
|
+
type: 'CREATE',
|
|
432
|
+
value: valB[keyB],
|
|
433
|
+
path: [keyB],
|
|
434
|
+
})
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return result
|
|
438
|
+
},
|
|
439
|
+
}
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
const mapper = vi.fn((val) => val * 2)
|
|
443
|
+
|
|
444
|
+
const doubledNodes = getIncrementalRecordMapper(nodes, mapper)
|
|
445
|
+
|
|
446
|
+
expect(doubledNodes.get()).toEqual({
|
|
447
|
+
a: 2,
|
|
448
|
+
b: 4,
|
|
449
|
+
c: 6,
|
|
450
|
+
d: 8,
|
|
451
|
+
e: 10,
|
|
452
|
+
})
|
|
453
|
+
expect(mapper).toHaveBeenCalledTimes(5)
|
|
454
|
+
|
|
455
|
+
nodes.update((ns) => ({ ...ns, a: 10 }))
|
|
456
|
+
|
|
457
|
+
expect(doubledNodes.get()).toEqual({
|
|
458
|
+
a: 20,
|
|
459
|
+
b: 4,
|
|
460
|
+
c: 6,
|
|
461
|
+
d: 8,
|
|
462
|
+
e: 10,
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
expect(mapper).toHaveBeenCalledTimes(6)
|
|
466
|
+
|
|
467
|
+
// remove d
|
|
468
|
+
nodes.update(({ d: _d, ...others }) => others)
|
|
469
|
+
|
|
470
|
+
expect(doubledNodes.get()).toEqual({
|
|
471
|
+
a: 20,
|
|
472
|
+
b: 4,
|
|
473
|
+
c: 6,
|
|
474
|
+
e: 10,
|
|
475
|
+
})
|
|
476
|
+
expect(mapper).toHaveBeenCalledTimes(6)
|
|
477
|
+
|
|
478
|
+
nodes.update((ns) => ({ ...ns, f: 50, g: 60 }))
|
|
479
|
+
|
|
480
|
+
expect(doubledNodes.get()).toEqual({
|
|
481
|
+
a: 20,
|
|
482
|
+
b: 4,
|
|
483
|
+
c: 6,
|
|
484
|
+
e: 10,
|
|
485
|
+
f: 100,
|
|
486
|
+
g: 120,
|
|
487
|
+
})
|
|
488
|
+
expect(mapper).toHaveBeenCalledTimes(8)
|
|
489
|
+
|
|
490
|
+
nodes.set({ ...nodes.get() })
|
|
491
|
+
// no changes so no new calls to mapper
|
|
492
|
+
expect(doubledNodes.get()).toEqual({
|
|
493
|
+
a: 20,
|
|
494
|
+
b: 4,
|
|
495
|
+
c: 6,
|
|
496
|
+
e: 10,
|
|
497
|
+
f: 100,
|
|
498
|
+
g: 120,
|
|
499
|
+
})
|
|
500
|
+
expect(mapper).toHaveBeenCalledTimes(8)
|
|
501
|
+
|
|
502
|
+
// make several changes
|
|
503
|
+
|
|
504
|
+
nodes.update((ns) => ({ ...ns, a: 1 }))
|
|
505
|
+
nodes.update((ns) => ({ ...ns, b: 9 }))
|
|
506
|
+
nodes.update((ns) => ({ ...ns, c: 17 }))
|
|
507
|
+
nodes.update(({ f: _f, g: _g, ...others }) => ({ ...others }))
|
|
508
|
+
nodes.update((ns) => ({ ...ns, d: 4 }))
|
|
509
|
+
nodes.update((ns) => ({ ...ns, a: 4 }))
|
|
510
|
+
|
|
511
|
+
// nothing was called because we didn't deref yet
|
|
512
|
+
expect(mapper).toHaveBeenCalledTimes(8)
|
|
513
|
+
|
|
514
|
+
expect(doubledNodes.get()).toEqual({
|
|
515
|
+
a: 8,
|
|
516
|
+
b: 18,
|
|
517
|
+
c: 34,
|
|
518
|
+
d: 8,
|
|
519
|
+
e: 10,
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
expect(mapper).toHaveBeenCalledTimes(12)
|
|
523
|
+
})
|
|
524
|
+
})
|