@tldraw/state 5.3.2 → 5.4.0-canary.02cd0bd3b597

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.
Files changed (71) hide show
  1. package/DOCS.md +64 -63
  2. package/README.md +35 -36
  3. package/dist-cjs/index.d.ts +26 -27
  4. package/dist-cjs/index.js +1 -1
  5. package/dist-cjs/lib/ArraySet.js +47 -144
  6. package/dist-cjs/lib/ArraySet.js.map +2 -2
  7. package/dist-cjs/lib/Atom.js +12 -26
  8. package/dist-cjs/lib/Atom.js.map +2 -2
  9. package/dist-cjs/lib/Computed.js +36 -64
  10. package/dist-cjs/lib/Computed.js.map +2 -2
  11. package/dist-cjs/lib/EffectScheduler.js +1 -1
  12. package/dist-cjs/lib/EffectScheduler.js.map +2 -2
  13. package/dist-cjs/lib/HistoryBuffer.js +8 -8
  14. package/dist-cjs/lib/HistoryBuffer.js.map +2 -2
  15. package/dist-cjs/lib/capture.js +1 -3
  16. package/dist-cjs/lib/capture.js.map +2 -2
  17. package/dist-cjs/lib/constants.js.map +2 -2
  18. package/dist-cjs/lib/helpers.js +3 -11
  19. package/dist-cjs/lib/helpers.js.map +2 -2
  20. package/dist-cjs/lib/localStorageAtom.js +7 -2
  21. package/dist-cjs/lib/localStorageAtom.js.map +2 -2
  22. package/dist-cjs/lib/transactions.js +11 -19
  23. package/dist-cjs/lib/transactions.js.map +2 -2
  24. package/dist-cjs/lib/types.js.map +1 -1
  25. package/dist-cjs/lib/warnings.js +2 -4
  26. package/dist-cjs/lib/warnings.js.map +2 -2
  27. package/dist-esm/index.d.mts +26 -27
  28. package/dist-esm/index.mjs +1 -1
  29. package/dist-esm/lib/ArraySet.mjs +47 -144
  30. package/dist-esm/lib/ArraySet.mjs.map +2 -2
  31. package/dist-esm/lib/Atom.mjs +12 -26
  32. package/dist-esm/lib/Atom.mjs.map +2 -2
  33. package/dist-esm/lib/Computed.mjs +36 -64
  34. package/dist-esm/lib/Computed.mjs.map +2 -2
  35. package/dist-esm/lib/EffectScheduler.mjs +1 -1
  36. package/dist-esm/lib/EffectScheduler.mjs.map +2 -2
  37. package/dist-esm/lib/HistoryBuffer.mjs +8 -8
  38. package/dist-esm/lib/HistoryBuffer.mjs.map +2 -2
  39. package/dist-esm/lib/capture.mjs +1 -3
  40. package/dist-esm/lib/capture.mjs.map +2 -2
  41. package/dist-esm/lib/constants.mjs.map +2 -2
  42. package/dist-esm/lib/helpers.mjs +3 -11
  43. package/dist-esm/lib/helpers.mjs.map +2 -2
  44. package/dist-esm/lib/localStorageAtom.mjs +7 -2
  45. package/dist-esm/lib/localStorageAtom.mjs.map +2 -2
  46. package/dist-esm/lib/transactions.mjs +11 -19
  47. package/dist-esm/lib/transactions.mjs.map +2 -2
  48. package/dist-esm/lib/types.mjs.map +1 -1
  49. package/dist-esm/lib/warnings.mjs +2 -4
  50. package/dist-esm/lib/warnings.mjs.map +2 -2
  51. package/package.json +2 -2
  52. package/src/lib/ArraySet.ts +68 -176
  53. package/src/lib/Atom.ts +27 -31
  54. package/src/lib/Computed.ts +68 -96
  55. package/src/lib/EffectScheduler.ts +9 -8
  56. package/src/lib/HistoryBuffer.ts +12 -10
  57. package/src/lib/__tests__/ArraySet.test.ts +39 -13
  58. package/src/lib/__tests__/EffectScheduler.test.ts +18 -0
  59. package/src/lib/__tests__/HistoryBuffer.test.ts +6 -3
  60. package/src/lib/__tests__/computed.test.ts +75 -0
  61. package/src/lib/__tests__/errors.test.ts +24 -0
  62. package/src/lib/__tests__/helpers.test.ts +7 -11
  63. package/src/lib/__tests__/history.test.ts +32 -2
  64. package/src/lib/__tests__/localStorageAtom.test.ts +15 -0
  65. package/src/lib/capture.ts +13 -13
  66. package/src/lib/constants.ts +3 -22
  67. package/src/lib/helpers.ts +15 -140
  68. package/src/lib/localStorageAtom.ts +9 -2
  69. package/src/lib/transactions.ts +23 -47
  70. package/src/lib/types.ts +7 -7
  71. package/src/lib/warnings.ts +2 -10
@@ -150,7 +150,7 @@ export interface ComputedOptions<Value, Diff> {
150
150
  /**
151
151
  * The maximum number of diffs to keep in the history buffer.
152
152
  *
153
- * If you don't need to compute diffs, or if you will supply diffs manually via {@link Atom.set}, you can leave this as `undefined` and no history buffer will be created.
153
+ * If you don't need diffs, leave this as `undefined` and no history buffer will be created. Diffs supplied via {@link withDiff} or {@link ComputedOptions.computeDiff} are only recorded when this is set.
154
154
  *
155
155
  * If you expect the value to be part of an active effect subscription all the time, and to not change multiple times inside of a single transaction, you can set this to a relatively low number (e.g. 10).
156
156
  *
@@ -294,31 +294,43 @@ class __UNSAFE__Computed<Value, Diff = unknown> implements Computed<Value, Diff>
294
294
  const result = this.derive(this.state, this.lastCheckedEpoch)
295
295
  const newState = result instanceof WithDiff ? result.value : result
296
296
  const isUninitialized = this.state === UNINITIALIZED
297
+ // `derive` may have advanced the epoch, so re-read it here, but only once — the whole
298
+ // commit below belongs to a single epoch.
299
+ const epoch = getGlobalEpoch()
297
300
  if (isUninitialized || !this.isEqual(this.state, newState)) {
298
301
  if (this.historyBuffer && !isUninitialized) {
302
+ // Only `undefined` means "no diff supplied"; `null` can be a legitimate diff.
299
303
  const diff = result instanceof WithDiff ? result.diff : undefined
300
304
  this.historyBuffer.pushEntry(
301
305
  this.lastChangedEpoch,
302
- getGlobalEpoch(),
303
- diff ??
304
- this.computeDiff?.(this.state, newState, this.lastCheckedEpoch, getGlobalEpoch()) ??
305
- RESET_VALUE
306
+ epoch,
307
+ diff !== undefined
308
+ ? diff
309
+ : this.computeDiff
310
+ ? this.computeDiff(this.state, newState, this.lastCheckedEpoch, epoch)
311
+ : RESET_VALUE
306
312
  )
307
313
  }
308
- this.lastChangedEpoch = getGlobalEpoch()
314
+ this.lastChangedEpoch = epoch
309
315
  this.state = newState
310
316
  }
311
317
  this.error = null
312
- this.lastCheckedEpoch = getGlobalEpoch()
318
+ this.lastCheckedEpoch = epoch
313
319
 
314
320
  return this.state
315
321
  } catch (e) {
316
322
  // if a derived value throws an error, we reset the state to UNINITIALIZED
317
- if (this.state !== UNINITIALIZED) {
323
+ const epoch = getGlobalEpoch()
324
+ // Entering the error state (from a value, or from never having computed) is a change;
325
+ // throwing again while already in it is not. Checking `error` rather than `state` matters
326
+ // for a first run that throws: `state` is already UNINITIALIZED then, and leaving
327
+ // `lastChangedEpoch` at GLOBAL_START_EPOCH would keep `isNew` true and re-run `derive` on
328
+ // every read instead of rethrowing the cached error.
329
+ if (this.error === null) {
318
330
  this.state = UNINITIALIZED as unknown as Value
319
- this.lastChangedEpoch = getGlobalEpoch()
331
+ this.lastChangedEpoch = epoch
320
332
  }
321
- this.lastCheckedEpoch = getGlobalEpoch()
333
+ this.lastCheckedEpoch = epoch
322
334
  // we also clear the history buffer if an error was thrown
323
335
  if (this.historyBuffer) {
324
336
  this.historyBuffer.clear()
@@ -370,74 +382,23 @@ export const _Computed = singleton('Computed', () => __UNSAFE__Computed)
370
382
  */
371
383
  export type _Computed = InstanceType<typeof __UNSAFE__Computed>
372
384
 
373
- function computedMethodLegacyDecorator(
374
- options: ComputedOptions<any, any> = {},
375
- _target: any,
376
- key: string,
377
- descriptor: PropertyDescriptor
378
- ) {
379
- const originalMethod = descriptor.value
380
- const derivationKey = Symbol.for('__@tldraw/state__computed__' + key)
381
-
382
- descriptor.value = function (this: any) {
383
- let d = this[derivationKey] as Computed<any> | undefined
384
-
385
- if (!d) {
386
- d = new _Computed(key, originalMethod!.bind(this) as any, options)
387
- Object.defineProperty(this, derivationKey, {
388
- enumerable: false,
389
- configurable: false,
390
- writable: false,
391
- value: d,
392
- })
393
- }
394
- return d.get()
395
- }
396
- descriptor.value[isComputedMethodKey] = true
397
-
398
- return descriptor
399
- }
400
-
401
- function computedGetterLegacyDecorator(
402
- options: ComputedOptions<any, any> = {},
403
- _target: any,
404
- key: string,
405
- descriptor: PropertyDescriptor
406
- ) {
407
- const originalMethod = descriptor.get
408
- const derivationKey = Symbol.for('__@tldraw/state__computed__' + key)
385
+ // Set on every decorated wrapper: the symbol its per-instance computed is stored under.
386
+ const derivationKeyKey = '@@__computedDerivationKey__@@'
409
387
 
410
- descriptor.get = function (this: any) {
411
- let d = this[derivationKey] as Computed<any> | undefined
412
-
413
- if (!d) {
414
- d = new _Computed(key, originalMethod!.bind(this) as any, options)
415
- Object.defineProperty(this, derivationKey, {
416
- enumerable: false,
417
- configurable: false,
418
- writable: false,
419
- value: d,
420
- })
421
- }
422
- return d.get()
423
- }
424
-
425
- return descriptor
426
- }
427
-
428
- function computedMethodTc39Decorator<This extends object, Value>(
429
- options: ComputedOptions<Value, any>,
430
- compute: () => Value,
431
- context: ClassMethodDecoratorContext<This, () => Value>
432
- ) {
433
- assert(context.kind === 'method', '@computed can only be used on methods')
434
- const derivationKey = Symbol.for('__@tldraw/state__computed__' + String(context.name))
388
+ /**
389
+ * Builds the method (or getter) body that `@computed` installs: a per-instance, lazily-created
390
+ * computed over the original method. The storage symbol is unique per decoration rather than
391
+ * `Symbol.for(name)` so that a subclass which overrides a `@computed` method and calls
392
+ * `super.method()` reaches the superclass's computed instead of re-entering its own.
393
+ */
394
+ function makeComputedWrapper(name: string, compute: () => any, options: ComputedOptions<any, any>) {
395
+ const derivationKey = Symbol('__@tldraw/state__computed__' + name)
435
396
 
436
- const fn = function (this: any) {
397
+ const wrapper = function (this: any) {
437
398
  let d = this[derivationKey] as Computed<any> | undefined
438
399
 
439
400
  if (!d) {
440
- d = new _Computed(String(context.name), compute.bind(this) as any, options)
401
+ d = new _Computed(name, compute.bind(this) as any, options)
441
402
  Object.defineProperty(this, derivationKey, {
442
403
  enumerable: false,
443
404
  configurable: false,
@@ -446,9 +407,9 @@ function computedMethodTc39Decorator<This extends object, Value>(
446
407
  })
447
408
  }
448
409
  return d.get()
449
- }
450
- fn[isComputedMethodKey] = true
451
- return fn
410
+ } as any
411
+ wrapper[derivationKeyKey] = derivationKey
412
+ return wrapper
452
413
  }
453
414
 
454
415
  function computedDecorator(
@@ -459,20 +420,20 @@ function computedDecorator(
459
420
  ) {
460
421
  if (args.length === 2) {
461
422
  const [originalMethod, context] = args
462
- return computedMethodTc39Decorator(options, originalMethod, context)
423
+ assert(context.kind === 'method', '@computed can only be used on methods')
424
+ return makeComputedWrapper(String(context.name), originalMethod, options)
463
425
  } else {
464
426
  const [_target, key, descriptor] = args
465
427
  if (descriptor.get) {
466
428
  logComputedGetterWarning()
467
- return computedGetterLegacyDecorator(options, _target, key, descriptor)
429
+ descriptor.get = makeComputedWrapper(key, descriptor.get, options)
468
430
  } else {
469
- return computedMethodLegacyDecorator(options, _target, key, descriptor)
431
+ descriptor.value = makeComputedWrapper(key, descriptor.value, options)
470
432
  }
433
+ return descriptor
471
434
  }
472
435
  }
473
436
 
474
- const isComputedMethodKey = '@@__isComputedMethod__@@'
475
-
476
437
  /**
477
438
  * Retrieves the underlying computed instance for a given property created with the `computed`
478
439
  * decorator.
@@ -481,7 +442,7 @@ const isComputedMethodKey = '@@__isComputedMethod__@@'
481
442
  * ```ts
482
443
  * class Counter {
483
444
  * max = 100
484
- * count = atom(0)
445
+ * count = atom('count', 0)
485
446
  *
486
447
  * @computed getRemaining() {
487
448
  * return this.max - this.count.get()
@@ -502,19 +463,29 @@ const isComputedMethodKey = '@@__isComputedMethod__@@'
502
463
  export function getComputedInstance<Obj extends object, Prop extends keyof Obj>(
503
464
  obj: Obj,
504
465
  propertyName: Prop
505
- ): Computed<Obj[Prop]> {
506
- const key = Symbol.for('__@tldraw/state__computed__' + propertyName.toString())
507
- let inst = obj[key as keyof typeof obj] as Computed<Obj[Prop]> | undefined
508
- if (!inst) {
509
- // deref to make sure it exists first
510
- const val = obj[propertyName]
511
- if (typeof val === 'function' && (val as any)[isComputedMethodKey]) {
512
- val.call(obj)
466
+ ): Computed<Obj[Prop] extends () => infer Value ? Value : Obj[Prop]> {
467
+ // The nearest decorated wrapper (a method, or a legacy getter) on the prototype chain knows
468
+ // which symbol the instance's computed is stored under.
469
+ for (let proto: any = obj; proto; proto = Object.getPrototypeOf(proto)) {
470
+ const descriptor = Object.getOwnPropertyDescriptor(proto, propertyName)
471
+ const wrapper = descriptor?.get ?? descriptor?.value
472
+ const key = wrapper?.[derivationKeyKey]
473
+ if (!key) continue
474
+
475
+ let inst = (obj as any)[key]
476
+ if (!inst) {
477
+ // calling the wrapper creates the computed (before it first derives, so a throwing derive
478
+ // still leaves the instance behind)
479
+ try {
480
+ wrapper.call(obj)
481
+ } catch {
482
+ // the caller asked for the instance, not its value
483
+ }
484
+ inst = (obj as any)[key]
513
485
  }
514
-
515
- inst = obj[key as keyof typeof obj] as Computed<Obj[Prop]> | undefined
486
+ return inst
516
487
  }
517
- return inst as any
488
+ return undefined as any
518
489
  }
519
490
 
520
491
  /**
@@ -535,7 +506,7 @@ export function getComputedInstance<Obj extends object, Prop extends keyof Obj>(
535
506
  * ```ts
536
507
  * class Counter {
537
508
  * max = 100
538
- * count = atom<number>(0)
509
+ * count = atom('count', 0)
539
510
  *
540
511
  * @computed getRemaining() {
541
512
  * return this.max - this.count.get()
@@ -549,7 +520,7 @@ export function getComputedInstance<Obj extends object, Prop extends keyof Obj>(
549
520
  * ```ts
550
521
  * class Counter {
551
522
  * max = 100
552
- * count = atom<number>(0)
523
+ * count = atom('count', 0)
553
524
  *
554
525
  * @computed({isEqual: (a, b) => a === b})
555
526
  * getRemaining() {
@@ -661,7 +632,8 @@ export function computed<Value, Diff = unknown>(
661
632
  * @public
662
633
  */
663
634
  export function computed() {
664
- if (arguments.length === 1) {
635
+ if (arguments.length <= 1) {
636
+ // decorator factory: `@computed(options)` or `@computed()`
665
637
  const options = arguments[0]
666
638
  return (...args: any) => computedDecorator(options, args)
667
639
  } else if (typeof arguments[0] === 'string') {
@@ -29,12 +29,9 @@ export interface EffectSchedulerOptions {
29
29
  * }
30
30
  * }
31
31
  * const stop = react('set page title', () => {
32
- * document.title = doc.title,
33
- * }, scheduleEffect)
32
+ * document.title = doc.title
33
+ * }, { scheduleEffect })
34
34
  * ```
35
- *
36
- * @param execute - A function that will execute the effect.
37
- * @returns void
38
35
  */
39
36
  // eslint-disable-next-line tldraw/method-signature-style
40
37
  scheduleEffect?: (execute: () => void) => void
@@ -95,12 +92,16 @@ class __EffectScheduler__<Result> implements EffectScheduler<Result> {
95
92
  // bail out if no atoms have changed since the last time we ran this effect
96
93
  if (this.lastReactedEpoch === getGlobalEpoch()) return
97
94
 
98
- // bail out if we have parents and they have not changed since last time
99
- if (this.parents.length && !haveParentsChanged(this)) {
95
+ // An effect that has run before (or captured parents before throwing) only needs to run
96
+ // again if one of those parents changed; that includes an effect that captured no parents at
97
+ // all. An effect that has never run always runs.
98
+ if (
99
+ (this.lastReactedEpoch !== GLOBAL_START_EPOCH || this.parents.length > 0) &&
100
+ !haveParentsChanged(this)
101
+ ) {
100
102
  this.lastReactedEpoch = getGlobalEpoch()
101
103
  return
102
104
  }
103
- // if we don't have parents it's probably the first time this is running.
104
105
  this.scheduleEffect()
105
106
  }
106
107
 
@@ -1,3 +1,4 @@
1
+ import { EMPTY_ARRAY } from './helpers'
1
2
  import { RESET_VALUE } from './types'
2
3
 
3
4
  /**
@@ -15,6 +16,9 @@ type RangeTuple<Diff> = [fromEpoch: number, toEpoch: number, diff: Diff]
15
16
  * The buffer uses a wrap-around strategy to maintain a fixed-size history of the most recent
16
17
  * changes, automatically overwriting older entries when the capacity is exceeded.
17
18
  *
19
+ * Entries must be contiguous (`entry[k].toEpoch === entry[k+1].fromEpoch`); `getChangesSince`
20
+ * relies on that to find the entry covering an epoch.
21
+ *
18
22
  * @example
19
23
  * ```ts
20
24
  * const buffer = new HistoryBuffer<string>(5)
@@ -54,12 +58,14 @@ export class HistoryBuffer<Diff> {
54
58
  /**
55
59
  * Adds a diff entry to the history buffer, representing a change between two epochs.
56
60
  *
57
- * If the diff is undefined, the operation is ignored. If the diff is RESET_VALUE,
58
- * the entire buffer is cleared to indicate that historical tracking should restart.
61
+ * If the diff is RESET_VALUE, or undefined (meaning no diff is available), the entire buffer is
62
+ * cleared to indicate that historical tracking should restart. Silently skipping an entry would
63
+ * leave a gap, and a later `getChangesSince` from before the gap would return an incomplete
64
+ * diff list rather than RESET_VALUE.
59
65
  *
60
66
  * @param lastComputedEpoch - The epoch when the previous value was computed
61
67
  * @param currentEpoch - The epoch when the current value was computed
62
- * @param diff - The diff representing the change, or RESET_VALUE to clear history
68
+ * @param diff - The diff representing the change, or RESET_VALUE / undefined to clear history
63
69
  * @example
64
70
  * ```ts
65
71
  * const buffer = new HistoryBuffer<string>(5)
@@ -67,12 +73,8 @@ export class HistoryBuffer<Diff> {
67
73
  * buffer.pushEntry(1, 2, RESET_VALUE) // Clears the buffer
68
74
  * ```
69
75
  */
70
- pushEntry(lastComputedEpoch: number, currentEpoch: number, diff: Diff | RESET_VALUE) {
71
- if (diff === undefined) {
72
- return
73
- }
74
-
75
- if (diff === RESET_VALUE) {
76
+ pushEntry(lastComputedEpoch: number, currentEpoch: number, diff: Diff | RESET_VALUE | undefined) {
77
+ if (diff === RESET_VALUE || diff === undefined) {
76
78
  this.clear()
77
79
  return
78
80
  }
@@ -139,7 +141,7 @@ export class HistoryBuffer<Diff> {
139
141
 
140
142
  // If the first element is already too early, bail
141
143
  if (i === 0 && sinceEpoch >= toEpoch) {
142
- return []
144
+ return EMPTY_ARRAY
143
145
  }
144
146
 
145
147
  // If the element is since the given epoch, return an array with all diffs from this element and all following elements
@@ -91,6 +91,24 @@ describe(ArraySet, () => {
91
91
  expect(as.add(1)).toBe(true)
92
92
  })
93
93
 
94
+ it('[AS4] a fresh ArraySet answers every query as empty and survives remove and clear', () => {
95
+ const as = new ArraySet<number>()
96
+
97
+ expect(as.has(1)).toBe(false)
98
+ expect(as.size()).toBe(0)
99
+ expect(as.isEmpty).toBe(true)
100
+ expect([...as]).toEqual([])
101
+ expect(get(as)).toEqual(new Set())
102
+
103
+ expect(as.remove(1)).toBe(false)
104
+ as.clear()
105
+
106
+ expect(as.isEmpty).toBe(true)
107
+ expect(as.add(1)).toBe(true)
108
+ expect(as.has(1)).toBe(true)
109
+ expect(as.size()).toBe(1)
110
+ })
111
+
94
112
  it('works with small numbers of things', () => {
95
113
  const as = new ArraySet<number>()
96
114
 
@@ -172,25 +190,33 @@ function runTest(seed: number) {
172
190
  const s = new Set<number>()
173
191
  const r = rng(seed)
174
192
 
175
- const nums = new Array(ARRAY_SIZE_THRESHOLD * 2).fill(0).map(() => Math.floor(r() * 100))
193
+ // Enough candidates, and enough bias toward adds, that the set fills past the promotion
194
+ // threshold rather than hovering below it in array mode forever.
195
+ const nums = new Array(ARRAY_SIZE_THRESHOLD * 4).fill(0).map(() => Math.floor(r() * 100))
176
196
 
177
197
  for (let i = 0; i < 1000; i++) {
178
198
  const num = nums[Math.floor(r() * nums.length)]
179
199
 
180
200
  const choice = r()
181
- if (choice < 0.45) {
182
- as.add(num)
183
- s.add(num)
184
- } else if (choice < 0.9) {
185
- as.remove(num)
186
- s.delete(num)
187
- } else {
188
- as.clear()
189
- s.clear()
190
- }
191
-
192
201
  try {
202
+ if (choice < 0.55) {
203
+ expect(as.add(num)).toBe(!s.has(num))
204
+ s.add(num)
205
+ } else if (choice < 0.95) {
206
+ expect(as.remove(num)).toBe(s.has(num))
207
+ s.delete(num)
208
+ } else {
209
+ as.clear()
210
+ s.clear()
211
+ }
212
+
193
213
  expect(get(as)).toEqual(s)
214
+ expect(new Set([...as])).toEqual(s)
215
+ expect(as.size()).toBe(s.size)
216
+ expect(as.isEmpty).toBe(s.size === 0)
217
+ for (const n of nums) {
218
+ expect(as.has(n)).toBe(s.has(n))
219
+ }
194
220
  } catch (e) {
195
221
  console.error('Failed on iteration', i, 'with seed', seed)
196
222
  throw e
@@ -198,7 +224,7 @@ function runTest(seed: number) {
198
224
  }
199
225
  }
200
226
 
201
- describe('fuzzing this thing (if this fails tell david)', () => {
227
+ describe('fuzzing against a reference Set', () => {
202
228
  new Array(10).fill(0).forEach(() => {
203
229
  const seed = Math.floor(Math.random() * 1000000)
204
230
  it(`fuzz with seed ${seed}`, () => {
@@ -233,6 +233,24 @@ describe('reactor (E)', () => {
233
233
  r.scheduler.maybeScheduleEffect()
234
234
  expect(rfn).toHaveBeenCalledTimes(1)
235
235
  })
236
+
237
+ it('[E2][E9] an effect that captured no parents does not re-run on start() after unrelated changes', () => {
238
+ const unrelated = atom('', 0)
239
+ const rfn = vi.fn()
240
+ const r = reactor('', rfn)
241
+
242
+ r.start()
243
+ expect(rfn).toHaveBeenCalledTimes(1)
244
+
245
+ r.stop()
246
+ unrelated.set(1)
247
+ r.start()
248
+ // no parent changed (there are none), so there is nothing to react to
249
+ expect(rfn).toHaveBeenCalledTimes(1)
250
+
251
+ r.start({ force: true })
252
+ expect(rfn).toHaveBeenCalledTimes(2)
253
+ })
236
254
  })
237
255
 
238
256
  describe('custom scheduling (E6, E7)', () => {
@@ -47,13 +47,16 @@ describe('HistoryBuffer', () => {
47
47
  expect(buf.getChangesSince(0)).toEqual(RESET_VALUE)
48
48
  })
49
49
 
50
- it('[HB1] ignores undefined diffs', () => {
50
+ it('[HB1] treats an undefined diff like RESET_VALUE: it clears rather than leaving a gap', () => {
51
51
  const buf = new HistoryBuffer<string>(3)
52
52
  buf.pushEntry(0, 1, 'a')
53
53
  buf.pushEntry(1, 2, undefined as any)
54
+ buf.pushEntry(2, 3, 'c')
54
55
 
55
- expect(buf.getChangesSince(0)).toEqual(['a'])
56
- expect(buf.getChangesSince(1)).toEqual([])
56
+ // if the undefined entry had been skipped, this would be ['a', 'c'] with the 1 -> 2 change missing
57
+ expect(buf.getChangesSince(0)).toEqual(RESET_VALUE)
58
+ expect(buf.getChangesSince(1)).toEqual(RESET_VALUE)
59
+ expect(buf.getChangesSince(2)).toEqual(['c'])
57
60
  })
58
61
 
59
62
  it('[HB1] will clear if you push RESET_VALUE', () => {
@@ -333,4 +333,79 @@ describe('the computed decorator (C8, C9, C10)', () => {
333
333
 
334
334
  warn.mockRestore()
335
335
  })
336
+
337
+ it('[C8] can be applied as a factory with no options', () => {
338
+ // the options argument is optional, so `@computed()` must work like `@computed`
339
+ class Foo {
340
+ a = atom('a', 1)
341
+ @computed()
342
+ getB() {
343
+ return this.a.get() * 2
344
+ }
345
+ }
346
+
347
+ const foo = new Foo()
348
+ expect(foo.getB()).toBe(2)
349
+ foo.a.set(2)
350
+ expect(foo.getB()).toBe(4)
351
+ })
352
+
353
+ it('[C8] a subclass override can call the superclass computed via super', () => {
354
+ // each decoration stores its computed under its own key; keying by method name made the
355
+ // super call re-enter the subclass's own computed and overflow the stack
356
+ class Base {
357
+ a = atom('a', 1)
358
+ @computed
359
+ getB() {
360
+ return this.a.get() * 2
361
+ }
362
+ }
363
+ class Sub extends Base {
364
+ @computed
365
+ override getB() {
366
+ return super.getB() + 1
367
+ }
368
+ }
369
+
370
+ const sub = new Sub()
371
+ expect(sub.getB()).toBe(3)
372
+ sub.a.set(2)
373
+ expect(sub.getB()).toBe(5)
374
+ expect(new Base().getB()).toBe(2)
375
+ })
376
+
377
+ it('[C9] getComputedInstance resolves the nearest decorated method on the prototype chain', () => {
378
+ class Base {
379
+ a = atom('a', 1)
380
+ @computed
381
+ getB() {
382
+ return this.a.get() * 2
383
+ }
384
+ }
385
+ class Sub extends Base {
386
+ @computed
387
+ override getB() {
388
+ return super.getB() + 1
389
+ }
390
+ }
391
+ class PlainSub extends Base {
392
+ override getB() {
393
+ return super.getB() + 100
394
+ }
395
+ }
396
+
397
+ const sub = new Sub()
398
+ const subInst = getComputedInstance(sub, 'getB')
399
+ expect(subInst.get()).toBe(3)
400
+ sub.a.set(2)
401
+ expect(subInst.get()).toBe(5)
402
+
403
+ // a plain (undecorated) override still exposes the superclass's computed
404
+ const plain = new PlainSub()
405
+ expect(getComputedInstance(plain, 'getB').get()).toBe(2)
406
+
407
+ // the returned type unwraps the method's return type
408
+ const n: number = getComputedInstance(new Base(), 'getB').get()
409
+ expect(n).toBe(2)
410
+ })
336
411
  })
@@ -36,6 +36,30 @@ describe('computed signals that throw (CE)', () => {
36
36
  expect(b.get()).toBe(3)
37
37
  })
38
38
 
39
+ it('[CE1] cache a throw from the very first computation too', () => {
40
+ let numComputations = 0
41
+ const a = atom('', 1)
42
+ const b = computed('', () => {
43
+ numComputations++
44
+ if (a.get() === 1) throw new Error('test')
45
+ return a.get()
46
+ })
47
+
48
+ expect(() => b.get()).toThrowErrorMatchingInlineSnapshot(`[Error: test]`)
49
+ expect(() => b.get()).toThrowErrorMatchingInlineSnapshot(`[Error: test]`)
50
+ expect(() => b.get()).toThrowErrorMatchingInlineSnapshot(`[Error: test]`)
51
+ expect(numComputations).toBe(1)
52
+
53
+ // an unrelated change doesn't re-run it either
54
+ atom('', 0).set(1)
55
+ expect(() => b.get()).toThrowErrorMatchingInlineSnapshot(`[Error: test]`)
56
+ expect(numComputations).toBe(1)
57
+
58
+ a.set(2)
59
+ expect(b.get()).toBe(2)
60
+ expect(numComputations).toBe(2)
61
+ })
62
+
39
63
  it('[CE2] entering the error state notifies effects, but consecutive errors do not', () => {
40
64
  const a = atom('', 1)
41
65
  let numComputations = 0
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
2
2
  import { ArraySet } from '../ArraySet'
3
3
  import { atom } from '../Atom'
4
4
  import { reactor } from '../EffectScheduler'
5
- import { attach, detach, equals, hasReactors, haveParentsChanged, singleton } from '../helpers'
5
+ import { attach, detach, equals, haveParentsChanged, singleton } from '../helpers'
6
6
  import { Child } from '../types'
7
7
 
8
8
  // Unit tests for the internal helpers behind SPEC.md rules EQ1/EQ2 (equals),
@@ -132,25 +132,21 @@ describe('helpers', () => {
132
132
  })
133
133
  })
134
134
 
135
- describe('hasReactors', () => {
136
- it('returns false when signal has no actively listening children', () => {
137
- const signal = atom('test', 1)
138
- expect(hasReactors(signal)).toBe(false)
139
- })
140
-
141
- it('integrates correctly with real reactive signals', () => {
135
+ // CAP7: a signal's children set is non-empty exactly while something downstream is listening
136
+ describe('children', () => {
137
+ it('is empty unless something is actively listening', () => {
142
138
  const baseAtom = atom('base', 1)
143
- expect(hasReactors(baseAtom)).toBe(false)
139
+ expect(baseAtom.children.isEmpty).toBe(true)
144
140
 
145
141
  const reactorInstance = reactor('test-reactor', () => {
146
142
  baseAtom.get()
147
143
  })
148
144
 
149
145
  reactorInstance.start()
150
- expect(hasReactors(baseAtom)).toBe(true)
146
+ expect(baseAtom.children.isEmpty).toBe(false)
151
147
 
152
148
  reactorInstance.stop()
153
- expect(hasReactors(baseAtom)).toBe(false)
149
+ expect(baseAtom.children.isEmpty).toBe(true)
154
150
  })
155
151
  })
156
152
  })