Unify Derivation and Prism 13/13

This commit is contained in:
Aria Minaei 2022-12-01 15:09:53 +01:00
parent d9644f2370
commit d2876a7c9a
12 changed files with 24 additions and 24 deletions

View 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
}

View 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()

View 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()
}
}

View 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()
})
})

View 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()
}
}

View 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'})
})
})
})

View 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