Unify Derivation and Prism 13/13
This commit is contained in:
parent
d9644f2370
commit
d2876a7c9a
12 changed files with 24 additions and 24 deletions
68
packages/dataverse/src/prism/Interface.ts
Normal file
68
packages/dataverse/src/prism/Interface.ts
Normal file
|
@ -0,0 +1,68 @@
|
|||
import type Ticker from '../Ticker'
|
||||
import type {$IntentionalAny, VoidFn} from '../types'
|
||||
|
||||
type IDependent = (msgComingFrom: Prism<$IntentionalAny>) => void
|
||||
|
||||
/**
|
||||
* Common interface for prisms.
|
||||
*/
|
||||
export interface Prism<V> {
|
||||
/**
|
||||
* Whether the object is a prism.
|
||||
*/
|
||||
isPrism: true
|
||||
|
||||
/**
|
||||
* Whether the prism is hot.
|
||||
*/
|
||||
isHot: boolean
|
||||
|
||||
/**
|
||||
* Calls `listener` with a fresh value every time the prism _has_ a new value, throttled by Ticker.
|
||||
*/
|
||||
onChange(
|
||||
ticker: Ticker,
|
||||
listener: (v: V) => void,
|
||||
immediate?: boolean,
|
||||
): VoidFn
|
||||
|
||||
onStale(cb: () => void): VoidFn
|
||||
|
||||
/**
|
||||
* Keep the prism hot, even if there are no tappers (subscribers).
|
||||
*/
|
||||
keepHot(): VoidFn
|
||||
|
||||
/**
|
||||
* Add a prism as a dependent of this prism.
|
||||
*
|
||||
* @param d - The prism to be made a dependent of this prism.
|
||||
*
|
||||
* @see _removeDependent
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
_addDependent(d: IDependent): void
|
||||
|
||||
/**
|
||||
* Remove a prism as a dependent of this prism.
|
||||
*
|
||||
* @param d - The prism to be removed from as a dependent of this prism.
|
||||
*
|
||||
* @see _addDependent
|
||||
* @internal
|
||||
*/
|
||||
_removeDependent(d: IDependent): void
|
||||
|
||||
/**
|
||||
* Gets the current value of the prism. If the value is stale, it causes the prism to freshen.
|
||||
*/
|
||||
getValue(): V
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether `d` is a prism.
|
||||
*/
|
||||
export function isPrism(d: any): d is Prism<unknown> {
|
||||
return d && d.isPrism && d.isPrism === true
|
||||
}
|
99
packages/dataverse/src/prism/discoveryMechanism.ts
Normal file
99
packages/dataverse/src/prism/discoveryMechanism.ts
Normal file
|
@ -0,0 +1,99 @@
|
|||
import type {$IntentionalAny} from '../types'
|
||||
import Stack from '../utils/Stack'
|
||||
import type {Prism} from './Interface'
|
||||
|
||||
function createMechanism() {
|
||||
const noop = () => {}
|
||||
|
||||
const stack = new Stack<Collector>()
|
||||
const noopCollector: Collector = noop
|
||||
|
||||
type Collector = (d: Prism<$IntentionalAny>) => void
|
||||
|
||||
const pushCollector = (collector: Collector): void => {
|
||||
stack.push(collector)
|
||||
}
|
||||
|
||||
const popCollector = (collector: Collector): void => {
|
||||
const existing = stack.peek()
|
||||
if (existing !== collector) {
|
||||
throw new Error(`Popped collector is not on top of the stack`)
|
||||
}
|
||||
stack.pop()
|
||||
}
|
||||
|
||||
const startIgnoringDependencies = () => {
|
||||
stack.push(noopCollector)
|
||||
}
|
||||
|
||||
const stopIgnoringDependencies = () => {
|
||||
if (stack.peek() !== noopCollector) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('This should never happen')
|
||||
}
|
||||
} else {
|
||||
stack.pop()
|
||||
}
|
||||
}
|
||||
|
||||
const reportResolutionStart = (d: Prism<$IntentionalAny>) => {
|
||||
const possibleCollector = stack.peek()
|
||||
if (possibleCollector) {
|
||||
possibleCollector(d)
|
||||
}
|
||||
|
||||
stack.push(noopCollector)
|
||||
}
|
||||
|
||||
const reportResolutionEnd = (_d: Prism<$IntentionalAny>) => {
|
||||
stack.pop()
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'Dataverse_discoveryMechanism' as 'Dataverse_discoveryMechanism',
|
||||
startIgnoringDependencies,
|
||||
stopIgnoringDependencies,
|
||||
reportResolutionStart,
|
||||
reportResolutionEnd,
|
||||
pushCollector,
|
||||
popCollector,
|
||||
}
|
||||
}
|
||||
|
||||
function getSharedMechanism(): ReturnType<typeof createMechanism> {
|
||||
const varName = '__dataverse_discoveryMechanism_sharedStack'
|
||||
const root =
|
||||
typeof window !== 'undefined'
|
||||
? window
|
||||
: typeof global !== 'undefined'
|
||||
? global
|
||||
: {}
|
||||
if (root) {
|
||||
const existingMechanism: ReturnType<typeof createMechanism> | undefined =
|
||||
// @ts-ignore ignore
|
||||
root[varName]
|
||||
if (
|
||||
existingMechanism &&
|
||||
typeof existingMechanism === 'object' &&
|
||||
existingMechanism.type === 'Dataverse_discoveryMechanism'
|
||||
) {
|
||||
return existingMechanism
|
||||
} else {
|
||||
const mechanism = createMechanism()
|
||||
// @ts-ignore ignore
|
||||
root[varName] = mechanism
|
||||
return mechanism
|
||||
}
|
||||
} else {
|
||||
return createMechanism()
|
||||
}
|
||||
}
|
||||
|
||||
export const {
|
||||
startIgnoringDependencies,
|
||||
stopIgnoringDependencies,
|
||||
reportResolutionEnd,
|
||||
reportResolutionStart,
|
||||
pushCollector,
|
||||
popCollector,
|
||||
} = getSharedMechanism()
|
33
packages/dataverse/src/prism/iterateAndCountTicks.ts
Normal file
33
packages/dataverse/src/prism/iterateAndCountTicks.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import {pointerToPrism} from '../Atom'
|
||||
import type {Pointer} from '../pointer'
|
||||
import {isPointer} from '../pointer'
|
||||
import type {Prism} from './Interface'
|
||||
import {isPrism} from './Interface'
|
||||
|
||||
export default function* iterateAndCountTicks<V>(
|
||||
pointerOrPrism: Prism<V> | Pointer<V>,
|
||||
): Generator<{value: V; ticks: number}, void, void> {
|
||||
let d
|
||||
if (isPointer(pointerOrPrism)) {
|
||||
d = pointerToPrism(pointerOrPrism) as Prism<V>
|
||||
} else if (isPrism(pointerOrPrism)) {
|
||||
d = pointerOrPrism
|
||||
} else {
|
||||
throw new Error(`Only pointers and prisms are supported`)
|
||||
}
|
||||
|
||||
let ticksCountedSinceLastYield = 0
|
||||
const untap = d.onStale(() => {
|
||||
ticksCountedSinceLastYield++
|
||||
})
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const ticks = ticksCountedSinceLastYield
|
||||
ticksCountedSinceLastYield = 0
|
||||
yield {value: d.getValue(), ticks}
|
||||
}
|
||||
} finally {
|
||||
untap()
|
||||
}
|
||||
}
|
22
packages/dataverse/src/prism/iterateOver.test.ts
Normal file
22
packages/dataverse/src/prism/iterateOver.test.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
import Atom from '../Atom'
|
||||
import iterateOver from './iterateOver'
|
||||
|
||||
describe(`iterateOver()`, () => {
|
||||
test('it should work', () => {
|
||||
const a = new Atom({a: 0})
|
||||
let iter = iterateOver(a.pointer.a)
|
||||
expect(iter.next().value).toEqual(0)
|
||||
a.setIn(['a'], 1)
|
||||
a.setIn(['a'], 2)
|
||||
expect(iter.next()).toMatchObject({value: 2, done: false})
|
||||
iter.return()
|
||||
iter = iterateOver(a.pointer.a)
|
||||
expect(iter.next().value).toEqual(2)
|
||||
a.setIn(['a'], 3)
|
||||
expect(iter.next()).toMatchObject({done: false, value: 3})
|
||||
iter.return()
|
||||
})
|
||||
})
|
33
packages/dataverse/src/prism/iterateOver.ts
Normal file
33
packages/dataverse/src/prism/iterateOver.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import {pointerToPrism} from '../Atom'
|
||||
import type {Pointer} from '../pointer'
|
||||
import {isPointer} from '../pointer'
|
||||
import Ticker from '../Ticker'
|
||||
import type {Prism} from './Interface'
|
||||
import {isPrism} from './Interface'
|
||||
|
||||
export default function* iterateOver<V>(
|
||||
pointerOrPrism: Prism<V> | Pointer<V>,
|
||||
): Generator<V, void, void> {
|
||||
let d
|
||||
if (isPointer(pointerOrPrism)) {
|
||||
d = pointerToPrism(pointerOrPrism) as Prism<V>
|
||||
} else if (isPrism(pointerOrPrism)) {
|
||||
d = pointerOrPrism
|
||||
} else {
|
||||
throw new Error(`Only pointers and prisms are supported`)
|
||||
}
|
||||
|
||||
const ticker = new Ticker()
|
||||
|
||||
const untap = d.onChange(ticker, (v) => {})
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
ticker.tick()
|
||||
|
||||
yield d.getValue()
|
||||
}
|
||||
} finally {
|
||||
untap()
|
||||
}
|
||||
}
|
229
packages/dataverse/src/prism/prism.test.ts
Normal file
229
packages/dataverse/src/prism/prism.test.ts
Normal file
|
@ -0,0 +1,229 @@
|
|||
/*
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
import Atom, {val} from '../Atom'
|
||||
import Ticker from '../Ticker'
|
||||
import type {$FixMe, $IntentionalAny} from '../types'
|
||||
import iterateAndCountTicks from './iterateAndCountTicks'
|
||||
import prism from './prism'
|
||||
|
||||
describe('prism', () => {
|
||||
let ticker: Ticker
|
||||
beforeEach(() => {
|
||||
ticker = new Ticker()
|
||||
})
|
||||
|
||||
it('should work', () => {
|
||||
const o = new Atom({foo: 'foo'})
|
||||
const d = prism(() => {
|
||||
return val(o.pointer.foo) + 'boo'
|
||||
})
|
||||
expect(d.getValue()).toEqual('fooboo')
|
||||
|
||||
const changes: Array<$FixMe> = []
|
||||
d.onChange(ticker, (c) => {
|
||||
changes.push(c)
|
||||
})
|
||||
|
||||
o.reduceState(['foo'], () => 'foo2')
|
||||
ticker.tick()
|
||||
expect(changes).toMatchObject(['foo2boo'])
|
||||
})
|
||||
it('should only collect immediate dependencies', () => {
|
||||
const aD = prism(() => 1)
|
||||
const bD = prism(() => aD.getValue() * 2)
|
||||
const cD = prism(() => {
|
||||
return bD.getValue()
|
||||
})
|
||||
expect(cD.getValue()).toEqual(2)
|
||||
const untap = cD.keepHot()
|
||||
expect((cD as $IntentionalAny)._state.handle._dependencies.size).toEqual(1)
|
||||
untap()
|
||||
})
|
||||
|
||||
describe('prism.ref()', () => {
|
||||
it('should work', () => {
|
||||
const theAtom: Atom<{n: number}> = new Atom({n: 2})
|
||||
|
||||
const isEvenD = prism((): {isEven: boolean} => {
|
||||
const ref = prism.ref<{isEven: boolean} | undefined>('cache', undefined)
|
||||
const currentN = val(theAtom.pointer.n)
|
||||
|
||||
const isEven = currentN % 2 === 0
|
||||
if (ref.current && ref.current.isEven === isEven) {
|
||||
return ref.current
|
||||
} else {
|
||||
ref.current = {isEven}
|
||||
return ref.current
|
||||
}
|
||||
})
|
||||
|
||||
const iterator = iterateAndCountTicks(isEvenD)
|
||||
|
||||
theAtom.reduceState(['n'], () => 3)
|
||||
|
||||
expect(iterator.next().value).toMatchObject({
|
||||
value: {isEven: false},
|
||||
ticks: 0,
|
||||
})
|
||||
theAtom.reduceState(['n'], () => 5)
|
||||
theAtom.reduceState(['n'], () => 7)
|
||||
expect(iterator.next().value).toMatchObject({
|
||||
value: {isEven: false},
|
||||
ticks: 1,
|
||||
})
|
||||
theAtom.reduceState(['n'], () => 2)
|
||||
theAtom.reduceState(['n'], () => 4)
|
||||
expect(iterator.next().value).toMatchObject({
|
||||
value: {isEven: true},
|
||||
ticks: 1,
|
||||
})
|
||||
expect(iterator.next().value).toMatchObject({
|
||||
value: {isEven: true},
|
||||
ticks: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('prism.effect()', () => {
|
||||
it('should work', async () => {
|
||||
let iteration = 0
|
||||
const sequence: unknown[] = []
|
||||
let deps: unknown[] = []
|
||||
|
||||
const a = new Atom({letter: 'a'})
|
||||
|
||||
const prsm = prism(() => {
|
||||
const n = val(a.pointer.letter)
|
||||
const iterationAtTimeOfCall = iteration
|
||||
sequence.push({prismCall: iterationAtTimeOfCall})
|
||||
|
||||
prism.effect(
|
||||
'f',
|
||||
() => {
|
||||
sequence.push({effectCall: iterationAtTimeOfCall})
|
||||
return () => {
|
||||
sequence.push({cleanupCall: iterationAtTimeOfCall})
|
||||
}
|
||||
},
|
||||
[...deps],
|
||||
)
|
||||
|
||||
return n
|
||||
})
|
||||
|
||||
const untap = prsm.onChange(ticker, (change) => {
|
||||
sequence.push({change})
|
||||
})
|
||||
|
||||
expect(sequence).toMatchObject([{prismCall: 0}, {effectCall: 0}])
|
||||
sequence.length = 0
|
||||
|
||||
iteration++
|
||||
a.setIn(['letter'], 'b')
|
||||
ticker.tick()
|
||||
expect(sequence).toMatchObject([{prismCall: 1}, {change: 'b'}])
|
||||
sequence.length = 0
|
||||
|
||||
deps = [1]
|
||||
iteration++
|
||||
a.setIn(['letter'], 'c')
|
||||
ticker.tick()
|
||||
expect(sequence).toMatchObject([
|
||||
{prismCall: 2},
|
||||
{cleanupCall: 0},
|
||||
{effectCall: 2},
|
||||
{change: 'c'},
|
||||
])
|
||||
sequence.length = 0
|
||||
|
||||
untap()
|
||||
|
||||
// takes a tick before untap takes effect
|
||||
await new Promise((resolve) => setTimeout(resolve, 1))
|
||||
expect(sequence).toMatchObject([{cleanupCall: 2}])
|
||||
})
|
||||
})
|
||||
|
||||
describe('prism.memo()', () => {
|
||||
it('should work', async () => {
|
||||
let iteration = 0
|
||||
const sequence: unknown[] = []
|
||||
let deps: unknown[] = []
|
||||
|
||||
const a = new Atom({letter: 'a'})
|
||||
|
||||
const prsm = prism(() => {
|
||||
const n = val(a.pointer.letter)
|
||||
const iterationAtTimeOfCall = iteration
|
||||
sequence.push({prismCall: iterationAtTimeOfCall})
|
||||
|
||||
const resultOfMemo = prism.memo(
|
||||
'memo',
|
||||
() => {
|
||||
sequence.push({memoCall: iterationAtTimeOfCall})
|
||||
return iterationAtTimeOfCall
|
||||
},
|
||||
[...deps],
|
||||
)
|
||||
|
||||
sequence.push({resultOfMemo})
|
||||
|
||||
return n
|
||||
})
|
||||
|
||||
const untap = prsm.onChange(ticker, (change) => {
|
||||
sequence.push({change})
|
||||
})
|
||||
|
||||
expect(sequence).toMatchObject([
|
||||
{prismCall: 0},
|
||||
{memoCall: 0},
|
||||
{resultOfMemo: 0},
|
||||
])
|
||||
sequence.length = 0
|
||||
|
||||
iteration++
|
||||
a.setIn(['letter'], 'b')
|
||||
ticker.tick()
|
||||
expect(sequence).toMatchObject([
|
||||
{prismCall: 1},
|
||||
{resultOfMemo: 0},
|
||||
{change: 'b'},
|
||||
])
|
||||
sequence.length = 0
|
||||
|
||||
deps = [1]
|
||||
iteration++
|
||||
a.setIn(['letter'], 'c')
|
||||
ticker.tick()
|
||||
expect(sequence).toMatchObject([
|
||||
{prismCall: 2},
|
||||
{memoCall: 2},
|
||||
{resultOfMemo: 2},
|
||||
{change: 'c'},
|
||||
])
|
||||
sequence.length = 0
|
||||
|
||||
untap()
|
||||
})
|
||||
})
|
||||
|
||||
describe(`prism.scope()`, () => {
|
||||
it('should prevent name conflicts', () => {
|
||||
const d = prism(() => {
|
||||
const thisNameWillBeUsedForBothMemos = 'blah'
|
||||
const a = prism.scope('a', () => {
|
||||
return prism.memo(thisNameWillBeUsedForBothMemos, () => 'a', [])
|
||||
})
|
||||
|
||||
const b = prism.scope('b', () => {
|
||||
return prism.memo(thisNameWillBeUsedForBothMemos, () => 'b', [])
|
||||
})
|
||||
|
||||
return {a, b}
|
||||
})
|
||||
expect(d.getValue()).toMatchObject({a: 'a', b: 'b'})
|
||||
})
|
||||
})
|
||||
})
|
803
packages/dataverse/src/prism/prism.ts
Normal file
803
packages/dataverse/src/prism/prism.ts
Normal file
|
@ -0,0 +1,803 @@
|
|||
import type Ticker from '../Ticker'
|
||||
import type {$IntentionalAny, VoidFn} from '../types'
|
||||
import Stack from '../utils/Stack'
|
||||
import type {Prism} from './Interface'
|
||||
import {isPrism} from './Interface'
|
||||
import {
|
||||
startIgnoringDependencies,
|
||||
stopIgnoringDependencies,
|
||||
pushCollector,
|
||||
popCollector,
|
||||
reportResolutionStart,
|
||||
reportResolutionEnd,
|
||||
} from './discoveryMechanism'
|
||||
|
||||
type IDependent = (msgComingFrom: Prism<$IntentionalAny>) => void
|
||||
|
||||
const voidFn = () => {}
|
||||
|
||||
class HotHandle<V> {
|
||||
private _didMarkDependentsAsStale: boolean = false
|
||||
private _isFresh: boolean = false
|
||||
protected _cacheOfDendencyValues: Map<Prism<unknown>, unknown> = new Map()
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
protected _dependents: Set<IDependent> = new Set()
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
protected _dependencies: Set<Prism<$IntentionalAny>> = new Set()
|
||||
|
||||
protected _possiblyStaleDeps = new Set<Prism<unknown>>()
|
||||
|
||||
private _scope: HotScope = new HotScope(
|
||||
this as $IntentionalAny as HotHandle<unknown>,
|
||||
)
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
protected _lastValue: undefined | V = undefined
|
||||
|
||||
/**
|
||||
* If true, the prism is stale even though its dependencies aren't
|
||||
* marked as such. This is used by `prism.source()` and `prism.state()`
|
||||
* to mark the prism as stale.
|
||||
*/
|
||||
private _forciblySetToStale: boolean = false
|
||||
|
||||
constructor(
|
||||
private readonly _fn: () => V,
|
||||
private readonly _prismInstance: PrismInstance<V>,
|
||||
) {
|
||||
for (const d of this._dependencies) {
|
||||
d._addDependent(this._reactToDependencyGoingStale)
|
||||
}
|
||||
|
||||
startIgnoringDependencies()
|
||||
this.getValue()
|
||||
stopIgnoringDependencies()
|
||||
}
|
||||
|
||||
get hasDependents(): boolean {
|
||||
return this._dependents.size > 0
|
||||
}
|
||||
removeDependent(d: IDependent) {
|
||||
this._dependents.delete(d)
|
||||
}
|
||||
addDependent(d: IDependent) {
|
||||
this._dependents.add(d)
|
||||
}
|
||||
|
||||
destroy() {
|
||||
for (const d of this._dependencies) {
|
||||
d._removeDependent(this._reactToDependencyGoingStale)
|
||||
}
|
||||
cleanupScopeStack(this._scope)
|
||||
}
|
||||
|
||||
getValue(): V {
|
||||
if (!this._isFresh) {
|
||||
const newValue = this._recalculate()
|
||||
this._lastValue = newValue
|
||||
this._isFresh = true
|
||||
this._didMarkDependentsAsStale = false
|
||||
this._forciblySetToStale = false
|
||||
}
|
||||
return this._lastValue!
|
||||
}
|
||||
|
||||
_recalculate() {
|
||||
let value: V
|
||||
|
||||
if (!this._forciblySetToStale) {
|
||||
if (this._possiblyStaleDeps.size > 0) {
|
||||
let anActuallyStaleDepWasFound = false
|
||||
startIgnoringDependencies()
|
||||
for (const dep of this._possiblyStaleDeps) {
|
||||
if (this._cacheOfDendencyValues.get(dep) !== dep.getValue()) {
|
||||
anActuallyStaleDepWasFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
stopIgnoringDependencies()
|
||||
this._possiblyStaleDeps.clear()
|
||||
if (!anActuallyStaleDepWasFound) {
|
||||
return this._lastValue!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newDeps: Set<Prism<unknown>> = new Set()
|
||||
this._cacheOfDendencyValues.clear()
|
||||
|
||||
const collector = (observedDep: Prism<unknown>): void => {
|
||||
newDeps.add(observedDep)
|
||||
this._addDependency(observedDep)
|
||||
}
|
||||
|
||||
pushCollector(collector)
|
||||
|
||||
hookScopeStack.push(this._scope)
|
||||
try {
|
||||
value = this._fn()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
const topOfTheStack = hookScopeStack.pop()
|
||||
if (topOfTheStack !== this._scope) {
|
||||
console.warn(
|
||||
// @todo guide the user to report the bug in an issue
|
||||
`The Prism hook stack has slipped. This is a bug.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
popCollector(collector)
|
||||
|
||||
for (const dep of this._dependencies) {
|
||||
if (!newDeps.has(dep)) {
|
||||
this._removeDependency(dep)
|
||||
}
|
||||
}
|
||||
|
||||
this._dependencies = newDeps
|
||||
|
||||
startIgnoringDependencies()
|
||||
for (const dep of newDeps) {
|
||||
this._cacheOfDendencyValues.set(dep, dep.getValue())
|
||||
}
|
||||
stopIgnoringDependencies()
|
||||
|
||||
return value!
|
||||
}
|
||||
|
||||
forceStale() {
|
||||
this._forciblySetToStale = true
|
||||
this._markAsStale()
|
||||
}
|
||||
|
||||
protected _reactToDependencyGoingStale = (which: Prism<$IntentionalAny>) => {
|
||||
this._possiblyStaleDeps.add(which)
|
||||
|
||||
this._markAsStale()
|
||||
}
|
||||
|
||||
private _markAsStale() {
|
||||
if (this._didMarkDependentsAsStale) return
|
||||
|
||||
this._didMarkDependentsAsStale = true
|
||||
this._isFresh = false
|
||||
|
||||
for (const dependent of this._dependents) {
|
||||
dependent(this._prismInstance)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
protected _addDependency(d: Prism<$IntentionalAny>) {
|
||||
if (this._dependencies.has(d)) return
|
||||
this._dependencies.add(d)
|
||||
d._addDependent(this._reactToDependencyGoingStale)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
protected _removeDependency(d: Prism<$IntentionalAny>) {
|
||||
if (!this._dependencies.has(d)) return
|
||||
this._dependencies.delete(d)
|
||||
d._removeDependent(this._reactToDependencyGoingStale)
|
||||
}
|
||||
}
|
||||
|
||||
const emptyObject = {}
|
||||
|
||||
class PrismInstance<V> implements Prism<V> {
|
||||
/**
|
||||
* Whether the object is a prism.
|
||||
*/
|
||||
readonly isPrism: true = true
|
||||
|
||||
private _state:
|
||||
| {hot: false; handle: undefined}
|
||||
| {hot: true; handle: HotHandle<V>} = {
|
||||
hot: false,
|
||||
handle: undefined,
|
||||
}
|
||||
|
||||
constructor(private readonly _fn: () => V) {}
|
||||
|
||||
/**
|
||||
* Whether the prism is hot.
|
||||
*/
|
||||
get isHot(): boolean {
|
||||
return this._state.hot
|
||||
}
|
||||
|
||||
onChange(
|
||||
ticker: Ticker,
|
||||
listener: (v: V) => void,
|
||||
immediate: boolean = false,
|
||||
): VoidFn {
|
||||
const dependent = () => {
|
||||
ticker.onThisOrNextTick(refresh)
|
||||
}
|
||||
|
||||
let lastValue = emptyObject
|
||||
|
||||
const refresh = () => {
|
||||
const newValue = this.getValue()
|
||||
if (newValue === lastValue) return
|
||||
|
||||
lastValue = newValue
|
||||
listener(newValue)
|
||||
}
|
||||
|
||||
this._addDependent(dependent)
|
||||
|
||||
if (immediate) {
|
||||
lastValue = this.getValue()
|
||||
listener(lastValue as $IntentionalAny as V)
|
||||
}
|
||||
|
||||
const unsubscribe = () => {
|
||||
this._removeDependent(dependent)
|
||||
}
|
||||
|
||||
return unsubscribe
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a tappable that fires every time the prism's state goes from `fresh-\>stale.`
|
||||
*/
|
||||
onStale(callback: () => void): VoidFn {
|
||||
const untap = () => {
|
||||
this._removeDependent(fn)
|
||||
}
|
||||
const fn = () => callback()
|
||||
this._addDependent(fn)
|
||||
return untap
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the prism hot, even if there are no tappers (subscribers).
|
||||
*/
|
||||
keepHot() {
|
||||
return this.onStale(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a prism as a dependent of this prism.
|
||||
*
|
||||
* @param d - The prism to be made a dependent of this prism.
|
||||
*
|
||||
* @see _removeDependent
|
||||
*/
|
||||
_addDependent(d: IDependent) {
|
||||
if (!this._state.hot) {
|
||||
this._goHot()
|
||||
}
|
||||
this._state.handle!.addDependent(d)
|
||||
}
|
||||
|
||||
private _goHot() {
|
||||
const hotHandle = new HotHandle(this._fn, this)
|
||||
this._state = {
|
||||
hot: true,
|
||||
handle: hotHandle,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a prism as a dependent of this prism.
|
||||
*
|
||||
* @param d - The prism to be removed from as a dependent of this prism.
|
||||
*
|
||||
* @see _addDependent
|
||||
*/
|
||||
_removeDependent(d: IDependent) {
|
||||
const state = this._state
|
||||
if (!state.hot) {
|
||||
return
|
||||
}
|
||||
const handle = state.handle
|
||||
handle.removeDependent(d)
|
||||
if (!handle.hasDependents) {
|
||||
this._state = {hot: false, handle: undefined}
|
||||
handle.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current value of the prism. If the value is stale, it causes the prism to freshen.
|
||||
*/
|
||||
getValue(): V {
|
||||
/**
|
||||
* TODO We should prevent (or warn about) a common mistake users make, which is reading the value of
|
||||
* a prism in the body of a react component (e.g. `der.getValue()` (often via `val()`) instead of `useVal()`
|
||||
* or `uesPrism()`).
|
||||
*
|
||||
* Although that's the most common example of this mistake, you can also find it outside of react components.
|
||||
* Basically the user runs `der.getValue()` assuming the read is detected by a wrapping prism when it's not.
|
||||
*
|
||||
* Sometiems the prism isn't even hot when the user assumes it is.
|
||||
*
|
||||
* We can fix this type of mistake by:
|
||||
* 1. Warning the user when they call `getValue()` on a cold prism.
|
||||
* 2. Warning the user about calling `getValue()` on a hot-but-stale prism
|
||||
* if `getValue()` isn't called by a known mechanism like a `PrismEmitter`.
|
||||
*
|
||||
* Design constraints:
|
||||
* - This fix should not have a perf-penalty in production. Perhaps use a global flag + `process.env.NODE_ENV !== 'production'`
|
||||
* to enable it.
|
||||
* - In the case of `onStale()`, we don't control when the user calls
|
||||
* `getValue()` (as opposed to `onChange()` which calls `getValue()` directly).
|
||||
* Perhaps we can disable the check in that case.
|
||||
* - Probably the best place to add this check is right here in this method plus some changes to `reportResulutionStart()`,
|
||||
* which would have to be changed to let the caller know if there is an actual collector (a prism)
|
||||
* present in its stack.
|
||||
*/
|
||||
reportResolutionStart(this)
|
||||
|
||||
const state = this._state
|
||||
|
||||
let val: V
|
||||
if (state.hot) {
|
||||
val = state.handle.getValue()
|
||||
} else {
|
||||
val = calculateColdPrism(this._fn)
|
||||
}
|
||||
|
||||
reportResolutionEnd(this)
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
interface PrismScope {
|
||||
effect(key: string, cb: () => () => void, deps?: unknown[]): void
|
||||
memo<T>(
|
||||
key: string,
|
||||
fn: () => T,
|
||||
deps: undefined | $IntentionalAny[] | ReadonlyArray<$IntentionalAny>,
|
||||
): T
|
||||
state<T>(key: string, initialValue: T): [T, (val: T) => void]
|
||||
ref<T>(key: string, initialValue: T): IRef<T>
|
||||
sub(key: string): PrismScope
|
||||
source<V>(subscribe: (fn: (val: V) => void) => VoidFn, getValue: () => V): V
|
||||
}
|
||||
|
||||
class HotScope implements PrismScope {
|
||||
constructor(private readonly _hotHandle: HotHandle<unknown>) {}
|
||||
|
||||
protected readonly _refs: Map<string, IRef<unknown>> = new Map()
|
||||
ref<T>(key: string, initialValue: T): IRef<T> {
|
||||
let ref = this._refs.get(key)
|
||||
if (ref !== undefined) {
|
||||
return ref as $IntentionalAny as IRef<T>
|
||||
} else {
|
||||
const ref = {
|
||||
current: initialValue,
|
||||
}
|
||||
this._refs.set(key, ref)
|
||||
return ref
|
||||
}
|
||||
}
|
||||
isPrismScope = true
|
||||
|
||||
// NOTE probably not a great idea to eager-allocate all of these objects/maps for every scope,
|
||||
// especially because most wouldn't get used in the majority of cases. However, back when these
|
||||
// were stored on weakmaps, they were uncomfortable to inspect in the debugger.
|
||||
readonly subs: Record<string, HotScope> = {}
|
||||
readonly effects: Map<string, IEffect> = new Map()
|
||||
|
||||
effect(key: string, cb: () => () => void, deps?: unknown[]): void {
|
||||
let effect = this.effects.get(key)
|
||||
if (effect === undefined) {
|
||||
effect = {
|
||||
cleanup: voidFn,
|
||||
deps: undefined,
|
||||
}
|
||||
this.effects.set(key, effect)
|
||||
}
|
||||
|
||||
if (depsHaveChanged(effect.deps, deps)) {
|
||||
effect.cleanup()
|
||||
|
||||
startIgnoringDependencies()
|
||||
effect.cleanup = safelyRun(cb, voidFn).value
|
||||
stopIgnoringDependencies()
|
||||
effect.deps = deps
|
||||
}
|
||||
/**
|
||||
* TODO: we should cleanup dangling effects too.
|
||||
* Example:
|
||||
* ```ts
|
||||
* let i = 0
|
||||
* prism(() => {
|
||||
* if (i === 0) prism.effect("this effect will only run once", () => {}, [])
|
||||
* i++
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
}
|
||||
|
||||
readonly memos: Map<string, IMemo> = new Map()
|
||||
|
||||
memo<T>(
|
||||
key: string,
|
||||
fn: () => T,
|
||||
deps: undefined | $IntentionalAny[] | ReadonlyArray<$IntentionalAny>,
|
||||
): T {
|
||||
let memo = this.memos.get(key)
|
||||
if (memo === undefined) {
|
||||
memo = {
|
||||
cachedValue: null,
|
||||
// undefined will always indicate "deps have changed", so we set its initial value as such
|
||||
deps: undefined,
|
||||
}
|
||||
this.memos.set(key, memo)
|
||||
}
|
||||
|
||||
if (depsHaveChanged(memo.deps, deps)) {
|
||||
startIgnoringDependencies()
|
||||
|
||||
memo.cachedValue = safelyRun(fn, undefined).value
|
||||
stopIgnoringDependencies()
|
||||
memo.deps = deps
|
||||
}
|
||||
|
||||
return memo.cachedValue as $IntentionalAny as T
|
||||
}
|
||||
|
||||
state<T>(key: string, initialValue: T): [T, (val: T) => void] {
|
||||
const {value, setValue} = this.memo(
|
||||
'state/' + key,
|
||||
() => {
|
||||
const value = {current: initialValue}
|
||||
const setValue = (newValue: T) => {
|
||||
value.current = newValue
|
||||
this._hotHandle.forceStale()
|
||||
}
|
||||
return {value, setValue}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return [value.current, setValue]
|
||||
}
|
||||
|
||||
sub(key: string): HotScope {
|
||||
if (!this.subs[key]) {
|
||||
this.subs[key] = new HotScope(this._hotHandle)
|
||||
}
|
||||
return this.subs[key]
|
||||
}
|
||||
|
||||
cleanupEffects() {
|
||||
for (const effect of this.effects.values()) {
|
||||
safelyRun(effect.cleanup, undefined)
|
||||
}
|
||||
this.effects.clear()
|
||||
}
|
||||
|
||||
source<V>(subscribe: (fn: (val: V) => void) => VoidFn, getValue: () => V): V {
|
||||
const sourceKey = '$$source/blah'
|
||||
this.effect(
|
||||
sourceKey,
|
||||
() => {
|
||||
const unsub = subscribe(() => {
|
||||
this._hotHandle.forceStale()
|
||||
})
|
||||
return unsub
|
||||
},
|
||||
[subscribe],
|
||||
)
|
||||
return getValue()
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupScopeStack(scope: HotScope) {
|
||||
for (const sub of Object.values(scope.subs)) {
|
||||
cleanupScopeStack(sub)
|
||||
}
|
||||
scope.cleanupEffects()
|
||||
}
|
||||
|
||||
function safelyRun<T, U>(
|
||||
fn: () => T,
|
||||
returnValueInCaseOfError: U,
|
||||
): {ok: true; value: T} | {ok: false; value: U} {
|
||||
try {
|
||||
return {value: fn(), ok: true}
|
||||
} catch (error) {
|
||||
// Naming this function can allow the error reporter additional context to the user on where this error came from
|
||||
setTimeout(function PrismReportThrow() {
|
||||
// ensure that the error gets reported, but does not crash the current execution scope
|
||||
throw error
|
||||
})
|
||||
return {value: returnValueInCaseOfError, ok: false}
|
||||
}
|
||||
}
|
||||
|
||||
const hookScopeStack = new Stack<PrismScope>()
|
||||
|
||||
type IRef<T> = {
|
||||
current: T
|
||||
}
|
||||
|
||||
type IEffect = {
|
||||
deps: undefined | unknown[]
|
||||
cleanup: VoidFn
|
||||
}
|
||||
|
||||
type IMemo = {
|
||||
deps: undefined | unknown[] | ReadonlyArray<unknown>
|
||||
cachedValue: unknown
|
||||
}
|
||||
|
||||
function ref<T>(key: string, initialValue: T): IRef<T> {
|
||||
const scope = hookScopeStack.peek()
|
||||
if (!scope) {
|
||||
throw new Error(`prism.ref() is called outside of a prism() call.`)
|
||||
}
|
||||
|
||||
return scope.ref(key, initialValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* An effect hook, similar to React's `useEffect()`, but is not sensitive to call order by using `key`.
|
||||
*
|
||||
* @param key - the key for the effect. Should be uniqe inside of the prism.
|
||||
* @param cb - the callback function. Requires returning a cleanup function.
|
||||
* @param deps - the dependency array
|
||||
*/
|
||||
function effect(key: string, cb: () => () => void, deps?: unknown[]): void {
|
||||
const scope = hookScopeStack.peek()
|
||||
if (!scope) {
|
||||
throw new Error(`prism.effect() is called outside of a prism() call.`)
|
||||
}
|
||||
|
||||
return scope.effect(key, cb, deps)
|
||||
}
|
||||
|
||||
function depsHaveChanged(
|
||||
oldDeps: undefined | unknown[] | ReadonlyArray<unknown>,
|
||||
newDeps: undefined | unknown[] | ReadonlyArray<unknown>,
|
||||
): boolean {
|
||||
if (oldDeps === undefined || newDeps === undefined) {
|
||||
return true
|
||||
}
|
||||
|
||||
const len = oldDeps.length
|
||||
if (len !== newDeps.length) return true
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (oldDeps[i] !== newDeps[i]) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a value to this {@link prism} stack.
|
||||
*
|
||||
* Unlike hooks seen in popular frameworks like React, you provide an exact `key` so
|
||||
* we can call `prism.memo` in any order, and conditionally.
|
||||
*
|
||||
* @param deps - Passing in `undefined` will always cause a recompute
|
||||
*/
|
||||
function memo<T>(
|
||||
key: string,
|
||||
fn: () => T,
|
||||
deps: undefined | $IntentionalAny[] | ReadonlyArray<$IntentionalAny>,
|
||||
): T {
|
||||
const scope = hookScopeStack.peek()
|
||||
if (!scope) {
|
||||
throw new Error(`prism.memo() is called outside of a prism() call.`)
|
||||
}
|
||||
|
||||
return scope.memo(key, fn, deps)
|
||||
}
|
||||
|
||||
/**
|
||||
* A state hook, similar to react's `useState()`.
|
||||
*
|
||||
* @param key - the key for the state
|
||||
* @param initialValue - the initial value
|
||||
* @returns [currentState, setState]
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import {prism} from 'dataverse'
|
||||
*
|
||||
* // This prism holds the current mouse position and updates when the mouse moves
|
||||
* const mousePositionD = prism(() => {
|
||||
* const [pos, setPos] = prism.state<[x: number, y: number]>('pos', [0, 0])
|
||||
*
|
||||
* prism.effect(
|
||||
* 'setupListeners',
|
||||
* () => {
|
||||
* const handleMouseMove = (e: MouseEvent) => {
|
||||
* setPos([e.screenX, e.screenY])
|
||||
* }
|
||||
* document.addEventListener('mousemove', handleMouseMove)
|
||||
*
|
||||
* return () => {
|
||||
* document.removeEventListener('mousemove', handleMouseMove)
|
||||
* }
|
||||
* },
|
||||
* [],
|
||||
* )
|
||||
*
|
||||
* return pos
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
function state<T>(key: string, initialValue: T): [T, (val: T) => void] {
|
||||
const scope = hookScopeStack.peek()
|
||||
if (!scope) {
|
||||
throw new Error(`prism.state() is called outside of a prism() call.`)
|
||||
}
|
||||
|
||||
return scope.state(key, initialValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* This is useful to make sure your code is running inside a `prism()` call.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import {prism} from '@theatre/dataverse'
|
||||
*
|
||||
* function onlyUsefulInAPrism() {
|
||||
* prism.ensurePrism()
|
||||
* }
|
||||
*
|
||||
* prism(() => {
|
||||
* onlyUsefulInAPrism() // will run fine
|
||||
* })
|
||||
*
|
||||
* setTimeout(() => {
|
||||
* onlyUsefulInAPrism() // throws an error
|
||||
* console.log('This will never get logged')
|
||||
* }, 0)
|
||||
* ```
|
||||
*/
|
||||
function ensurePrism(): void {
|
||||
const scope = hookScopeStack.peek()
|
||||
if (!scope) {
|
||||
throw new Error(`The parent function is called outside of a prism() call.`)
|
||||
}
|
||||
}
|
||||
|
||||
function scope<T>(key: string, fn: () => T): T {
|
||||
const parentScope = hookScopeStack.peek()
|
||||
if (!parentScope) {
|
||||
throw new Error(`prism.scope() is called outside of a prism() call.`)
|
||||
}
|
||||
const subScope = parentScope.sub(key)
|
||||
hookScopeStack.push(subScope)
|
||||
const ret = safelyRun(fn, undefined).value
|
||||
hookScopeStack.pop()
|
||||
return ret as $IntentionalAny as T
|
||||
}
|
||||
|
||||
function sub<T>(
|
||||
key: string,
|
||||
fn: () => T,
|
||||
deps: undefined | $IntentionalAny[],
|
||||
): T {
|
||||
return memo(key, () => prism(fn), deps).getValue()
|
||||
}
|
||||
|
||||
function inPrism(): boolean {
|
||||
return !!hookScopeStack.peek()
|
||||
}
|
||||
|
||||
const possiblePrismToValue = <P extends Prism<$IntentionalAny> | unknown>(
|
||||
input: P,
|
||||
): P extends Prism<infer T> ? T : P => {
|
||||
if (isPrism(input)) {
|
||||
return input.getValue() as $IntentionalAny
|
||||
} else {
|
||||
return input as $IntentionalAny
|
||||
}
|
||||
}
|
||||
|
||||
function source<V>(
|
||||
subscribe: (fn: (val: V) => void) => VoidFn,
|
||||
getValue: () => V,
|
||||
): V {
|
||||
const scope = hookScopeStack.peek()
|
||||
if (!scope) {
|
||||
throw new Error(`prism.source() is called outside of a prism() call.`)
|
||||
}
|
||||
|
||||
return scope.source(subscribe, getValue)
|
||||
}
|
||||
|
||||
type IPrismFn = {
|
||||
<T>(fn: () => T): Prism<T>
|
||||
ref: typeof ref
|
||||
effect: typeof effect
|
||||
memo: typeof memo
|
||||
ensurePrism: typeof ensurePrism
|
||||
state: typeof state
|
||||
scope: typeof scope
|
||||
sub: typeof sub
|
||||
inPrism: typeof inPrism
|
||||
source: typeof source
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a prism from the passed function that adds all prisms referenced
|
||||
* in it as dependencies, and reruns the function when these change.
|
||||
*
|
||||
* @param fn - The function to rerun when the prisms referenced in it change.
|
||||
*/
|
||||
const prism: IPrismFn = (fn) => {
|
||||
return new PrismInstance(fn)
|
||||
}
|
||||
|
||||
class ColdScope implements PrismScope {
|
||||
effect(key: string, cb: () => () => void, deps?: unknown[]): void {
|
||||
console.warn(`prism.effect() does not run in cold prisms`)
|
||||
}
|
||||
memo<T>(
|
||||
key: string,
|
||||
fn: () => T,
|
||||
deps: any[] | readonly any[] | undefined,
|
||||
): T {
|
||||
return fn()
|
||||
}
|
||||
state<T>(key: string, initialValue: T): [T, (val: T) => void] {
|
||||
return [initialValue, () => {}]
|
||||
}
|
||||
ref<T>(key: string, initialValue: T): IRef<T> {
|
||||
return {current: initialValue}
|
||||
}
|
||||
sub(key: string): ColdScope {
|
||||
return new ColdScope()
|
||||
}
|
||||
source<V>(subscribe: (fn: (val: V) => void) => VoidFn, getValue: () => V): V {
|
||||
return getValue()
|
||||
}
|
||||
}
|
||||
|
||||
function calculateColdPrism<V>(fn: () => V): V {
|
||||
const scope = new ColdScope()
|
||||
hookScopeStack.push(scope)
|
||||
let value: V
|
||||
try {
|
||||
value = fn()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
const topOfTheStack = hookScopeStack.pop()
|
||||
if (topOfTheStack !== scope) {
|
||||
console.warn(
|
||||
// @todo guide the user to report the bug in an issue
|
||||
`The Prism hook stack has slipped. This is a bug.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
return value!
|
||||
}
|
||||
|
||||
prism.ref = ref
|
||||
prism.effect = effect
|
||||
prism.memo = memo
|
||||
prism.ensurePrism = ensurePrism
|
||||
prism.state = state
|
||||
prism.scope = scope
|
||||
prism.sub = sub
|
||||
prism.inPrism = inPrism
|
||||
prism.source = source
|
||||
|
||||
export default prism
|
Loading…
Add table
Add a link
Reference in a new issue