diff --git a/packages/dataverse/src/Atom.ts b/packages/dataverse/src/Atom.ts index 5ecb27d..d4246d4 100644 --- a/packages/dataverse/src/Atom.ts +++ b/packages/dataverse/src/Atom.ts @@ -22,19 +22,17 @@ enum ValueTypes { /** * Interface for objects that can provide a prism at a certain path. */ -export interface IdentityPrismProvider { +export interface PointerToPrismProvider { /** * @internal - * Future: We could consider using a `Symbol.for("dataverse/IdentityPrismProvider")` as a key here, similar to + * Future: We could consider using a `Symbol.for("dataverse/PointerToPrismProvider")` as a key here, similar to * how {@link Iterable} works for `of`. */ - readonly $$isIdentityPrismProvider: true + readonly $$isPointerToPrismProvider: true /** * Returns a prism of the value at the provided path. - * - * @param path - The path to create the prism at. */ - getIdentityPrism(path: Array): Prism + pointerToPrism

(pointer: Pointer

): Prism

} const getTypeOfValue = (v: unknown): ValueTypes => { @@ -115,12 +113,12 @@ class Scope { /** * Wraps an object whose (sub)properties can be individually tracked. */ -export default class Atom implements IdentityPrismProvider { +export default class Atom implements PointerToPrismProvider { private _currentState: State /** * @internal */ - readonly $$isIdentityPrismProvider = true + readonly $$isPointerToPrismProvider = true private readonly _rootScope: Scope /** * Convenience property that gives you a pointer to the root of the atom. @@ -128,14 +126,15 @@ export default class Atom implements IdentityPrismProvider { * @remarks * Equivalent to `pointer({ root: thisAtom, path: [] })`. */ - readonly pointer: Pointer + readonly pointer: Pointer = pointer({root: this as $FixMe, path: []}) - readonly prism: Prism = this.getIdentityPrism([]) as $IntentionalAny + readonly prism: Prism = this.pointerToPrism( + this.pointer, + ) as $IntentionalAny constructor(initialState: State) { this._currentState = initialState this._rootScope = new Scope(undefined, []) - this.pointer = pointer({root: this as $FixMe, path: []}) } /** @@ -232,7 +231,8 @@ export default class Atom implements IdentityPrismProvider { * * @param path - The path to create the prism at. */ - getIdentityPrism(path: Array): Prism { + pointerToPrism

(pointer: Pointer

): Prism

{ + const {path} = getPointerParts(pointer) const subscribe = (listener: (val: unknown) => void) => this._onPathValueChange(path, listener) @@ -240,7 +240,7 @@ export default class Atom implements IdentityPrismProvider { return prism(() => { return prism.source(subscribe, getValue) - }) + }) as Prism

} } @@ -260,23 +260,22 @@ export const pointerToPrism =

>( let prismInstance = identifyPrismWeakMap.get(meta) if (!prismInstance) { const root = meta.root - if (!isIdentityPrismProvider(root)) { + if (!isPointerToPrismProvider(root)) { throw new Error( - `Cannot run pointerToPrism() on a pointer whose root is not an IdentityPrismProvider`, + `Cannot run pointerToPrism() on a pointer whose root is not an PointerToPrismProvider`, ) } - const {path} = meta - prismInstance = root.getIdentityPrism(path) + prismInstance = root.pointerToPrism(pointer as $IntentionalAny) identifyPrismWeakMap.set(meta, prismInstance) } return prismInstance as $IntentionalAny } -function isIdentityPrismProvider(val: unknown): val is IdentityPrismProvider { +function isPointerToPrismProvider(val: unknown): val is PointerToPrismProvider { return ( typeof val === 'object' && val !== null && - (val as $IntentionalAny)['$$isIdentityPrismProvider'] === true + (val as $IntentionalAny)['$$isPointerToPrismProvider'] === true ) } diff --git a/packages/dataverse/src/PointerProxy.ts b/packages/dataverse/src/PointerProxy.ts index de6d9ca..5ba2a1b 100644 --- a/packages/dataverse/src/PointerProxy.ts +++ b/packages/dataverse/src/PointerProxy.ts @@ -1,9 +1,11 @@ -import type {IdentityPrismProvider} from './Atom' +import type {PointerToPrismProvider} from './Atom' import Atom, {val} from './Atom' import type {Pointer} from './pointer' +import {getPointerMeta} from './pointer' import pointer from './pointer' import type {$FixMe, $IntentionalAny} from './types' import prism from './prism/prism' +import type {Prism} from './prism/Interface' /** * Allows creating pointer-prisms where the pointer can be switched out. @@ -13,12 +15,12 @@ import prism from './prism/prism' * to the proxied pointer too. */ export default class PointerProxy - implements IdentityPrismProvider + implements PointerToPrismProvider { /** * @internal */ - readonly $$isIdentityPrismProvider = true + readonly $$isPointerToPrismProvider = true private readonly _currentPointerBox: Atom> /** * Convenience pointer pointing to the root of this PointerProxy. @@ -46,14 +48,15 @@ export default class PointerProxy * * @param path - The path to create the prism at. */ - getIdentityPrism(path: Array) { + pointerToPrism

(pointer: Pointer

): Prism

{ + const {path} = getPointerMeta(pointer) return prism(() => { const currentPointer = this._currentPointerBox.prism.getValue() const subPointer = path.reduce( (pointerSoFar, pathItem) => (pointerSoFar as $IntentionalAny)[pathItem], currentPointer, ) - return val(subPointer) + return val(subPointer) as P }) } } diff --git a/packages/dataverse/src/dataverse.test.ts b/packages/dataverse/src/dataverse.test.ts new file mode 100644 index 0000000..656df22 --- /dev/null +++ b/packages/dataverse/src/dataverse.test.ts @@ -0,0 +1,327 @@ +import {prism} from '@theatre/dataverse' + +describe(`prism()`, () => { + // these tests are going will act as a guide on how to use prisms. + + // prisms are a way to create a value that depends on other values. + // prisms can be hot or cold, they have dependencies and dependents, and hot prisms can be stale or fresh. + + // let's start with a simple example: + test(`using a pretty useless prism`, async () => { + // each prism has a calculate function that it runs to calculate its value. let's make a simple function that just returns 1 + const calculate = jest.fn(() => 1) + + // now we can make a prism out of it + const pr = prism(calculate) + + // now, this prism is pretty useless. it doesn't depend on anything, and it doesn't have any dependents. + // but we can already illustrate some of the concepts that are important to understand prisms. + + // `calculate` won't be called until it's needed + expect(calculate).not.toHaveBeenCalled() + + // we can get the value of the prism, which will be what `calculate` returned + expect(pr.getValue()).toBe(1) + + // now the calculate function will have be called + expect(calculate).toHaveBeenCalledTimes(1) + + // now, you might expect that if we call `getValue()` again, the calculate function won't be called again. + // but that's not the case. the calculate function will be called again, because the prism is cold. + // we'll talk about cold/hot in a bit, but let's just say that cold prisms are calculated every time they're read. + + pr.getValue() + expect(calculate).toHaveBeenCalledTimes(2) + + // we can even check whether a prism is hot or cold. Ours is cold. + expect(pr.isHot).toBe(false) + + // we'll get to hot prisms soon, but let's talk about dependencies and dependents first. + }) + + // prisms can depend on other prisms. let's make a prism that depends on another prism. + test(`prisms can depend on other prisms`, async () => { + const calculateA = jest.fn(() => 1) + const a = prism(calculateA) + + const calculateATimesTwo = jest.fn(() => a.getValue() * 2) + const aTimesTwo = prism(calculateATimesTwo) + + // clear the count of mocks + function clearMocks() { + calculateA.mockClear() + calculateATimesTwo.mockClear() + } + + // now, `aTimesTwo` depends on `a`. + // In our parlance, `aTimesTwo` is a dependent of `a`, and `a` is a dependency of `aTimesTwo`. + + // now if we read the value of `aTimesTwo`, it will call `calculateATimesTwo`, which will call `calculateA`: + expect(aTimesTwo.getValue()).toBe(2) + expect(calculateA).toHaveBeenCalledTimes(1) + expect(calculateATimesTwo).toHaveBeenCalledTimes(1) + + clearMocks() + + // and like we saw in the previous test, if we read the value of `aTimesTwo` again, it will call both of our calculate functions again: + aTimesTwo.getValue() + expect(calculateATimesTwo).toHaveBeenCalledTimes(1) + expect(calculateA).toHaveBeenCalledTimes(1) + + clearMocks() + + // but if we read the value of `a`, it won't call `calculateATimesTwo`: + a.getValue() + expect(calculateATimesTwo).toHaveBeenCalledTimes(0) + expect(calculateA).toHaveBeenCalledTimes(1) + + clearMocks() + + // now let's see what happens if we make our prism hot. + + // one way to do this, is to add an `onStale` listener to our prism. + const onStale = jest.fn() + const unsubscribe = aTimesTwo.onStale(onStale) + + // as soon as we do this, the prism will become hot. + expect(aTimesTwo.isHot).toBe(true) + + // and so will its dependencies: + expect(a.isHot).toBe(true) + + // so let's see what happens when we read the value of `aTimesTwo` again: + aTimesTwo.getValue() + // `calculateATimesTwo` will be called again, + expect(calculateATimesTwo).toHaveBeenCalledTimes(1) + // and so will `calculateA`, + expect(calculateA).toHaveBeenCalledTimes(1) + + clearMocks() + + // but if we read `aTimesTwo` again, none of the calculate functions will be called again. + aTimesTwo.getValue() + expect(calculateATimesTwo).toHaveBeenCalledTimes(0) + expect(calculateA).toHaveBeenCalledTimes(0) + + clearMocks() + + // this behavior propogates up the dependency chain, so if we read `a` again, `calculateA` won't be called again, + // because its value is already fresh. + a.getValue() + expect(calculateA).toHaveBeenCalledTimes(0) + + clearMocks() + + // at this point, since none of our prisms depend on a prism whose value will change, they'll remain + // fresh forever. + a.getValue() + aTimesTwo.getValue() + a.getValue() + aTimesTwo.getValue() + + expect(calculateATimesTwo).toHaveBeenCalledTimes(0) + expect(calculateA).toHaveBeenCalledTimes(0) + + clearMocks() + + // but as soon as we unsubscribe from our `onStale()` listener, the prisms will become cold again. + unsubscribe() + expect(aTimesTwo.isHot).toBe(false) + expect(a.isHot).toBe(false) + + // and they'll return back to their cold behavior. + aTimesTwo.getValue() + expect(calculateATimesTwo).toHaveBeenCalledTimes(1) + expect(calculateA).toHaveBeenCalledTimes(1) + + clearMocks() + + aTimesTwo.getValue() + expect(calculateATimesTwo).toHaveBeenCalledTimes(1) + expect(calculateA).toHaveBeenCalledTimes(1) + + clearMocks() + + // now, one more thing before we move on. What will if we make `a` hot, but not `aTimesTwo`? + // let's try it out. + const unsubcribeFromAOnStale = a.onStale(() => {}) + // a will go hot + expect(a.isHot).toBe(true) + // but aTimesTwo will stay cold + expect(aTimesTwo.isHot).toBe(false) + + // now let's read the value of `a` + a.getValue() + + // `calculateA` will be called + expect(calculateA).toHaveBeenCalledTimes(1) + // and obviously `calculateATimesTwo` won't be called + expect(calculateATimesTwo).toHaveBeenCalledTimes(0) + + clearMocks() + + // and if we re-read the value of `a`, `calculateA` won't be called again, becuase `a` is hot and its value is fresh. + a.getValue() + expect(calculateA).toHaveBeenCalledTimes(0) + + clearMocks() + + // but if we read the value of `aTimesTwo`, `calculateATimesTwo` will be called, because `aTimesTwo` is cold. + aTimesTwo.getValue() + expect(calculateATimesTwo).toHaveBeenCalledTimes(1) + // yet `calculateA` won't be called, because `a` is hot and its value is fresh. + expect(calculateA).toHaveBeenCalledTimes(0) + + clearMocks() + + // in conclusion, if we make a prism hot, it'll make its dependencies hot too. + // if we read the value of a cold prism, it'll call its calculate function, which will + // call the calculate functions of its dependencies, and so on. + // but if we read the value of a hot prism, it'll only call its calculate function if its value is stale. + + // le'ts wrap up this part by unsubscribing from `a`'s `onStale` listener to not have any lingering listeners. + unsubcribeFromAOnStale() + }) + + // so far, our prisms have not depended on any changing values, so in turn, _their_ values have never changed either. + // but what if we want to create a prism that depends on a changing value? + // we call those values "sources", and we can create them using the `prism.source()` hook: + test('prism.source()', () => { + // let's say we want to create a prism that depends on this value: + let value = 0 + + { + // the _wrong_ way to do this, is to create a prism that directly reads this value + const p = prism(() => value) + + // this will actually work if the prism is cold: + expect(p.getValue()).toBe(0) + value = 1 + expect(p.getValue()).toBe(1) + + // but if we make the prism hot, it'll never update its value, because it's not subscribed to any sources. + const unsubscribe = p.onStale(() => {}) + expect(p.isHot).toBe(true) + // on first read, it'll give us the current value of `value`, which is 1. + expect(p.getValue()).toBe(1) + // but if we change `value` again, the prism won't know + value = 2 + expect(p.getValue()).toBe(1) + // and so it'll keep returning the old value. + expect(p.getValue()).toBe(1) + + unsubscribe() + } + + // so, the _right_ way to do this, is to use the `source` hook: + { + // the source hook requires a `listen` function, and a `get` function. + // let's skip the `listen` function for now, and just focus on the `getValue` function. + const listen = jest.fn(() => () => {}) + // the `getValue` function should return the current value of the source. + const get = jest.fn(() => value) + + const p = prism(() => { + return prism.source(listen, get) * 2 + }) + + value = 1 + + // our prism is cold right now. let's see what happens when we read its value. + expect(p.getValue()).toBe(2) + // `get` will be called once, because we're reading the value of the source for the first time. + expect(get).toHaveBeenCalledTimes(1) + // and `listen` won't be called at all + expect(listen).toHaveBeenCalledTimes(0) + + get.mockClear() + + // now let's make the prism hot + const unsubscribe = p.onStale(() => {}) + expect(p.isHot).toBe(true) + expect(p.getValue()).toBe(2) + // `get` will be called again, because we're reading the value of the source for the second time. + expect(get).toHaveBeenCalledTimes(1) + // and `listen` will be called once, because our prism wants to be notified when the source changes. + expect(listen).toHaveBeenCalledTimes(1) + + get.mockClear() + listen.mockClear() + + // now, since our `listen` function is a mock, it won't actually do anything, + // so the prism still won't know when the source changes. + value = 2 + expect(p.getValue()).toBe(2) + // `get` won't be called again, because the source hasn't changed. + expect(get).toHaveBeenCalledTimes(0) + + unsubscribe() + } + + { + let value = 0 + // now let's implement an actual `listen` function. + + // first, we need to keep track of all the listeners that our source wil have + const listeners = new Set<(val: number) => void>() + + // the `listen` function should return an stop function. + // the stop function should stop listening to the source. + const listen = jest.fn((fn) => { + listeners.add(fn) + + return function stop() { + listeners.delete(fn) + } + }) + + const get = jest.fn(() => value) + + // and now we need to define a function that will notify all the listeners that the source has changed. + const set = (newValue: number) => { + if (newValue !== value) { + value = newValue + listeners.forEach((fn) => fn(value)) + } + } + + // don't worry, there are helpers for this in dataverse. but for now, we'll implement + // it manually to understand how it works. + + // now let's create a prism that depends on our source. + const p = prism(() => { + return prism.source(listen, get) * 2 + }) + + // let's make the prism hot + const staleListener = jest.fn() + const unsubscribe = p.onStale(staleListener) + expect(p.isHot).toBe(true) + + // and let's read its value + expect(p.getValue()).toBe(0) + // `get` will be called once, because we're reading the value of the source for the first time. + expect(get).toHaveBeenCalledTimes(1) + // and `listen` will be called once, because our prism wants to be notified when the source changes. + expect(listen).toHaveBeenCalledTimes(1) + + get.mockClear() + listen.mockClear() + + // now let's change the value of the source + set(1) + + // this time, our prism will know that the source has changed, and it'll update its value. + expect(p.getValue()).toBe(2) + + // and that's how we create a prism that depends on a changing value. + + unsubscribe() + } + }) + + // in practice, we'll almost never need to use the `source` hook directly, + // and we'll never need to provide our own `listen` and `get` functions. + // instead, we'll use `Atom`s, which are sources that are already implemented for us. + test(`Using Atoms`, () => {}) +}) diff --git a/packages/dataverse/src/index.ts b/packages/dataverse/src/index.ts index d01ecb5..5a933d3 100644 --- a/packages/dataverse/src/index.ts +++ b/packages/dataverse/src/index.ts @@ -4,7 +4,7 @@ * @packageDocumentation */ -export type {IdentityPrismProvider} from './Atom' +export type {PointerToPrismProvider} from './Atom' export {default as Atom, val, pointerToPrism} from './Atom' export {isPrism} from './prism/Interface' export type {Prism} from './prism/Interface' diff --git a/theatre/core/src/sequences/Sequence.ts b/theatre/core/src/sequences/Sequence.ts index 7b49785..017e969 100644 --- a/theatre/core/src/sequences/Sequence.ts +++ b/theatre/core/src/sequences/Sequence.ts @@ -44,7 +44,7 @@ export default class Sequence { _playableRangeD: undefined | Prism<{start: number; end: number}> readonly pointer: ISequence['pointer'] = pointer({root: this, path: []}) - readonly $$isIdentityPrismProvider = true + readonly $$isPointerToPrismProvider = true readonly _logger: ILogger constructor( @@ -81,7 +81,7 @@ export default class Sequence { }) } - getIdentityPrism(path: Array): Prism { + pointerToPrism(path: Array): Prism { if (path.length === 0) { return prism((): ISequence['pointer']['$$__pointer_type'] => ({ length: val(this.pointer.length), diff --git a/theatre/core/src/sheetObjects/SheetObject.ts b/theatre/core/src/sheetObjects/SheetObject.ts index deef0d4..8be6c32 100644 --- a/theatre/core/src/sheetObjects/SheetObject.ts +++ b/theatre/core/src/sheetObjects/SheetObject.ts @@ -14,7 +14,7 @@ import type { SerializableValue, } from '@theatre/shared/utils/types' import {valToAtom} from '@theatre/shared/utils/valToAtom' -import type {IdentityPrismProvider, Prism, Pointer} from '@theatre/dataverse' +import type {PointerToPrismProvider, Prism, Pointer} from '@theatre/dataverse' import {Atom, getPointerParts, pointer, prism, val} from '@theatre/dataverse' import type SheetObjectTemplate from './SheetObjectTemplate' @@ -38,11 +38,11 @@ type SheetObjectPropsValue = SerializableMap * Note that this cannot be generic over `Props`, since the user is * able to change prop configs for the sheet object's properties. */ -export default class SheetObject implements IdentityPrismProvider { +export default class SheetObject implements PointerToPrismProvider { get type(): 'Theatre_SheetObject' { return 'Theatre_SheetObject' } - readonly $$isIdentityPrismProvider: true = true + readonly $$isPointerToPrismProvider: true = true readonly address: SheetObjectAddress readonly publicApi: TheatreSheetObject private readonly _initialValue = new Atom({}) @@ -211,7 +211,8 @@ export default class SheetObject implements IdentityPrismProvider { ) as SerializableValue as T } - getIdentityPrism(path: Array): Prism { + pointerToPrism

(pointer: Pointer

): Prism

{ + const {path} = getPointerParts(pointer) /** * @remarks * TODO perf: Too much indirection here. @@ -219,7 +220,7 @@ export default class SheetObject implements IdentityPrismProvider { return prism(() => { const allValuesP = val(this.getValues()) return val(pointerDeep(allValuesP as $FixMe, path)) as SerializableMap - }) + }) as $IntentionalAny as Prism

} /**