kopia lustrzana https://github.com/Tldraw/Tldraw
611 wiersze
14 KiB
TypeScript
611 wiersze
14 KiB
TypeScript
import { atom } from '../Atom'
|
|
import { Computed, _Computed, computed, getComputedInstance, isUninitialized } from '../Computed'
|
|
import { reactor } from '../EffectScheduler'
|
|
import { assertNever } from '../helpers'
|
|
import { advanceGlobalEpoch, globalEpoch, transact, transaction } from '../transactions'
|
|
import { RESET_VALUE, Signal } from '../types'
|
|
|
|
function getLastCheckedEpoch(derivation: Computed<any>): number {
|
|
return (derivation as any).lastCheckedEpoch
|
|
}
|
|
|
|
describe('derivations', () => {
|
|
it('will cache a value forever if it has no parents', () => {
|
|
const derive = jest.fn(() => 1)
|
|
const startEpoch = globalEpoch
|
|
const derivation = computed('', derive)
|
|
|
|
expect(derive).toHaveBeenCalledTimes(0)
|
|
|
|
expect(derivation.value).toBe(1)
|
|
expect(derivation.value).toBe(1)
|
|
expect(derivation.value).toBe(1)
|
|
|
|
expect(derive).toHaveBeenCalledTimes(1)
|
|
|
|
advanceGlobalEpoch()
|
|
advanceGlobalEpoch()
|
|
advanceGlobalEpoch()
|
|
advanceGlobalEpoch()
|
|
|
|
expect(derivation.value).toBe(1)
|
|
expect(derivation.value).toBe(1)
|
|
expect(derivation.value).toBe(1)
|
|
advanceGlobalEpoch()
|
|
advanceGlobalEpoch()
|
|
expect(derivation.value).toBe(1)
|
|
expect(derivation.value).toBe(1)
|
|
|
|
expect(derive).toHaveBeenCalledTimes(1)
|
|
|
|
expect(derivation.parents.length).toBe(0)
|
|
|
|
expect(derivation.lastChangedEpoch).toBe(startEpoch)
|
|
})
|
|
|
|
it('will update when parent atoms update', () => {
|
|
const a = atom('', 1)
|
|
const double = jest.fn(() => a.value * 2)
|
|
const derivation = computed('', double)
|
|
const startEpoch = globalEpoch
|
|
expect(double).toHaveBeenCalledTimes(0)
|
|
|
|
expect(derivation.value).toBe(2)
|
|
expect(double).toHaveBeenCalledTimes(1)
|
|
|
|
expect(derivation.lastChangedEpoch).toBe(startEpoch)
|
|
|
|
expect(derivation.value).toBe(2)
|
|
expect(derivation.value).toBe(2)
|
|
expect(double).toHaveBeenCalledTimes(1)
|
|
expect(derivation.lastChangedEpoch).toBe(startEpoch)
|
|
|
|
a.set(2)
|
|
const nextEpoch = globalEpoch
|
|
expect(nextEpoch > startEpoch).toBe(true)
|
|
|
|
expect(double).toHaveBeenCalledTimes(1)
|
|
expect(derivation.lastChangedEpoch).toBe(startEpoch)
|
|
expect(derivation.value).toBe(4)
|
|
|
|
expect(double).toHaveBeenCalledTimes(2)
|
|
expect(derivation.lastChangedEpoch).toBe(nextEpoch)
|
|
|
|
expect(derivation.value).toBe(4)
|
|
expect(double).toHaveBeenCalledTimes(2)
|
|
expect(derivation.lastChangedEpoch).toBe(nextEpoch)
|
|
|
|
// creating an unrelated atom and setting it will have no effect
|
|
const unrelatedAtom = atom('', 1)
|
|
unrelatedAtom.set(2)
|
|
unrelatedAtom.set(3)
|
|
unrelatedAtom.set(5)
|
|
|
|
expect(derivation.value).toBe(4)
|
|
expect(double).toHaveBeenCalledTimes(2)
|
|
expect(derivation.lastChangedEpoch).toBe(nextEpoch)
|
|
})
|
|
|
|
it('supports history', () => {
|
|
const startEpoch = globalEpoch
|
|
const a = atom('', 1)
|
|
|
|
const derivation = computed('', () => a.value * 2, {
|
|
historyLength: 3,
|
|
computeDiff: (a, b) => {
|
|
return b - a
|
|
},
|
|
})
|
|
|
|
derivation.value
|
|
|
|
expect(derivation.getDiffSince(startEpoch)).toHaveLength(0)
|
|
|
|
a.set(2)
|
|
|
|
expect(derivation.getDiffSince(startEpoch)).toEqual([+2])
|
|
|
|
a.set(3)
|
|
|
|
expect(derivation.getDiffSince(startEpoch)).toEqual([+2, +2])
|
|
|
|
a.set(5)
|
|
|
|
expect(derivation.getDiffSince(startEpoch)).toEqual([+2, +2, +4])
|
|
|
|
a.set(6)
|
|
// should fail now because we don't have enough hisstory
|
|
expect(derivation.getDiffSince(startEpoch)).toEqual(RESET_VALUE)
|
|
})
|
|
|
|
it('doesnt update history if it doesnt change', () => {
|
|
const startEpoch = globalEpoch
|
|
const a = atom('', 1)
|
|
|
|
const floor = jest.fn((n: number) => Math.floor(n))
|
|
const derivation = computed('', () => floor(a.value), {
|
|
historyLength: 3,
|
|
computeDiff: (a, b) => {
|
|
return b - a
|
|
},
|
|
})
|
|
|
|
expect(derivation.value).toBe(1)
|
|
expect(derivation.getDiffSince(startEpoch)).toHaveLength(0)
|
|
|
|
a.set(1.2)
|
|
|
|
expect(derivation.value).toBe(1)
|
|
expect(derivation.getDiffSince(startEpoch)).toHaveLength(0)
|
|
expect(floor).toHaveBeenCalledTimes(2)
|
|
|
|
a.set(1.5)
|
|
|
|
expect(derivation.value).toBe(1)
|
|
expect(derivation.getDiffSince(startEpoch)).toHaveLength(0)
|
|
expect(floor).toHaveBeenCalledTimes(3)
|
|
|
|
a.set(1.9)
|
|
|
|
expect(derivation.value).toBe(1)
|
|
expect(derivation.getDiffSince(startEpoch)).toHaveLength(0)
|
|
expect(floor).toHaveBeenCalledTimes(4)
|
|
|
|
a.set(2.3)
|
|
|
|
expect(derivation.value).toBe(2)
|
|
expect(derivation.getDiffSince(startEpoch)).toEqual([+1])
|
|
expect(floor).toHaveBeenCalledTimes(5)
|
|
})
|
|
|
|
it('updates the lastCheckedEpoch whenever the globalEpoch advances', () => {
|
|
const startEpoch = globalEpoch
|
|
const a = atom('', 1)
|
|
|
|
const double = jest.fn(() => a.value * 2)
|
|
const derivation = computed('', double)
|
|
|
|
derivation.value
|
|
|
|
expect(getLastCheckedEpoch(derivation)).toEqual(startEpoch)
|
|
|
|
advanceGlobalEpoch()
|
|
derivation.value
|
|
|
|
expect(getLastCheckedEpoch(derivation)).toBeGreaterThan(startEpoch)
|
|
|
|
expect(double).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('receives UNINTIALIZED as the previousValue the first time it computes', () => {
|
|
const a = atom('', 1)
|
|
const double = jest.fn((_prevValue) => a.value * 2)
|
|
const derivation = computed('', double)
|
|
|
|
expect(derivation.value).toBe(2)
|
|
|
|
expect(isUninitialized(double.mock.calls[0][0])).toBe(true)
|
|
|
|
a.set(2)
|
|
|
|
expect(derivation.value).toBe(4)
|
|
expect(isUninitialized(double.mock.calls[1][0])).toBe(false)
|
|
expect(double.mock.calls[1][0]).toBe(2)
|
|
})
|
|
|
|
it('receives the lastChangedEpoch as the second parameter each time it recomputes', () => {
|
|
const a = atom('', 1)
|
|
const double = jest.fn((_prevValue, lastChangedEpoch) => {
|
|
expect(lastChangedEpoch).toBe(derivation.lastChangedEpoch)
|
|
return a.value * 2
|
|
})
|
|
const derivation = computed('', double)
|
|
|
|
expect(derivation.value).toBe(2)
|
|
|
|
const startEpoch = globalEpoch
|
|
|
|
a.set(2)
|
|
|
|
expect(derivation.value).toBe(4)
|
|
expect(derivation.lastChangedEpoch).toBeGreaterThan(startEpoch)
|
|
|
|
expect(double).toHaveBeenCalledTimes(2)
|
|
expect.assertions(6)
|
|
})
|
|
|
|
it('can be reacted to', () => {
|
|
const firstName = atom('', 'John')
|
|
const lastName = atom('', 'Doe')
|
|
|
|
let numTimesComputed = 0
|
|
const fullName = computed('', () => {
|
|
numTimesComputed++
|
|
return `${firstName.value} ${lastName.value}`
|
|
})
|
|
|
|
let numTimesReacted = 0
|
|
let name = ''
|
|
const r = reactor('', () => {
|
|
name = fullName.value
|
|
numTimesReacted++
|
|
})
|
|
|
|
expect(numTimesReacted).toBe(0)
|
|
expect(name).toBe('')
|
|
|
|
r.start()
|
|
|
|
expect(numTimesReacted).toBe(1)
|
|
expect(numTimesComputed).toBe(1)
|
|
expect(name).toBe('John Doe')
|
|
|
|
firstName.set('Jane')
|
|
|
|
expect(numTimesComputed).toBe(2)
|
|
expect(numTimesReacted).toBe(2)
|
|
expect(name).toBe('Jane Doe')
|
|
|
|
firstName.set('Jane')
|
|
firstName.set('Jane')
|
|
firstName.set('Jane')
|
|
|
|
expect(numTimesComputed).toBe(2)
|
|
expect(numTimesReacted).toBe(2)
|
|
expect(name).toBe('Jane Doe')
|
|
|
|
transact(() => {
|
|
firstName.set('Wilbur')
|
|
expect(numTimesComputed).toBe(2)
|
|
expect(numTimesReacted).toBe(2)
|
|
expect(name).toBe('Jane Doe')
|
|
lastName.set('Jones')
|
|
expect(numTimesComputed).toBe(2)
|
|
expect(numTimesReacted).toBe(2)
|
|
expect(name).toBe('Jane Doe')
|
|
expect(fullName.value).toBe('Wilbur Jones')
|
|
|
|
expect(numTimesComputed).toBe(3)
|
|
expect(numTimesReacted).toBe(2)
|
|
expect(name).toBe('Jane Doe')
|
|
})
|
|
|
|
expect(numTimesComputed).toBe(3)
|
|
expect(numTimesReacted).toBe(3)
|
|
expect(name).toBe('Wilbur Jones')
|
|
})
|
|
|
|
it('will roll back to their initial value if a transaciton is aborted', () => {
|
|
const firstName = atom('', 'John')
|
|
const lastName = atom('', 'Doe')
|
|
|
|
const fullName = computed('', () => `${firstName.value} ${lastName.value}`)
|
|
|
|
transaction((rollback) => {
|
|
firstName.set('Jane')
|
|
lastName.set('Jones')
|
|
expect(fullName.value).toBe('Jane Jones')
|
|
rollback()
|
|
})
|
|
|
|
expect(fullName.value).toBe('John Doe')
|
|
})
|
|
|
|
it('will add history items if a transaction is aborted', () => {
|
|
const a = atom('', 1)
|
|
const b = atom('', 1)
|
|
|
|
const c = computed('', () => a.value + b.value, {
|
|
historyLength: 3,
|
|
computeDiff: (a, b) => b - a,
|
|
})
|
|
|
|
const startEpoch = globalEpoch
|
|
|
|
transaction((rollback) => {
|
|
expect(c.getDiffSince(startEpoch)).toEqual([])
|
|
a.set(2)
|
|
b.set(2)
|
|
expect(c.getDiffSince(startEpoch)).toEqual([+2])
|
|
rollback()
|
|
})
|
|
|
|
expect(c.getDiffSince(startEpoch)).toEqual([2, -2])
|
|
})
|
|
|
|
it('will return RESET_VALUE if .getDiffSince is called with an epoch before initialization', () => {
|
|
const a = atom('', 1)
|
|
const b = atom('', 1)
|
|
|
|
const c = computed('', () => a.value + b.value, {
|
|
historyLength: 3,
|
|
computeDiff: (a, b) => b - a,
|
|
})
|
|
|
|
expect(c.getDiffSince(globalEpoch - 1)).toEqual(RESET_VALUE)
|
|
})
|
|
})
|
|
|
|
type Difference =
|
|
| {
|
|
type: 'CHANGE'
|
|
path: string[]
|
|
value: any
|
|
oldValue: any
|
|
}
|
|
| { type: 'CREATE'; path: string[]; value: any }
|
|
| { type: 'REMOVE'; path: string[]; oldValue: any }
|
|
|
|
function getIncrementalRecordMapper<In, Out>(
|
|
obj: Signal<Record<string, In>, Difference[]>,
|
|
mapper: (t: In, k: string) => Out
|
|
): Computed<Record<string, Out>> {
|
|
function computeFromScratch() {
|
|
const input = obj.value
|
|
return Object.fromEntries(Object.entries(input).map(([k, v]) => [k, mapper(v, k)]))
|
|
}
|
|
return computed('', (previousValue, lastComputedEpoch) => {
|
|
if (isUninitialized(previousValue)) {
|
|
return computeFromScratch()
|
|
}
|
|
const diff = obj.getDiffSince(lastComputedEpoch)
|
|
if (diff === RESET_VALUE) {
|
|
return computeFromScratch()
|
|
}
|
|
if (diff.length === 0) {
|
|
return previousValue
|
|
}
|
|
|
|
const newUpstream = obj.value
|
|
|
|
const result = { ...previousValue } as Record<string, Out>
|
|
|
|
const changedKeys = new Set<string>()
|
|
for (const change of diff.flat()) {
|
|
const key = change.path[0] as string
|
|
if (changedKeys.has(key)) {
|
|
continue
|
|
}
|
|
switch (change.type) {
|
|
case 'CHANGE':
|
|
case 'CREATE':
|
|
changedKeys.add(key)
|
|
if (key in newUpstream) {
|
|
result[key] = mapper(newUpstream[key], change.path[0] as string)
|
|
} else {
|
|
// key was removed later in this patch
|
|
}
|
|
break
|
|
case 'REMOVE':
|
|
if (key in result) {
|
|
delete result[key]
|
|
}
|
|
break
|
|
default:
|
|
assertNever(change)
|
|
}
|
|
}
|
|
|
|
return result
|
|
})
|
|
}
|
|
|
|
describe('incremental derivations', () => {
|
|
it('should be possible', () => {
|
|
type NumberMap = Record<string, number>
|
|
|
|
const nodes = atom<NumberMap, Difference[]>(
|
|
'',
|
|
{
|
|
a: 1,
|
|
b: 2,
|
|
c: 3,
|
|
d: 4,
|
|
e: 5,
|
|
},
|
|
{
|
|
historyLength: 10,
|
|
computeDiff: (valA, valB) => {
|
|
const result: Difference[] = []
|
|
for (const keyA in valA) {
|
|
if (!(keyA in valB)) {
|
|
result.push({
|
|
type: 'REMOVE',
|
|
oldValue: valA[keyA],
|
|
path: [keyA],
|
|
})
|
|
} else if (valA[keyA] != valB[keyA]) {
|
|
result.push({
|
|
type: 'CHANGE',
|
|
oldValue: valA[keyA],
|
|
path: [keyA],
|
|
value: valB[keyA],
|
|
})
|
|
}
|
|
}
|
|
|
|
for (const keyB in valB) {
|
|
if (!(keyB in valA)) {
|
|
result.push({
|
|
type: 'CREATE',
|
|
value: valB[keyB],
|
|
path: [keyB],
|
|
})
|
|
}
|
|
}
|
|
return result
|
|
},
|
|
}
|
|
)
|
|
|
|
const mapper = jest.fn((val) => val * 2)
|
|
|
|
const doubledNodes = getIncrementalRecordMapper(nodes, mapper)
|
|
|
|
expect(doubledNodes.value).toEqual({
|
|
a: 2,
|
|
b: 4,
|
|
c: 6,
|
|
d: 8,
|
|
e: 10,
|
|
})
|
|
expect(mapper).toHaveBeenCalledTimes(5)
|
|
|
|
nodes.update((ns) => ({ ...ns, a: 10 }))
|
|
|
|
expect(doubledNodes.value).toEqual({
|
|
a: 20,
|
|
b: 4,
|
|
c: 6,
|
|
d: 8,
|
|
e: 10,
|
|
})
|
|
|
|
expect(mapper).toHaveBeenCalledTimes(6)
|
|
|
|
// remove d
|
|
nodes.update(({ d: _d, ...others }) => others)
|
|
|
|
expect(doubledNodes.value).toEqual({
|
|
a: 20,
|
|
b: 4,
|
|
c: 6,
|
|
e: 10,
|
|
})
|
|
expect(mapper).toHaveBeenCalledTimes(6)
|
|
|
|
nodes.update((ns) => ({ ...ns, f: 50, g: 60 }))
|
|
|
|
expect(doubledNodes.value).toEqual({
|
|
a: 20,
|
|
b: 4,
|
|
c: 6,
|
|
e: 10,
|
|
f: 100,
|
|
g: 120,
|
|
})
|
|
expect(mapper).toHaveBeenCalledTimes(8)
|
|
|
|
nodes.set({ ...nodes.value })
|
|
// no changes so no new calls to mapper
|
|
expect(doubledNodes.value).toEqual({
|
|
a: 20,
|
|
b: 4,
|
|
c: 6,
|
|
e: 10,
|
|
f: 100,
|
|
g: 120,
|
|
})
|
|
expect(mapper).toHaveBeenCalledTimes(8)
|
|
|
|
// make several changes
|
|
|
|
nodes.update((ns) => ({ ...ns, a: 1 }))
|
|
nodes.update((ns) => ({ ...ns, b: 9 }))
|
|
nodes.update((ns) => ({ ...ns, c: 17 }))
|
|
nodes.update(({ f: _f, g: _g, ...others }) => ({ ...others }))
|
|
nodes.update((ns) => ({ ...ns, d: 4 }))
|
|
nodes.update((ns) => ({ ...ns, a: 4 }))
|
|
|
|
// nothing was called because we didn't deref yet
|
|
expect(mapper).toHaveBeenCalledTimes(8)
|
|
|
|
expect(doubledNodes.value).toEqual({
|
|
a: 8,
|
|
b: 18,
|
|
c: 34,
|
|
d: 8,
|
|
e: 10,
|
|
})
|
|
|
|
expect(mapper).toHaveBeenCalledTimes(12)
|
|
})
|
|
})
|
|
|
|
describe('computed as a decorator', () => {
|
|
it('can be used to decorate a class', () => {
|
|
class Foo {
|
|
a = atom('a', 1)
|
|
@computed
|
|
get b() {
|
|
return this.a.value * 2
|
|
}
|
|
}
|
|
|
|
const foo = new Foo()
|
|
|
|
expect(foo.b).toBe(2)
|
|
|
|
foo.a.set(2)
|
|
|
|
expect(foo.b).toBe(4)
|
|
})
|
|
|
|
it('can be used to decorate a class with custom properties', () => {
|
|
let numComputations = 0
|
|
class Foo {
|
|
a = atom('a', 1)
|
|
|
|
@computed({ isEqual: (a, b) => a.b === b.b })
|
|
get b() {
|
|
numComputations++
|
|
return { b: this.a.value * this.a.value }
|
|
}
|
|
}
|
|
|
|
const foo = new Foo()
|
|
|
|
const firstVal = foo.b
|
|
expect(firstVal).toEqual({ b: 1 })
|
|
|
|
foo.a.set(-1)
|
|
|
|
const secondVal = foo.b
|
|
expect(secondVal).toEqual({ b: 1 })
|
|
|
|
expect(firstVal).toBe(secondVal)
|
|
expect(numComputations).toBe(2)
|
|
})
|
|
})
|
|
|
|
describe(getComputedInstance, () => {
|
|
it('can retrieve the underlying computed instance', () => {
|
|
class Foo {
|
|
a = atom('a', 1)
|
|
|
|
@computed({ isEqual: (a, b) => a.b === b.b })
|
|
get b() {
|
|
return { b: this.a.value * this.a.value }
|
|
}
|
|
}
|
|
|
|
const foo = new Foo()
|
|
|
|
const bInst = getComputedInstance(foo, 'b')
|
|
|
|
expect(bInst).toBeDefined()
|
|
expect(bInst).toBeInstanceOf(_Computed)
|
|
})
|
|
})
|
|
|
|
describe('computed isEqual', () => {
|
|
it('does not get called for the initialization', () => {
|
|
const isEqual = jest.fn((a, b) => a === b)
|
|
|
|
const a = atom('a', 1)
|
|
const b = computed('b', () => a.value * 2, { isEqual })
|
|
|
|
expect(b.value).toBe(2)
|
|
expect(isEqual).not.toHaveBeenCalled()
|
|
expect(b.value).toBe(2)
|
|
expect(isEqual).not.toHaveBeenCalled()
|
|
|
|
a.set(2)
|
|
|
|
expect(b.value).toBe(4)
|
|
expect(isEqual).toHaveBeenCalledTimes(1)
|
|
expect(b.value).toBe(4)
|
|
expect(isEqual).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|