diff --git a/packages/dataverse/src/derivations/prism/prism.ts b/packages/dataverse/src/derivations/prism/prism.ts index e12b342..eb4c5d6 100644 --- a/packages/dataverse/src/derivations/prism/prism.ts +++ b/packages/dataverse/src/derivations/prism/prism.ts @@ -1,25 +1,267 @@ import Box from '../../Box' +import type Ticker from '../../Ticker' import type {$IntentionalAny, VoidFn} from '../../types' import Stack from '../../utils/Stack' -import AbstractDerivation from '../AbstractDerivation' +import type Tappable from '../../utils/Tappable' +import DerivationEmitter from '../DerivationEmitter' +import DerivationValuelessEmitter from '../DerivationValuelessEmitter' import type {IDerivation} from '../IDerivation' +import {isDerivation} from '../IDerivation' import { startIgnoringDependencies, stopIgnoringDependencies, pushCollector, popCollector, + reportResolutionStart, + reportResolutionEnd, } from './discoveryMechanism' +type IDependent = (msgComingFrom: IDerivation<$IntentionalAny>) => void + const voidFn = () => {} -export class PrismDerivation extends AbstractDerivation { +export class PrismDerivation implements IDerivation { protected _cacheOfDendencyValues: Map, unknown> = new Map() protected _possiblyStaleDeps = new Set>() private _prismScope = new PrismScope() - constructor(readonly _fn: () => V) { - super() + /** + * Whether the object is a derivation. + */ + readonly isDerivation: true = true + private _didMarkDependentsAsStale: boolean = false + private _isHot: boolean = false + + private _isFresh: boolean = false + + /** + * @internal + */ + protected _lastValue: undefined | V = undefined + + /** + * @internal + */ + protected _dependents: Set = new Set() + + /** + * @internal + */ + protected _dependencies: Set> = new Set() + + constructor(readonly _fn: () => V) {} + + /** + * Whether the derivation is hot. + */ + get isHot(): boolean { + return this._isHot + } + + /** + * @internal + */ + protected _addDependency(d: IDerivation<$IntentionalAny>) { + if (this._dependencies.has(d)) return + this._dependencies.add(d) + if (this._isHot) d.addDependent(this._markAsStale) + } + + /** + * @internal + */ + protected _removeDependency(d: IDerivation<$IntentionalAny>) { + if (!this._dependencies.has(d)) return + this._dependencies.delete(d) + if (this._isHot) d.removeDependent(this._markAsStale) + } + + /** + * Returns a `Tappable` of the changes of this derivation. + */ + changes(ticker: Ticker): Tappable { + return new DerivationEmitter(this, ticker).tappable() + } + + /** + * Like {@link AbstractDerivation.changes} but with a different performance model. `changesWithoutValues` returns a `Tappable` that + * updates every time the derivation is updated, even if the value didn't change, and the callback is called without + * the value. The advantage of this is that you have control over when the derivation is freshened, it won't + * automatically be kept fresh. + */ + changesWithoutValues(): Tappable { + return new DerivationValuelessEmitter(this).tappable() + } + + /** + * Keep the derivation hot, even if there are no tappers (subscribers). + */ + keepHot() { + return this.changesWithoutValues().tap(() => {}) + } + + /** + * Convenience method that taps (subscribes to) the derivation using `this.changes(ticker).tap(fn)` and immediately calls + * the callback with the current value. + * + * @param ticker - The ticker to use for batching. + * @param fn - The callback to call on update. + * + * @see changes + */ + tapImmediate(ticker: Ticker, fn: (cb: V) => void): VoidFn { + const untap = this.changes(ticker).tap(fn) + fn(this.getValue()) + return untap + } + + /** + * Add a derivation as a dependent of this derivation. + * + * @param d - The derivation to be made a dependent of this derivation. + * + * @see removeDependent + */ + // TODO: document this better, what are dependents? + addDependent(d: IDependent) { + const hadDepsBefore = this._dependents.size > 0 + this._dependents.add(d) + const hasDepsNow = this._dependents.size > 0 + if (hadDepsBefore !== hasDepsNow) { + this._reactToNumberOfDependentsChange() + } + } + + /** + * Remove a derivation as a dependent of this derivation. + * + * @param d - The derivation to be removed from as a dependent of this derivation. + * + * @see addDependent + */ + removeDependent(d: IDependent) { + const hadDepsBefore = this._dependents.size > 0 + this._dependents.delete(d) + const hasDepsNow = this._dependents.size > 0 + if (hadDepsBefore !== hasDepsNow) { + this._reactToNumberOfDependentsChange() + } + } + + protected _markAsStale = (which: IDerivation<$IntentionalAny>) => { + this._reactToDependencyBecomingStale(which) + + if (this._didMarkDependentsAsStale) return + + this._didMarkDependentsAsStale = true + this._isFresh = false + + for (const dependent of this._dependents) { + dependent(this) + } + } + + /** + * Gets the current value of the derivation. If the value is stale, it causes the derivation to freshen. + */ + getValue(): V { + /** + * TODO We should prevent (or warn about) a common mistake users make, which is reading the value of + * a derivation 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 derivation 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 derivation. + * 2. Warning the user about calling `getValue()` on a hot-but-stale derivation + * if `getValue()` isn't called by a known mechanism like a `DerivationEmitter`. + * + * 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 `DerivationValuelessEmitter`, we don't control when the user calls + * `getValue()` (as opposed to `DerivationEmitter` 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) + + if (!this._isFresh) { + const newValue = this._recalculate() + this._lastValue = newValue + if (this._isHot) { + this._isFresh = true + this._didMarkDependentsAsStale = false + } + } + + reportResolutionEnd(this) + return this._lastValue! + } + + private _reactToNumberOfDependentsChange() { + const shouldBecomeHot = this._dependents.size > 0 + + if (shouldBecomeHot === this._isHot) return + + this._isHot = shouldBecomeHot + this._didMarkDependentsAsStale = false + this._isFresh = false + if (shouldBecomeHot) { + for (const d of this._dependencies) { + d.addDependent(this._markAsStale) + } + this._keepHot() + } else { + for (const d of this._dependencies) { + d.removeDependent(this._markAsStale) + } + this._becomeCold() + } + } + + /** + * A simple mapping function similar to Array.map() + * + * @deprecated This is a remnant of the old monadic api. Now it's functionally equal to `prism(() => fn(der.getValue()))`, so use that instead. + */ + map(fn: (v: V) => T): IDerivation { + console.log('map') + + return prism(() => fn(this.getValue())) + } + + /** + * Same as {@link AbstractDerivation.map}, but the mapping function can also return a derivation, in which case the derivation returned + * by `flatMap` takes the value of that derivation. + * + * @deprecated This is a remnant of the old monadic api. Now it's functionally equal to `prism(() => val(fn(val(der))))` + * + * @example + * ```ts + * // Simply using map() here would return the inner derivation when we call getValue() + * new Box(3).derivation.map((value) => new Box(value).derivation).getValue() + * + * // Using flatMap() eliminates the inner derivation + * new Box(3).derivation.flatMap((value) => new Box(value).derivation).getValue() + * ``` + * + * @param fn - The mapping function to use. Note: it accepts a plain value, not a derivation. + */ + flatMap( + fn: (v: V) => R, + ): IDerivation ? T : R> { + console.log('flatMap') + return prism(() => { + return possibleDerivationToValue(fn(this.getValue())) + }) } _recalculate() { @@ -376,6 +618,18 @@ function inPrism(): boolean { return !!hookScopeStack.peek() } +const possibleDerivationToValue = < + P extends IDerivation<$IntentionalAny> | unknown, +>( + input: P, +): P extends IDerivation ? T : P => { + if (isDerivation(input)) { + return input.getValue() as $IntentionalAny + } else { + return input as $IntentionalAny + } +} + type IPrismFn = { (fn: () => T): IDerivation ref: typeof ref