@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.
@@ -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
- const child: 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
-
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: Child = {
29
- parents: [parentAtom],
30
- parentEpochs: [oldEpoch],
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
- const oldEpoch1 = parent1.lastChangedEpoch
48
- const oldEpoch2 = parent2.lastChangedEpoch
49
-
50
- const child: Child = {
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: 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: 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
+ })