theatre/packages/dataverse/src/derivations/prism/prism.ts

425 lines
10 KiB
TypeScript
Raw Normal View History

2021-06-18 13:05:06 +02:00
import Box from '../../Box'
import type {$IntentionalAny, VoidFn} from '../../types'
import Stack from '../../utils/Stack'
import AbstractDerivation from '../AbstractDerivation'
import type {IDerivation} from '../IDerivation'
import {
startIgnoringDependencies,
stopIgnoringDependencies,
pushCollector,
popCollector,
2021-06-18 13:05:06 +02:00
} from './discoveryMechanism'
const voidFn = () => {}
export class PrismDerivation<V> extends AbstractDerivation<V> {
protected _cacheOfDendencyValues: Map<IDerivation<unknown>, unknown> =
new Map()
protected _possiblyStaleDeps = new Set<IDerivation<unknown>>()
private _prismScope = new PrismScope()
constructor(readonly _fn: () => V) {
super()
}
_recalculate() {
let value: V
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) {
// console.log('ok')
return this._lastValue!
}
}
const newDeps: Set<IDerivation<unknown>> = new Set()
this._cacheOfDendencyValues.clear()
const collector = (observedDep: IDerivation<unknown>): void => {
newDeps.add(observedDep)
this._addDependency(observedDep)
}
pushCollector(collector)
hookScopeStack.push(this._prismScope)
try {
value = this._fn()
} catch (error) {
console.error(error)
} finally {
const topOfTheStack = hookScopeStack.pop()
if (topOfTheStack !== this._prismScope) {
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)
2021-06-18 13:05:06 +02:00
for (const dep of this._dependencies) {
2021-06-18 13:05:06 +02:00
if (!newDeps.has(dep)) {
this._removeDependency(dep)
}
}
2021-06-18 13:05:06 +02:00
this._dependencies = newDeps
startIgnoringDependencies()
for (const dep of newDeps) {
2021-06-18 13:05:06 +02:00
this._cacheOfDendencyValues.set(dep, dep.getValue())
}
2021-06-18 13:05:06 +02:00
stopIgnoringDependencies()
return value!
}
_reactToDependencyBecomingStale(msgComingFrom: IDerivation<unknown>) {
this._possiblyStaleDeps.add(msgComingFrom)
}
_keepHot() {
this._prismScope = new PrismScope()
startIgnoringDependencies()
this.getValue()
stopIgnoringDependencies()
}
_becomeCold() {
cleanupScopeStack(this._prismScope)
this._prismScope = new PrismScope()
}
}
class PrismScope {
isPrismScope = true
private _subs: Record<string, PrismScope> = {}
readonly effects: Map<string, IEffect> = new Map()
2021-06-18 13:05:06 +02:00
sub(key: string) {
if (!this._subs[key]) {
this._subs[key] = new PrismScope()
}
return this._subs[key]
}
get subs() {
return this._subs
}
cleanupEffects() {
for (const effect of this.effects.values()) {
safelyRun(effect.cleanup, undefined)
}
this.effects.clear()
}
2021-06-18 13:05:06 +02:00
}
function cleanupScopeStack(scope: PrismScope) {
2022-04-30 15:19:47 +02:00
for (const sub of Object.values(scope.subs)) {
2021-06-18 13:05:06 +02:00
cleanupScopeStack(sub)
}
scope.cleanupEffects()
2021-06-18 13:05:06 +02:00
}
function safelyRun<T, U>(
fn: () => T,
returnValueInCaseOfError: U,
2022-04-30 15:19:47 +02:00
): {ok: true; value: T} | {ok: false; value: U} {
2021-06-18 13:05:06 +02:00
try {
2022-04-30 15:19:47 +02:00
return {value: fn(), ok: true}
2021-06-18 13:05:06 +02:00
} catch (error) {
2022-04-30 15:19:47 +02:00
// 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
2021-06-18 13:05:06 +02:00
throw error
})
2022-04-30 15:19:47 +02:00
return {value: returnValueInCaseOfError, ok: false}
2021-06-18 13:05:06 +02:00
}
}
const hookScopeStack = new Stack<PrismScope>()
2022-04-30 15:19:47 +02:00
const refsWeakMap = new WeakMap<PrismScope, Map<string, IRef<unknown>>>()
2021-06-18 13:05:06 +02:00
type IRef<T> = {
current: T
}
type IEffect = {
deps: undefined | unknown[]
cleanup: VoidFn
}
2022-04-30 15:19:47 +02:00
const memosWeakMap = new WeakMap<PrismScope, Map<string, IMemo>>()
2021-06-18 13:05:06 +02:00
type IMemo = {
2021-07-14 18:37:32 +02:00
deps: undefined | unknown[] | ReadonlyArray<unknown>
2021-06-18 13:05:06 +02:00
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.`)
}
let refs = refsWeakMap.get(scope)
2022-04-30 15:19:47 +02:00
if (refs === undefined) {
refs = new Map()
2021-06-18 13:05:06 +02:00
refsWeakMap.set(scope, refs)
}
2022-04-30 15:19:47 +02:00
let ref = refs.get(key)
if (ref !== undefined) {
return ref as $IntentionalAny as IRef<T>
2021-06-18 13:05:06 +02:00
} else {
2022-04-30 15:19:47 +02:00
const ref = {
2021-06-18 13:05:06 +02:00
current: initialValue,
}
2022-04-30 15:19:47 +02:00
refs.set(key, ref)
2021-06-18 13:05:06 +02:00
return ref
}
}
/**
2022-04-30 15:19:47 +02:00
* An effect hook, similar to React's `useEffect()`, but is not sensitive to call order by using `key`.
*
2022-04-09 15:30:20 +02:00
* @param key - the key for the effect. Should be uniqe inside of the prism.
2022-04-30 15:19:47 +02:00
* @param cb - the callback function. Requires returning a cleanup function.
2022-04-09 15:30:20 +02:00
* @param deps - the dependency array
*/
2021-06-18 13:05:06 +02:00
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.`)
}
let effect = scope.effects.get(key)
2022-04-30 15:19:47 +02:00
if (effect === undefined) {
effect = {
2021-06-18 13:05:06 +02:00
cleanup: voidFn,
2022-04-30 15:19:47 +02:00
deps: undefined,
2021-06-18 13:05:06 +02:00
}
scope.effects.set(key, effect)
2021-06-18 13:05:06 +02:00
}
if (depsHaveChanged(effect.deps, deps)) {
effect.cleanup()
startIgnoringDependencies()
2022-04-30 15:19:47 +02:00
effect.cleanup = safelyRun(cb, voidFn).value
2021-06-18 13:05:06 +02:00
stopIgnoringDependencies()
effect.deps = deps
}
}
function depsHaveChanged(
2021-07-14 18:37:32 +02:00
oldDeps: undefined | unknown[] | ReadonlyArray<unknown>,
newDeps: undefined | unknown[] | ReadonlyArray<unknown>,
2021-06-18 13:05:06 +02:00
): boolean {
if (oldDeps === undefined || newDeps === undefined) {
return true
}
2022-04-30 15:19:47 +02:00
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
2021-06-18 13:05:06 +02:00
}
2022-04-30 15:19:47 +02:00
/**
* 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
*/
2021-06-18 13:05:06 +02:00
function memo<T>(
key: string,
fn: () => T,
2021-07-14 18:37:32 +02:00
deps: undefined | $IntentionalAny[] | ReadonlyArray<$IntentionalAny>,
2021-06-18 13:05:06 +02:00
): T {
const scope = hookScopeStack.peek()
if (!scope) {
throw new Error(`prism.memo() is called outside of a prism() call.`)
}
let memos = memosWeakMap.get(scope)
if (!memos) {
2022-04-30 15:19:47 +02:00
memos = new Map()
2021-06-18 13:05:06 +02:00
memosWeakMap.set(scope, memos)
}
2022-04-30 15:19:47 +02:00
let memo = memos.get(key)
if (memo === undefined) {
memo = {
2021-06-18 13:05:06 +02:00
cachedValue: null,
2022-04-30 15:19:47 +02:00
// undefined will always indicate "deps have changed", so we set it's initial value as such
deps: undefined,
2021-06-18 13:05:06 +02:00
}
2022-04-30 15:19:47 +02:00
memos.set(key, memo)
2021-06-18 13:05:06 +02:00
}
if (depsHaveChanged(memo.deps, deps)) {
startIgnoringDependencies()
2022-04-30 15:19:47 +02:00
memo.cachedValue = safelyRun(fn, undefined).value
2021-06-18 13:05:06 +02:00
stopIgnoringDependencies()
memo.deps = deps
}
return memo.cachedValue as $IntentionalAny as T
}
2022-03-15 14:55:06 +01:00
/**
* 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 derivation 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
* })
* ```
*/
2021-06-18 13:05:06 +02:00
function state<T>(key: string, initialValue: T): [T, (val: T) => void] {
const {b, setValue} = prism.memo(
'state/' + key,
() => {
const b = new Box<T>(initialValue)
const setValue = (val: T) => b.set(val)
return {b, setValue}
},
[],
)
return [b.derivation.getValue(), setValue]
}
2022-03-15 14:55:06 +01:00
/**
* 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)
* ```
*/
2021-06-18 13:05:06 +02:00
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)
2022-04-30 15:19:47 +02:00
const ret = safelyRun(fn, undefined).value
2021-06-18 13:05:06 +02:00
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()
}
type IPrismFn = {
<T>(fn: () => T): IDerivation<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
}
2022-01-19 13:06:13 +01:00
/**
* Creates a derivation from the passed function that adds all derivations referenced
* in it as dependencies, and reruns the function when these change.
*
2022-02-23 22:53:39 +01:00
* @param fn - The function to rerun when the derivations referenced in it change.
2022-01-19 13:06:13 +01:00
*/
2021-06-18 13:05:06 +02:00
const prism: IPrismFn = (fn) => {
return new PrismDerivation(fn)
}
prism.ref = ref
prism.effect = effect
prism.memo = memo
prism.ensurePrism = ensurePrism
prism.state = state
prism.scope = scope
prism.sub = sub
prism.inPrism = inPrism
export default prism