@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.
- package/DOCS.md +64 -63
- package/README.md +35 -36
- package/dist-cjs/index.d.ts +26 -27
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/ArraySet.js +47 -144
- package/dist-cjs/lib/ArraySet.js.map +2 -2
- package/dist-cjs/lib/Atom.js +12 -26
- package/dist-cjs/lib/Atom.js.map +2 -2
- package/dist-cjs/lib/Computed.js +36 -64
- package/dist-cjs/lib/Computed.js.map +2 -2
- package/dist-cjs/lib/EffectScheduler.js +1 -1
- package/dist-cjs/lib/EffectScheduler.js.map +2 -2
- package/dist-cjs/lib/HistoryBuffer.js +8 -8
- package/dist-cjs/lib/HistoryBuffer.js.map +2 -2
- package/dist-cjs/lib/capture.js +1 -3
- package/dist-cjs/lib/capture.js.map +2 -2
- package/dist-cjs/lib/constants.js.map +2 -2
- package/dist-cjs/lib/helpers.js +3 -11
- package/dist-cjs/lib/helpers.js.map +2 -2
- package/dist-cjs/lib/localStorageAtom.js +7 -2
- package/dist-cjs/lib/localStorageAtom.js.map +2 -2
- package/dist-cjs/lib/transactions.js +11 -19
- package/dist-cjs/lib/transactions.js.map +2 -2
- package/dist-cjs/lib/types.js.map +1 -1
- package/dist-cjs/lib/warnings.js +2 -4
- package/dist-cjs/lib/warnings.js.map +2 -2
- package/dist-esm/index.d.mts +26 -27
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/ArraySet.mjs +47 -144
- package/dist-esm/lib/ArraySet.mjs.map +2 -2
- package/dist-esm/lib/Atom.mjs +12 -26
- package/dist-esm/lib/Atom.mjs.map +2 -2
- package/dist-esm/lib/Computed.mjs +36 -64
- package/dist-esm/lib/Computed.mjs.map +2 -2
- package/dist-esm/lib/EffectScheduler.mjs +1 -1
- package/dist-esm/lib/EffectScheduler.mjs.map +2 -2
- package/dist-esm/lib/HistoryBuffer.mjs +8 -8
- package/dist-esm/lib/HistoryBuffer.mjs.map +2 -2
- package/dist-esm/lib/capture.mjs +1 -3
- package/dist-esm/lib/capture.mjs.map +2 -2
- package/dist-esm/lib/constants.mjs.map +2 -2
- package/dist-esm/lib/helpers.mjs +3 -11
- package/dist-esm/lib/helpers.mjs.map +2 -2
- package/dist-esm/lib/localStorageAtom.mjs +7 -2
- package/dist-esm/lib/localStorageAtom.mjs.map +2 -2
- package/dist-esm/lib/transactions.mjs +11 -19
- package/dist-esm/lib/transactions.mjs.map +2 -2
- package/dist-esm/lib/types.mjs.map +1 -1
- package/dist-esm/lib/warnings.mjs +2 -4
- package/dist-esm/lib/warnings.mjs.map +2 -2
- package/package.json +2 -2
- package/src/lib/ArraySet.ts +68 -176
- package/src/lib/Atom.ts +27 -31
- package/src/lib/Computed.ts +68 -96
- package/src/lib/EffectScheduler.ts +9 -8
- package/src/lib/HistoryBuffer.ts +12 -10
- package/src/lib/__tests__/ArraySet.test.ts +39 -13
- package/src/lib/__tests__/EffectScheduler.test.ts +18 -0
- package/src/lib/__tests__/HistoryBuffer.test.ts +6 -3
- package/src/lib/__tests__/computed.test.ts +75 -0
- package/src/lib/__tests__/errors.test.ts +24 -0
- package/src/lib/__tests__/helpers.test.ts +7 -11
- package/src/lib/__tests__/history.test.ts +32 -2
- package/src/lib/__tests__/localStorageAtom.test.ts +15 -0
- package/src/lib/capture.ts +13 -13
- package/src/lib/constants.ts +3 -22
- package/src/lib/helpers.ts +15 -140
- package/src/lib/localStorageAtom.ts +9 -2
- package/src/lib/transactions.ts +23 -47
- package/src/lib/types.ts +7 -7
- package/src/lib/warnings.ts +2 -10
package/src/lib/Computed.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
303
|
-
diff
|
|
304
|
-
|
|
305
|
-
|
|
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 =
|
|
314
|
+
this.lastChangedEpoch = epoch
|
|
309
315
|
this.state = newState
|
|
310
316
|
}
|
|
311
317
|
this.error = null
|
|
312
|
-
this.lastCheckedEpoch =
|
|
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
|
-
|
|
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 =
|
|
331
|
+
this.lastChangedEpoch = epoch
|
|
320
332
|
}
|
|
321
|
-
this.lastCheckedEpoch =
|
|
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
|
-
|
|
374
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
451
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
429
|
+
descriptor.get = makeComputedWrapper(key, descriptor.get, options)
|
|
468
430
|
} else {
|
|
469
|
-
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
99
|
-
if
|
|
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
|
|
package/src/lib/HistoryBuffer.ts
CHANGED
|
@@ -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
|
|
58
|
-
*
|
|
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
|
-
|
|
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
|
|
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]
|
|
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
|
-
|
|
56
|
-
expect(buf.getChangesSince(
|
|
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,
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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(
|
|
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(
|
|
146
|
+
expect(baseAtom.children.isEmpty).toBe(false)
|
|
151
147
|
|
|
152
148
|
reactorInstance.stop()
|
|
153
|
-
expect(
|
|
149
|
+
expect(baseAtom.children.isEmpty).toBe(true)
|
|
154
150
|
})
|
|
155
151
|
})
|
|
156
152
|
})
|