From ef279eddffcff12f3da32df2c8d838194781315f Mon Sep 17 00:00:00 2001 From: Aria Minaei Date: Sat, 21 Jan 2023 21:57:28 +0100 Subject: [PATCH] Add tests and docs to dataverse --- packages/dataverse/README.md | 2 +- packages/dataverse/src/Atom.ts | 176 ++-- packages/dataverse/src/PointerProxy.ts | 5 +- packages/dataverse/src/atom.typeTest.ts | 3 +- packages/dataverse/src/dataverse.test.ts | 907 +++++++++++++++++- packages/dataverse/src/index.ts | 8 +- packages/dataverse/src/integration.test.ts | 3 +- packages/dataverse/src/pointer.ts | 2 +- packages/dataverse/src/pointerToPrism.ts | 58 ++ packages/dataverse/src/prism/Interface.ts | 2 +- .../src/prism/iterateAndCountTicks.ts | 2 +- packages/dataverse/src/prism/iterateOver.ts | 2 +- packages/dataverse/src/prism/prism.test.ts | 3 +- packages/dataverse/src/prism/prism.ts | 78 +- packages/dataverse/src/val.ts | 41 + packages/react/README.md | 77 +- theatre/core/src/propTypes/index.ts | 3 +- 17 files changed, 1225 insertions(+), 147 deletions(-) create mode 100644 packages/dataverse/src/pointerToPrism.ts create mode 100644 packages/dataverse/src/val.ts diff --git a/packages/dataverse/README.md b/packages/dataverse/README.md index fe6a24c..3058276 100644 --- a/packages/dataverse/README.md +++ b/packages/dataverse/README.md @@ -6,4 +6,4 @@ Dataverse is currently an internal library. It is used within Theatre.js, but it ## Documentation -> TODO: Write new docs. Enough has changed between 0.5 and 0.6, that the 0.5 docs are not useful anymore. \ No newline at end of file +* [The exhaustive guide to dataverse](./src/dataverse.test.ts) \ No newline at end of file diff --git a/packages/dataverse/src/Atom.ts b/packages/dataverse/src/Atom.ts index d4246d4..4c12a30 100644 --- a/packages/dataverse/src/Atom.ts +++ b/packages/dataverse/src/Atom.ts @@ -2,14 +2,14 @@ import get from 'lodash-es/get' import isPlainObject from 'lodash-es/isPlainObject' import last from 'lodash-es/last' import type {Prism} from './prism/Interface' -import {isPrism} from './prism/Interface' -import type {Pointer, PointerType} from './pointer' +import type {Pointer} from './pointer' import {getPointerParts} from './pointer' import {isPointer} from './pointer' -import pointer, {getPointerMeta} from './pointer' +import pointer from './pointer' import type {$FixMe, $IntentionalAny} from './types' import updateDeep from './utils/updateDeep' import prism from './prism/prism' +import type {PointerToPrismProvider} from './pointerToPrism' type Listener = (newVal: unknown) => void @@ -19,22 +19,6 @@ enum ValueTypes { Other, } -/** - * Interface for objects that can provide a prism at a certain path. - */ -export interface PointerToPrismProvider { - /** - * @internal - * Future: We could consider using a `Symbol.for("dataverse/PointerToPrismProvider")` as a key here, similar to - * how {@link Iterable} works for `of`. - */ - readonly $$isPointerToPrismProvider: true - /** - * Returns a prism of the value at the provided path. - */ - pointerToPrism

(pointer: Pointer

): Prism

-} - const getTypeOfValue = (v: unknown): ValueTypes => { if (Array.isArray(v)) return ValueTypes.Array if (isPlainObject(v)) return ValueTypes.Dict @@ -153,8 +137,25 @@ export default class Atom implements PointerToPrismProvider { return this._currentState } - getByPointer(fn: (p: Pointer) => Pointer): S { - const pointer = fn(this.pointer) + /** + * Returns the value at the given pointer + * + * @param pointerOrFn - A pointer to the desired path. Could also be a function returning a pointer + * + * Example + * ```ts + * const atom = atom({ a: { b: 1 } }) + * atom.getByPointer(atom.pointer.a.b) // 1 + * atom.getByPointer((p) => p.a.b) // 1 + * ``` + */ + getByPointer( + pointerOrFn: Pointer | ((p: Pointer) => Pointer), + ): S { + const pointer = isPointer(pointerOrFn) + ? pointerOrFn + : (pointerOrFn as $IntentionalAny)(this.pointer) + const path = getPointerParts(pointer).path return this._getIn(path) as S } @@ -170,18 +171,48 @@ export default class Atom implements PointerToPrismProvider { this.set(fn(this.get())) } + /** + * Reduces the value at the given pointer + * + * @param pointerOrFn - A pointer to the desired path. Could also be a function returning a pointer + * + * Example + * ```ts + * const atom = atom({ a: { b: 1 } }) + * atom.reduceByPointer(atom.pointer.a.b, (b) => b + 1) // atom.get().a.b === 2 + * atom.reduceByPointer((p) => p.a.b, (b) => b + 1) // atom.get().a.b === 2 + * ``` + */ reduceByPointer( - fn: (p: Pointer) => Pointer, + pointerOrFn: Pointer | ((p: Pointer) => Pointer), reducer: (s: S) => S, ) { - const pointer = fn(this.pointer) + const pointer = isPointer(pointerOrFn) + ? pointerOrFn + : (pointerOrFn as $IntentionalAny)(this.pointer) + const path = getPointerParts(pointer).path const newState = updateDeep(this.get(), path, reducer) this.set(newState) } - setByPointer(fn: (p: Pointer) => Pointer, val: S) { - this.reduceByPointer(fn, () => val) + /** + * Sets the value at the given pointer + * + * @param pointerOrFn - A pointer to the desired path. Could also be a function returning a pointer + * + * Example + * ```ts + * const atom = atom({ a: { b: 1 } }) + * atom.setByPointer(atom.pointer.a.b, 2) // atom.get().a.b === 2 + * atom.setByPointer((p) => p.a.b, 2) // atom.get().a.b === 2 + * ``` + */ + setByPointer( + pointerOrFn: Pointer | ((p: Pointer) => Pointer), + val: S, + ) { + this.reduceByPointer(pointerOrFn, () => val) } private _checkUpdates(scope: Scope, oldState: unknown, newState: unknown) { @@ -214,27 +245,33 @@ export default class Atom implements PointerToPrismProvider { return curScope } - private _onPathValueChange = ( - path: (string | number)[], - cb: (v: unknown) => void, - ) => { + private _onPointerValueChange =

( + pointer: Pointer

, + cb: (v: P) => void, + ): (() => void) => { + const {path} = getPointerParts(pointer) const scope = this._getOrCreateScopeForPath(path) - scope.identityChangeListeners.add(cb) - const untap = () => { - scope.identityChangeListeners.delete(cb) + scope.identityChangeListeners.add(cb as $IntentionalAny) + const unsubscribe = () => { + scope.identityChangeListeners.delete(cb as $IntentionalAny) } - return untap + return unsubscribe } /** * Returns a new prism of the value at the provided path. * - * @param path - The path to create the prism at. + * @param pointer - The path to create the prism at. + * + * ```ts + * const pr = atom({ a: { b: 1 } }).pointerToPrism(atom.pointer.a.b) + * pr.getValue() // 1 + * ``` */ pointerToPrism

(pointer: Pointer

): Prism

{ const {path} = getPointerParts(pointer) const subscribe = (listener: (val: unknown) => void) => - this._onPathValueChange(path, listener) + this._onPointerValueChange(pointer, listener) const getValue = () => this._getIn(path) @@ -243,72 +280,3 @@ export default class Atom implements PointerToPrismProvider { }) as Prism

} } - -const identifyPrismWeakMap = new WeakMap<{}, Prism>() - -/** - * Returns a prism of the value at the provided pointer. Prisms are - * cached per pointer. - * - * @param pointer - The pointer to return the prism at. - */ -export const pointerToPrism =

>( - pointer: P, -): Prism

? T : void> => { - const meta = getPointerMeta(pointer) - - let prismInstance = identifyPrismWeakMap.get(meta) - if (!prismInstance) { - const root = meta.root - if (!isPointerToPrismProvider(root)) { - throw new Error( - `Cannot run pointerToPrism() on a pointer whose root is not an PointerToPrismProvider`, - ) - } - prismInstance = root.pointerToPrism(pointer as $IntentionalAny) - identifyPrismWeakMap.set(meta, prismInstance) - } - return prismInstance as $IntentionalAny -} - -function isPointerToPrismProvider(val: unknown): val is PointerToPrismProvider { - return ( - typeof val === 'object' && - val !== null && - (val as $IntentionalAny)['$$isPointerToPrismProvider'] === true - ) -} - -/** - * Convenience function that returns a plain value from its argument, whether it - * is a pointer, a prism or a plain value itself. - * - * @remarks - * For pointers, the value is returned by first creating a prism, so it is - * reactive e.g. when used in a `prism`. - * - * @param input - The argument to return a value from. - */ -export const val = < - P extends - | PointerType<$IntentionalAny> - | Prism<$IntentionalAny> - | undefined - | null, ->( - input: P, -): P extends PointerType - ? T - : P extends Prism - ? T - : P extends undefined | null - ? P - : unknown => { - if (isPointer(input)) { - return pointerToPrism(input).getValue() as $IntentionalAny - } else if (isPrism(input)) { - return input.getValue() as $IntentionalAny - } else { - return input as $IntentionalAny - } -} diff --git a/packages/dataverse/src/PointerProxy.ts b/packages/dataverse/src/PointerProxy.ts index 5ba2a1b..8af83d3 100644 --- a/packages/dataverse/src/PointerProxy.ts +++ b/packages/dataverse/src/PointerProxy.ts @@ -1,11 +1,12 @@ -import type {PointerToPrismProvider} from './Atom' -import Atom, {val} from './Atom' +import Atom from './Atom' +import {val} from './val' 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' +import type {PointerToPrismProvider} from './pointerToPrism' /** * Allows creating pointer-prisms where the pointer can be switched out. diff --git a/packages/dataverse/src/atom.typeTest.ts b/packages/dataverse/src/atom.typeTest.ts index d8ac196..447a761 100644 --- a/packages/dataverse/src/atom.typeTest.ts +++ b/packages/dataverse/src/atom.typeTest.ts @@ -1,4 +1,5 @@ -import Atom, {val} from './Atom' +import Atom from './Atom' +import {val} from './val' import {expectType, _any} from './utils/typeTestUtils' ;() => { const p = new Atom<{foo: string; bar: number; optional?: boolean}>(_any) diff --git a/packages/dataverse/src/dataverse.test.ts b/packages/dataverse/src/dataverse.test.ts index b4f7be1..3fc8fc0 100644 --- a/packages/dataverse/src/dataverse.test.ts +++ b/packages/dataverse/src/dataverse.test.ts @@ -1,13 +1,31 @@ -import {Atom, prism, Ticker, val} from '@theatre/dataverse' +import type {Pointer, Prism} from '@theatre/dataverse' +import { + isPointer, + isPrism, + pointerToPrism, + Atom, + getPointerParts, + pointer, + prism, + Ticker, + val, +} from '@theatre/dataverse' +// eslint-disable-next-line no-restricted-imports +import {set as lodashSet} from 'lodash' +import {isPointerToPrismProvider} from './pointerToPrism' -describe(`Dataverse guide`, () => { - // Hi there! I'm writing this test suite as an ever-green guide to dataverse. You should be able - // to read it from top to bottom and understand the concepts of dataverse. +describe(`The exhaustive guide to dataverse`, () => { + // Hi there! I'm writing this test suite as an ever-green and thorough guide to dataverse. You should be able + // to read it from top to bottom and learn pretty much all there is to know about dataverse. + // + // This is not a quick-start guide, so feel free to skip around. // // Since this is a test suite, you should be able to run it in [debug mode](https://jestjs.io/docs/en/troubleshooting) // and inspect the value of variables at any point in the test. + // We recommend you follow this guide using VSCode's [Jest extension](https://marketplace.visualstudio.com/items?itemName=Orta.vscode-jest), + // which allows you to run/debug tests from within the editor and see the values of variables in the editor itself. - describe(`Chapter 0 - Concepts`, () => { + describe(`0 - Concepts`, () => { // There 4 main concepts in dataverse: // - Atoms, hold the state of your application. // - Pointers are a type-safe way to get/set/react-to changes in Atoms. @@ -15,7 +33,7 @@ describe(`Dataverse guide`, () => { // - Tickers are a way to schedule and synchronise computations. // before we dive into the concepts, let me show you how a simple dataverse setup looks like. - test('A simple dataverse setup', () => { + test('0.1 - A simple dataverse setup', () => { // In this setup, we're gonna write a program that renders an image of a sunset, // like this: // | @@ -96,11 +114,11 @@ describe(`Dataverse guide`, () => { }) }) - describe(`Chapter 1 - What is a prism?`, () => { + describe(`1 - What is a prism?`, () => { // A Prism is a way to create a value that depends on other values. // let's start with a simple example: - test(`A pretty useless prism`, async () => { + test(`1.1 - 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) @@ -133,7 +151,7 @@ describe(`Dataverse guide`, () => { }) // prisms can depend on other prisms. let's make a prism that depends on another prism. - test(`prisms can depend on other prisms`, async () => { + test(`1.2 - prisms can depend on other prisms`, async () => { const calculateA = jest.fn(() => 1) const a = prism(calculateA) @@ -276,15 +294,15 @@ describe(`Dataverse guide`, () => { unsubcribeFromAOnStale() }) - test('What about state?', () => { + describe('1.3 - What about state?', () => { // 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: - // let's say we want to create a prism that depends on this value: - let value = 0 + test('1.3.1 - The wrong way to depend on state', () => { + // 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) @@ -305,10 +323,10 @@ describe(`Dataverse guide`, () => { expect(p.getValue()).toBe(1) unsubscribe() - } + }) - // so, the _right_ way to do this, is to use the `source` hook: - { + test('1.3.2 - The _less_ wrong way to depend on state', () => { + let value = 0 // 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(() => () => {}) @@ -350,9 +368,9 @@ describe(`Dataverse guide`, () => { expect(get).toHaveBeenCalledTimes(0) unsubscribe() - } + }) - { + test('1.3.3 - The right way to depend on state', () => { let value = 0 // now let's implement an actual `listen` function. @@ -411,7 +429,7 @@ describe(`Dataverse guide`, () => { // 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, @@ -419,11 +437,858 @@ describe(`Dataverse guide`, () => { // instead, we'll use `Atom`s, which are sources that are already implemented for us. }) - describe(`Chapter 2 - Atoms`, () => { + describe(`2 - Atoms`, () => { // In the final test of the previous chapter, we learned how to create our own sources of state, // and make a prism depend on them, using the `prism.source()` hook. In this chapter, we'll learn // how to use the `Atom` class, which is a source of state that's already implemented for us and comes // with a lot of useful features. - test(`Using Atoms`, () => {}) + test(`2.1 - Using Atoms without prisms`, () => { + const initialState = {foo: 'foo', bar: 0} + + // Let's create an atom with an initial state. + const atom = new Atom(initialState) + + // We can read our atom's state via `atom.get()` which returns an exact reference to its state + expect(atom.get()).toBe(initialState) + + // `atom.set()` will replace the state with a new object. + atom.set({foo: 'foo', bar: 1}) + + expect(atom.get()).not.toBe(initialState) + expect(atom.get()).toEqual({foo: 'foo', bar: 1}) + + // Another way to change the state, with the reducer pattern. + atom.reduce(({foo, bar}) => ({foo, bar: bar + 1})) + expect(atom.get()).toEqual({foo: 'foo', bar: 2}) + + // Having to write `({foo, bar}) => ({foo, bar: bar + 1})` every time we want to change the state + // is a bit annoying. This is one place where pointers come in handy. We'll have a whole chapter + // about pointers later, but for now, let's just say that they're a type-safe way to refer to a sub-prop of our atom's state. + // + // In this example, we're using the `setByPointer()` method to change the `bar` property of the state. + atom.setByPointer((p) => p.bar, 3) + expect(atom.get()).toEqual({foo: 'foo', bar: 3}) + + // Also, note that there is nothing magical about pointers. They're just a type-safe encoding of `['path', 'to', 'property']`. + // Pointers can even point to non-existent properties, and they'll be created when we use them. Typescript will complain if we + // try to use a pointer to a non-existent property, but in the runtime, there will be no errors. + // Let's silence the typescript error for the sake of the test + // @ts-ignore and refer to `baz`, which doesn't actually exist in our state. + atom.setByPointer((p) => p.baz, 'baz') + // Atom will create the `baz` property for us: + expect(atom.get()).toEqual({foo: 'foo', bar: 3, baz: 'baz'}) + + // The pointer can also refer to the whole state, and we can use it to replace the whole state. + atom.setByPointer((p) => p, {foo: 'newfoo', bar: -1}) + expect(atom.get()).toEqual({foo: 'newfoo', bar: -1}) + + // `getByPointer()` is to `get()` what `setByPointer()` is to `set()` + expect(atom.getByPointer((p) => p.bar)).toBe(-1) + + // `reduceByPointer()` is to `setByPointer()` what `reduce()` is to `set()` + atom.reduceByPointer( + (p) => p.bar, + (bar) => bar + 1, + ) + + expect(atom.get()).toEqual({foo: 'newfoo', bar: 0}) + + // we can use external pointers too (which we'll learn how to create in the next Pointers chapter) + const externalPointer = atom.pointer.bar + atom.setByPointer(() => externalPointer, 1) + expect(atom.get()).toEqual({foo: 'newfoo', bar: 1}) + + let internalPointer + // the pointer passed to `setByPointer()` is the same as the one returned by `atom.pointer` + atom.setByPointer((p) => { + internalPointer = p + return p.bar + }, 2) + + expect(internalPointer).toBe(atom.pointer) + + expect(atom.pointer).toBe(atom.pointer) + expect(atom.pointer.bar).toBe(atom.pointer.bar) + + // pointers don't change when the atom's state changes + const oldPointer = atom.pointer.bar + atom.set({foo: 'foo', bar: 10}) + expect(atom.pointer.bar).toBe(oldPointer) + }) + + // Now that we know how to set/get the state of Atoms, let's learn how to use them with prisms. + test(`2.2 - The hard way to use Atoms with prisms`, () => { + // In Chapter 1.3.3, we learned how to create a prism that depends on a changing value, + // but we had to provide our own `listen` and `get` functions. Now let's see how to do the same + // thing with an Atom. + + // Just to learn how things work under the hood, we're still going to use the `prism.source()` hook. + // In the next chapter, we'll learn how to skip that step too. + + // Let's create an atom with an initial state. + const atom = new Atom({foo: 'foo', bar: 0}) + + // The same prism from chapter 1.3.3: + const pr = prism(() => { + return prism.source(listen, get) * 2 + }) + + // now let's define the `listen` and `get` functions that we'll pass to `prism.source()` + function listen(cb: (value: number) => void) { + // `atom._onPointerValueChange()` is a method that we can use to listen to changes in a specific path of the atom's state. + // This is not a public API, so typescript will complain, but we can silence it with `@ts-ignore`. + // _onPointerValueChange() returns an unsubscribe function, so we'll just return that as is. + // @ts-ignore + return atom._onPointerValueChange( + // the path to listen to is just the pointer to the `bar` property of the atom's state. + atom.pointer.bar, + cb, + ) + } + + // The `get` function will just return the value of the `bar` property of the atom's state. + function get() { + return atom.get().bar + } + + // And that's it! We can now use the prism with the atom's state. + + // let's make the prism hot + const staleListener = jest.fn() + const unsubscribe = pr.onStale(staleListener) + expect(pr.isHot).toBe(true) + + // and let's read its value + expect(pr.getValue()).toBe(0) + + // now let's change the value of the source + atom.setByPointer((p) => p.bar, 1) + + // our prism will know that the source has changed, and it'll update its value. + expect(pr.getValue()).toBe(2) + + unsubscribe() + + // and that's how we create a prism that depends on an atom, but that's still + // pretty verbose. Let's see how to do the same thing in a more convenient way. + }) + + test(`2.3 - The easy way to use Atoms with prisms`, () => { + // In the previous chapter, we learned how to create a prism that depends on an atom, + // but we had to provide our own `listen` and `get` functions. Now let's see how to do the same + // thing with an Atom, but in the idiomatic way. We'll use pointers and `val()`. + + // Let's create an atom with an initial state. + const atom = new Atom({foo: 'foo', bar: 0}) + + // Now instead of using `prism.source()`, we'll use val(atom.pointer): + const pr = prism(() => { + // We'll cover pointers and `val()` soon, but for now, just know that `val(atom.pointer.bar)` + // will return the value of the `bar` property of the atom's state. + return val(atom.pointer.bar) * 2 + }) + + // and that's it! + + // let's test that it works as expected + const staleListener = jest.fn() + const unsubscribe = pr.onStale(staleListener) + expect(pr.isHot).toBe(true) + + // and let's read its value + expect(pr.getValue()).toBe(0) + + // now let's change the value of the source + atom.setByPointer((p) => p.bar, 1) + + // this time, our prism will know that the source has changed, and it'll update its value. + expect(pr.getValue()).toBe(2) + + unsubscribe() + }) }) + + describe(`3 - Pointers`, () => { + test('3.0 - Why pointers?', () => { + // We've come across pointers a few times already. + { + // For example, we saw that Atoms provide `set|get|reduceByPointer()` methods: + const atom = new Atom({foo: 'foo', bar: 0}) + atom.setByPointer((p) => p.bar, 1) + // or equivalently: + atom.setByPointer(atom.pointer.bar, 1) + } + + // You might be wondering why not just use dot-delimited paths like in lodash's `set(val, 'path.to.prop', 1)`? + // The answer is that pointers are much easier to type, and they work well with typescript's autocomplete. + // Another benefit is that pointers are always cached, in so that `pointer.bar === pointer.bar` will always be true, + // which means we can use them to attach metadata to a pointer. We'll see how to do that in a bit. + + // Another alternative to pointers is array paths, like in lodash's `set(val, ['path', 'to', 'prop'], 1)`. + // Similar to dot-delimited paths, array paths are also not easy to type, and they don't work well with typescript's autocomplete. + // Another problem is that creating an array path every time we want to access a property is not very efficient. + // The JS engine will have to allocate a new array every time, and then it'll have to iterate over it to find the property. + // Pointers on the other hand, are always cached, so they're allocated only once. + + // We'll learn how to take advantage of these benefits in the next sub-chapters. + }) + test(`3.1 - Pointers in the runtime`, () => { + // Let's have a look at how pointers work in the runtime. + // Pointers refer to a specific nested property of an object. The object is called the "root" of the pointer, + // and the property is called the "path" of the pointer. + + // So for example, if this is our root object: + const root = {foo: 'foo', bar: 0} + // This pointer will refer to the whole object: + const p = pointer({root: root, path: []}) + + // We can inspect the pointer's root and path using `getPointerParts()`: + const parts = getPointerParts(p) + expect(parts.root).toBe(root) + expect(parts.path).toEqual([]) + + // This pointer will refer to the `foo` property of the root object: + const pointerToFoo = p.foo + // p.foo is a pointer to the `foo` property of the root object. its only difference to p is that its path is `['foo']` + expect(getPointerParts(pointerToFoo).path).toEqual(['foo']) + expect(getPointerParts(pointerToFoo).root).toBe(root) + + // subPointers are cached. Calling `p.foo` twice will return the same pointer: + expect(pointerToFoo).toBe(p.foo) + + // we can also manually construct the pointer to foo: + const pointerToFoo2 = pointer({root: root, path: ['foo']}) + expect(getPointerParts(pointerToFoo2).path).toEqual(['foo']) + }) + + test(`3.2 - Pointers in typescript`, () => { + // Pointers become more useful when we properly type them. Let's do that now: + + type Data = {str: string; foo?: {bar?: {baz: number}}} + const root: Data = {str: 'some string'} + + const p = pointer({ + root, + path: [], + }) + + // now typescript will error if we try to access a property that doesn't exist + // @ts-expect-error + p.baz + + // but accessing known properties and nested properties is fine + p.foo + p.foo.bar.baz + + // we don't need to manually type the pointer since pointers are usually provided by Atoms, and those are already typed + const atom = new Atom(root) + + // so this will be fine by typescript: + atom.pointer.foo.bar.baz + + // while this will error + // @ts-ignore + atom.pointer.foo.bar.baz.nonExistentProperty + }) + + test(`3.3 - Creating type-safe utility functions with pointers`, () => { + // Now that we know how to create pointers, let's see how to use them to create utility functions. + + // Let's create a function that will set a property of an object by a pointer, similar to `lodash.set()`. + // The function will take the root object, the pointer, and the new value. + function setByPointer( + root: Root, + getPointer: (ptr: Pointer) => Pointer, + newValue: Value, + ): Root { + // we'll create a pointer to the root object, which would not be efficient + // if `setByPointer` was called many times. We'll see how to improve this in the next sub-chapters. + const rootPointer = pointer({ + root: root, + path: [], + }) as Pointer + // We'll use `getPointerParts()` to get the root and path of the pointer. + const {path} = getPointerParts(getPointer(rootPointer)) + + // @ts-ignore we'll ignore the typescript error because `lodash.set()` is not typed + return lodashSet(root, path, newValue) + } + + // now let's test our utility function + const data = {foo: {bar: 0}} + const newData = setByPointer(data, (p) => p.foo.bar, 1) + expect(newData).toEqual({foo: {bar: 1}}) + + // Compared to `lodash.set()`, our function is type-safe and plays nicely with intellisense and autocomplete. + }) + + test('3.4 - Converting pointers to prisms', () => { + // So, how does the `val()` function work? + // Let's look at its implementation: + const val = (input: any) => { + // if the input is a pointer, we'll convert it to a prism and `getValue()` on it + if (isPointer(input)) { + return pointerToPrism(input).getValue() + // otherwise if it's already a prism, we `getValue()` on it + } else if (isPrism(input)) { + return input.getValue() + } else { + // or otherwise we return the input as is. + return input + } + } + + // So, the interesting part is the `pointerToPrism()` function. How does it + // convert a pointer to a prism? + + // Let's implement it: + function pointerToPrismV1(ptr: Pointer): Prism { + // we'll use `getPointerParts()` to get the root and path of the pointer + const {root} = getPointerParts(ptr) + // Then we check whether the root is an atom + if (!(root instanceof Atom)) { + throw new Error( + `pointerToPrismV1() only supports pointers whose root is an Atom`, + ) + } + + // We'll need to define the listen/get functions as well + + // the listen function will listen to changes on the pointer + const listen = (cb: (newValue: V) => void): (() => void) => { + // @ts-ignore we'll ignore the typescript error because `_onPointerValueChange()` is not a public method + return atom._onPointerValueChange(ptr, cb) + } + + const get = (): V => { + return root.getByPointer(ptr) + } + + // then we'll create a prism that sources from the atom + return prism(() => { + return prism.source(listen, get) + }) + } + + // Now let's test it: + const atom = new Atom({foo: {bar: 0}}) + const ptr = atom.pointer.foo.bar + + const p = pointerToPrismV1(ptr) + expect(p.getValue()).toBe(0) + + // It works! + + // Now let's see how we can improve it. + + // First, we can cache the prism so that we don't create a new prism every time we call `pointerToPrism()`. + // This will improve performance and reduce memory usage. + const cache = new WeakMap, Prism>() + function pointerToPrismV2(ptr: Pointer): Prism { + // we'll check whether the pointer is already in the cache + const cached = cache.get(ptr as any) + if (cached) { + return cached as any + } + + // if not, we'll create a new prism and cache it + const p = pointerToPrismV1(ptr) + cache.set(ptr as any, p) + return p + } + + // Now let's test it: + expect(pointerToPrismV2(ptr)).toBe(pointerToPrismV2(ptr)) // the cache works + expect(pointerToPrismV2(ptr).getValue()).toBe(0) // the prism works + + // The second improvement would be to decouple `pointerToPrism()` from the implementation of `Atom`. + // Namely, `pointerToPrism()` only calls `Atom._onPointerValueChange()` and `Atom.getByPointer()`, which + // are methods that can be implemented on other objects as well. Instead, we can just define an interface + // that requires these methods to be implemented. + // We call this interface `PointerToPrismProvider`: + // For example, Atom implements this interface: + expect(isPointerToPrismProvider(atom)).toBe(true) + + // So our implementation can be updated to: + function pointerToPrismV3(ptr: Pointer): Prism { + const cached = cache.get(ptr as any) + if (cached) { + return cached as any + } + + const {root} = getPointerParts(ptr) + if (!isPointerToPrismProvider(root)) { + throw new Error( + `pointerToPrismV3() only supports pointers whose root implements PointerToPrismProvider`, + ) + } + // one final improvement is to allow the implementation of `PointerToPrismProvider` to create + // the prism, rather than us calling `prism()`, and `prism.source` directly. This will allow + // the implementation to custmoize and possibly optimise how the prism sources its value. + const pr = root.pointerToPrism(ptr) + cache.set(ptr as any, pr) + return pr + } + + // Now let's test it: + expect(pointerToPrismV3(ptr)).toBe(pointerToPrismV3(ptr)) // the cache works + expect(pointerToPrismV3(ptr).getValue()).toBe(0) // the prism works + + // To summarize: + // * we've learned how to implement a `val()` function that works with pointers and prisms. + // * we've learned how to implement a `pointerToPrism()` function that converts a pointer to a prism. + // * we've learned how to improve the performance of `pointerToPrism()` by caching the prisms. + // * we've learned how to decouple `pointerToPrism()` from the implementation of `Atom` by using an interface. + }) + }) + + describe('5 - Tickers', () => { + // A ticker is how dataverse allows you to coordinate the timing of your computations. + // For example, let's say we have a prism whose value changes every 5 milliseconds. And we want to + // render the value of that prism every ~16 milliseconds (60fps). A ticker allows us to do that. + + test('5.1 - Our prism has gone stale...', () => { + // In order to see how tickers fit into the picture, we should first understand how prisms + // go stale. + const atom = new Atom('1') + + const aParsed = prism(() => parseInt(val(atom.pointer))) + + // To illustrate how prisms go stale, we'll create a prism that computes the factorial of the atom's value. + // Since factorial is a computationally expensive operation, we'll only want to compute it when we actually + // need it. + function factorial(n: number): number { + if (n === 0) return 1 + return n * factorial(n - 1) + } + + // we'll want to track how many times our prism actually recalculates its value, so we'll use a jest spy + const recalculateSpy = jest.fn() + const aFactoriel = prism(() => { + recalculateSpy() + return factorial(val(aParsed)) + }) + + // To make it easy to inspect the state of a prism, we'll create a helper function: + const prismState = ( + p: Prism, + ): 'cold' | 'hot:stale' | 'hot:fresh' => { + // @ts-ignore this is a hack to access the internal state of the prism + const internalState = p._state as any + return internalState.hot === false + ? 'cold' + : internalState.handle._isFresh + ? 'hot:fresh' + : 'hot:stale' + } + + // Every prism starts out as 'cold' + expect(prismState(aFactoriel)).toBe('cold') + expect(prismState(aParsed)).toBe('cold') + + { + // as soon as we subscribe to its `onStale` event, it becomes 'hot:fresh' + const unsubscribe = aFactoriel.onStale(jest.fn()) + expect(prismState(aFactoriel)).toBe('hot:fresh') + // since its value is fresh, it should have already called our spy + expect(recalculateSpy).toHaveBeenCalledTimes(1) + recalculateSpy.mockClear() + + // and if we try to get its value, it won't recalculate it + expect(aFactoriel.getValue()).toBe(1) + expect(recalculateSpy).toHaveBeenCalledTimes(0) + + // and if we change the state of our atom, + atom.set('2') + // our prism will go stale: + expect(prismState(aFactoriel)).toBe('hot:stale') + // And so will its dependency: + expect(prismState(aParsed)).toBe('hot:stale') + + // Has the recalculate spy been called? + expect(recalculateSpy).toHaveBeenCalledTimes(0) + // it hasn't. It'll only recalculate when we actually need its value: + expect(aFactoriel.getValue()).toBe(2) + expect(recalculateSpy).toHaveBeenCalledTimes(1) + unsubscribe() + } + + // So far we have established that instead of recalculating their values, prisms simply go stale when their dependencies change. + // and they'll go fresh again when we call `getValue()` on them. + + // tickers are a way to make sure `getValue()` is called at the rate/frequency we want. + const ticker = new Ticker() + const onChange = jest.fn() + // notice how we're using `onChange` only on the prism that we care about, and not on its dependencies. + const unsubscribe = aFactoriel.onChange(ticker, onChange) + // now our prism will go stale every time our atom changes, but it won't recalculate its value until we call `tick()` + atom.set('3') + expect(onChange).toHaveBeenCalledTimes(0) + ticker.tick() + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith(6) + + // We'd usually create a single ticker for an entire page, and call `tick()` on it every frame. + // For example, on a regular web page, we'd use `requestAnimationFrame()` to `tick()` our ticker. + // On an XR session, we'd use `XRSession.requestAnimationFrame()`. + function tickEveryFrame() { + ticker.tick() + requestAnimationFrame(tickEveryFrame) + } + // now we're not gonna call `tickEveryFrame()` because our tests are running on node, but you get the idea. + unsubscribe() + + // Also note that we can have multiple tickers for the same prism: + // `pr.onChange(ticker1, ...); pr.onChange(ticker2, ...);` is perfectly valid. + // And it would be useful if we're using the value of the same prism in multiple places. + }) + + // That's pretty much it for tickers. If you're curious how they work, have a look at `./Ticker.test.ts` + }) + + describe('6 - Prism hooks', () => { + // Prism hooks are inspired by [React hooks](https://reactjs.org/docs/hooks-intro.html) ) and work in a similar way. + describe(`6.1 - prism.source()`, () => { + // We've already come across `prism.source()` in chapter 3. `prism.source()` allow a prism to react to changes in + // some external source (other than other prisms). For example, `Atom.pointerToPrism()` uses `prism.source()` to + // create a prism that reacts to changes in the atom's value. + + // Here is another example. Let's say we want to create a prism that reacts to changes in the value of an HTML input element: + test(`6.1.1 - Example: listening to changes in an input element`, () => { + function prismFromInputElement(input: HTMLInputElement): Prism { + function subscribe(cb: (value: string) => void) { + const listener = () => { + cb(input.value) + } + input.addEventListener('input', listener) + return () => { + input.removeEventListener('input', listener) + } + } + + function get() { + return input.value + } + return prism(() => prism.source(subscribe, get)) + } + + // And this is how we'd use it: + // const el = document.querySelector('input.our-input') + // const prism = prismFromInputElement(el) + // our prism will start listening to changes in the input element as soon as it goes hot, + // and it will stop listening when it goes cold. + }) + + test('6.2.2 - Behavior of `prism.source()`', () => { + // Let's use a few spies to see what's going on under the hood: + const events: Array<'get' | 'subscribe' | 'unsubscribe'> = [] + + const subscribe = () => { + events.push('subscribe') + return () => { + events.push('unsubscribe') + } + } + const get = () => { + events.push('get') + } + + const pr = prism(() => prism.source(subscribe, get)) + expect(events).toEqual([]) + pr.getValue() + // since our prism is cold, it won't subscribe to the source and will only call `get()` + expect(events).toEqual(['get']) + events.length = 0 // reset the events array + + // as we know, cold prisms do not cache their values, so calling `getValue()` again will call `get()` again: + pr.getValue() + expect(events).toEqual(['get']) + + events.length = 0 // reset the events array + + // now let's make our prism hot: + const unsub = pr.onStale(() => {}) + // as soon as the prism goes hot, it will subscribe to the source, and it'll also call `get()` for the first time: + expect(events).toEqual(['subscribe', 'get']) + events.length = 0 // reset the events array + pr.getValue() + expect(events).toEqual([]) + + // now let's make our prism cold again: + unsub() + // as soon as the prism goes cold, it will unsubscribe from the source: + expect(events).toEqual(['unsubscribe']) + }) + }) + test(`6.2 - prism.ref()`, () => { + // Just like React's `useRef()`, `prism.ref()` allows us to create a prism that holds a reference to some value. + // The only difference is that `prism.ref()` requires a key to be passed into it, whlie `useRef()` doesn't. + // This means that we can call `prism.ref()` in any order, and we can call it multiple times with the same key. + const spy = jest.fn() + const atom = new Atom(0) + const pr = prism(() => { + val(atom.pointer) // just to make our prism depend on the atom. we don't care about the value of the atom. + + const elRef = prism.ref('my-key', undefined) + spy(elRef.current) + if (elRef.current === undefined) { + // @ts-ignore - we're just testing the behavior here, we won't create a real dom node + elRef.current = {} + } + }) + // now, what happens if we get the value of our prism? + pr.getValue() + expect(spy).toHaveBeenCalledWith(undefined) + spy.mockClear() + + // and if we get its value again? + pr.getValue() + expect(spy).toHaveBeenCalledWith(undefined) // the ref is still undefined + spy.mockClear() + + // this is because `prism.ref()` only works when the prism is hot, otherwise it'll always return the initial value of the ref. + // So let's make our prism hot: + const unsub = pr.onStale(() => {}) + expect(spy).toHaveBeenCalledWith(undefined) + spy.mockClear() + // now let's make the prism go stale + atom.set(1) + // of course the atom won't recalculate as long as we don't call `getValue()` on it: + expect(spy).not.toHaveBeenCalled() + // so let's call `getValue()` on it: + pr.getValue() + expect(spy).toHaveBeenCalledWith({}) + // and that's how `prism.ref()` works! + unsub() + }) + describe(`6.3 - prism.memo()`, () => { + // `prism.memo()` works just like React's `useMemo()` hook. It's a way to cache the result of a function call. + // The only difference is that `prism.memo()` requires a key to be passed into it, whlie `useMemo()` doesn't. + // This means that we can call `prism.memo()` in any order, and we can call it multiple times with the same key. + + test(`6.3.1 - Example: using prism.memo()`, () => { + const atom = new Atom(1) + function factorial(n: number): number { + if (n === 0) return 1 + return n * factorial(n - 1) + } + + const spy = jest.fn() + + const pr = prism(() => { + // num will be between 0 and 9. This is so we can test what happens when the atom's value changes, but + // the memoized value doesn't change. + const num = val(atom.pointer) + const numMod10 = num % 10 + const value = prism.memo( + // we need a string key to identify the hook. This allows us to call `prism.memo()` in any order, or even conditionally. + 'factorial', + // the function to memoize + () => { + spy() + return factorial(numMod10) + }, + // the dependencies of the function. If any of the dependencies change, the function will be called again. + [numMod10], + ) + + return `number is ${num}, num % 10 is ${numMod10} and its factorial is ${value}` + }) + + // firts let's test our prism when it's cold: + expect(pr.getValue()).toBe( + 'number is 1, num % 10 is 1 and its factorial is 1', + ) + expect(spy).toHaveBeenCalledTimes(1) + + // since cold prisms don't cache their values, calling `getValue()` again will call the factorial function again: + expect(pr.getValue()).toBe( + 'number is 1, num % 10 is 1 and its factorial is 1', + ) + expect(spy).toHaveBeenCalledTimes(2) + + spy.mockClear() + // now let's make our prism hot: + const unsub = pr.onStale(() => {}) + pr.getValue() + expect(spy).toHaveBeenCalledTimes(1) + spy.mockClear() + + // if the memo's dependencies don't change, the memoized function won't be called again: + pr.getValue() + expect(spy).toHaveBeenCalledTimes(0) + + // now let's change the atom's value, but not the factorial value: + atom.set(11) + // our prism _will_ recalculate, but the memoized function won't be called again: + expect(pr.getValue()).toBe( + 'number is 11, num % 10 is 1 and its factorial is 1', + ) + expect(spy).toHaveBeenCalledTimes(0) + + unsub() + // and that's how `prism.memo()` works! + }) + }) + describe(`6.4 - prism.effect() and prism.state()`, () => { + // These are two more hooks that are similar to React's `useEffect()` and `useState()` hooks. + + // `prism.effect()` is similar to React's `useEffect()` hook. It allows us to run side-effects when the prism is calculated. + // Note that prisms are supposed to be "virtually" pure functions. That means they either should not have side-effects (and thus, no calls for `prism.effect()`), + // or their side-effects should clean themselves up when the prism goes cold. + + // `prism.state()` is similar to React's `useState()` hook. It allows us to create a stateful value that is scoped to the prism. + + // We'll defer to React's documentation for [a more detailed explanation of how `useEffect()`](https://reactjs.org/docs/hooks-effect.html) + // and how [`useState()`](https://reactjs.org/docs/hooks-state.html) work. + // But here's a quick example: + test(`6.4.1 - Example: using prism.effect() and prism.state()`, () => { + jest.useFakeTimers() + const events: Array< + 'effectInstalled' | 'intervalCalled' | 'effectCleanedUp' + > = [] + const pr = prism(() => { + const [randomValue, setRandomValue] = prism.state('randomValue', 0) + + // This is only allowed for prisms that are supposed to be hot before their first calculation. + // Otherwise it will log a warning and no effect will run. + prism.effect( + 'update-random-value', + () => { + events.push('effectInstalled') + const interval = setInterval(() => { + events.push('intervalCalled') + setRandomValue(Math.random()) + }, 1000) + return () => { + events.push('effectCleanedUp') + clearInterval(interval) + } + }, + [], + ) + return randomValue + }) + + // let's make our prism hot: + const unsub = pr.onStale(() => {}) + // which should already have called the effect: + expect(events).toEqual(['effectInstalled']) + pr.getValue() + events.length = 0 // clear the events array + // now let's fast-forward the time by 2500ms: + jest.advanceTimersByTime(2500) + // and we should have seen the interval called twice: + expect(events).toEqual(['intervalCalled', 'intervalCalled']) + expect(pr.getValue()).toEqual(expect.any(Number)) + events.length = 0 // clear the events array + + // now let's unsubscribe from the prism: + unsub() + expect(events).toEqual(['effectCleanedUp']) + }) + + test('6.4.2 - A more useful example', () => { + // This prism holds the current mouse position and updates when the mouse moves + const mousePositionPr = 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 + }) + // We can't test this since our test environment doesn't have a mouse, but you get the idea :) + }) + }) + + test(`6.5 - prism.sub()`, () => { + // `prism.sub()` is a shortcut for creating a prism inside another prism. + // It's equivalent to calling `prism.memo(key, () => prism(fn), deps).getValue()`. + // `prism.sub()` is useful when you want to divide your prism into smaller prisms, each of which + // would _only_ recalculate when _certain_ dependencies change. In other words, it's an optimization tool. + + function factorial(num: number): number { + if (num === 0) return 1 + return num * factorial(num - 1) + } + + const events: Array<'foo-calculated' | 'bar-calculated'> = [] + + // example: + const state = new Atom({foo: 0, bar: 0}) + const pr = prism(() => { + const resultOfFoo = prism.sub( + 'foo', + () => { + events.push('foo-calculated') + const foo = val(state.pointer.foo) % 10 + // Note how `prism.sub()` is more powerful than `prism.memo()` because it allows us to use `prism.memo()` and other hooks inside of it: + return prism.memo('factorial', () => factorial(foo), [foo]) + }, + [], + ) + const resultOfBar = prism.sub( + 'bar', + () => { + events.push('bar-calculated') + const bar = val(state.pointer.bar) % 10 + + return prism.memo('factorial', () => factorial(bar), [bar]) + }, + [], + ) + + return `result of foo is ${resultOfFoo}, result of bar is ${resultOfBar}` + }) + + const unsub = pr.onStale(() => {}) + // on the first run, both subs should be calculated: + expect(events).toEqual(['foo-calculated', 'bar-calculated']) + events.length = 0 // clear the events array + + // now if we change the value of `bar`, only `bar` should be recalculated: + state.setByPointer(state.pointer.bar, 2) + pr.getValue() + expect(events).toEqual(['bar-calculated']) + + unsub() + }) + + test(`6.6 - prism.scope()`, () => { + // since prism hooks are keyed (as opposed to React hooks where they're identified by their order), + // it's possible to have multiple hooks with the same key in the same prism. + // To avoid this, we can use `prism.scope()` to create a "scope" for our hooks. + // Example: + const pr = prism(() => { + prism.scope('a', () => { + prism.memo('foo', () => 1, []) + }) + + prism.scope('b', () => { + prism.memo('foo', () => 1, []) + }) + }) + }) + }) + + // What's next? + // At this point we have covered all of `@theatre/dataverse`. + // If you're planning to use Dataverse with React, have a look at [`@theatre/react`](https://github.com/theatre-js/theatre/tree/main/packages/react) + // which provides a React integration for Dataverse as well. }) diff --git a/packages/dataverse/src/index.ts b/packages/dataverse/src/index.ts index 5a933d3..6ee9151 100644 --- a/packages/dataverse/src/index.ts +++ b/packages/dataverse/src/index.ts @@ -4,14 +4,16 @@ * @packageDocumentation */ -export type {PointerToPrismProvider} from './Atom' -export {default as Atom, val, pointerToPrism} from './Atom' +export type {PointerToPrismProvider} from './pointerToPrism' +export {default as Atom} from './Atom' +export {val} from './val' +export {pointerToPrism} from './pointerToPrism' export {isPrism} from './prism/Interface' export type {Prism} from './prism/Interface' export {default as iterateAndCountTicks} from './prism/iterateAndCountTicks' export {default as iterateOver} from './prism/iterateOver' export {default as prism} from './prism/prism' export {default as pointer, getPointerParts, isPointer} from './pointer' -export type {Pointer, PointerType} from './pointer' +export type {Pointer, PointerType, PointerMeta} from './pointer' export {default as Ticker} from './Ticker' export {default as PointerProxy} from './PointerProxy' diff --git a/packages/dataverse/src/integration.test.ts b/packages/dataverse/src/integration.test.ts index 09fa695..f4d51b1 100644 --- a/packages/dataverse/src/integration.test.ts +++ b/packages/dataverse/src/integration.test.ts @@ -1,7 +1,8 @@ /* * @jest-environment jsdom */ -import Atom, {val} from './Atom' +import Atom from './Atom' +import {val} from './val' import prism from './prism/prism' import Ticker from './Ticker' diff --git a/packages/dataverse/src/pointer.ts b/packages/dataverse/src/pointer.ts index ae1c25c..3887ae6 100644 --- a/packages/dataverse/src/pointer.ts +++ b/packages/dataverse/src/pointer.ts @@ -2,7 +2,7 @@ import type {$IntentionalAny} from './types' type PathToProp = Array -type PointerMeta = { +export type PointerMeta = { root: {} path: (string | number)[] } diff --git a/packages/dataverse/src/pointerToPrism.ts b/packages/dataverse/src/pointerToPrism.ts new file mode 100644 index 0000000..baae29c --- /dev/null +++ b/packages/dataverse/src/pointerToPrism.ts @@ -0,0 +1,58 @@ +import type {Prism} from './prism/Interface' +import type {Pointer, PointerType} from './pointer' +import {getPointerMeta} from './pointer' +import type {$IntentionalAny} from './types' + +const identifyPrismWeakMap = new WeakMap<{}, Prism>() + +/** + * Interface for objects that can provide a prism at a certain path. + */ +export interface PointerToPrismProvider { + /** + * @internal + * Future: We could consider using a `Symbol.for("dataverse/PointerToPrismProvider")` as a key here, similar to + * how {@link Iterable} works for `of`. + */ + readonly $$isPointerToPrismProvider: true + /** + * Returns a prism of the value at the provided pointer. + */ + pointerToPrism

(pointer: Pointer

): Prism

+} + +export function isPointerToPrismProvider( + val: unknown, +): val is PointerToPrismProvider { + return ( + typeof val === 'object' && + val !== null && + (val as $IntentionalAny)['$$isPointerToPrismProvider'] === true + ) +} + +/** + * Returns a prism of the value at the provided pointer. Prisms are + * cached per pointer. + * + * @param pointer - The pointer to return the prism at. + */ + +export const pointerToPrism =

>( + pointer: P, +): Prism

? T : void> => { + const meta = getPointerMeta(pointer) + + let prismInstance = identifyPrismWeakMap.get(meta) + if (!prismInstance) { + const root = meta.root + if (!isPointerToPrismProvider(root)) { + throw new Error( + `Cannot run pointerToPrism() on a pointer whose root is not an PointerToPrismProvider`, + ) + } + prismInstance = root.pointerToPrism(pointer as $IntentionalAny) + identifyPrismWeakMap.set(meta, prismInstance) + } + return prismInstance as $IntentionalAny +} diff --git a/packages/dataverse/src/prism/Interface.ts b/packages/dataverse/src/prism/Interface.ts index 4ff5268..ff41890 100644 --- a/packages/dataverse/src/prism/Interface.ts +++ b/packages/dataverse/src/prism/Interface.ts @@ -64,5 +64,5 @@ export interface Prism { * Returns whether `d` is a prism. */ export function isPrism(d: any): d is Prism { - return d && d.isPrism && d.isPrism === true + return !!(d && d.isPrism && d.isPrism === true) } diff --git a/packages/dataverse/src/prism/iterateAndCountTicks.ts b/packages/dataverse/src/prism/iterateAndCountTicks.ts index cecc3a3..f0651e4 100644 --- a/packages/dataverse/src/prism/iterateAndCountTicks.ts +++ b/packages/dataverse/src/prism/iterateAndCountTicks.ts @@ -1,4 +1,4 @@ -import {pointerToPrism} from '../Atom' +import {pointerToPrism} from '../pointerToPrism' import type {Pointer} from '../pointer' import {isPointer} from '../pointer' import type {Prism} from './Interface' diff --git a/packages/dataverse/src/prism/iterateOver.ts b/packages/dataverse/src/prism/iterateOver.ts index 93cf066..ba7b777 100644 --- a/packages/dataverse/src/prism/iterateOver.ts +++ b/packages/dataverse/src/prism/iterateOver.ts @@ -1,4 +1,4 @@ -import {pointerToPrism} from '../Atom' +import {pointerToPrism} from '../pointerToPrism' import type {Pointer} from '../pointer' import {isPointer} from '../pointer' import Ticker from '../Ticker' diff --git a/packages/dataverse/src/prism/prism.test.ts b/packages/dataverse/src/prism/prism.test.ts index 957cdb1..434e41a 100644 --- a/packages/dataverse/src/prism/prism.test.ts +++ b/packages/dataverse/src/prism/prism.test.ts @@ -1,7 +1,8 @@ /* * @jest-environment jsdom */ -import Atom, {val} from '../Atom' +import Atom from '../Atom' +import {val} from '../val' import Ticker from '../Ticker' import type {$FixMe, $IntentionalAny} from '../types' import iterateAndCountTicks from './iterateAndCountTicks' diff --git a/packages/dataverse/src/prism/prism.ts b/packages/dataverse/src/prism/prism.ts index d49692d..a8d6801 100644 --- a/packages/dataverse/src/prism/prism.ts +++ b/packages/dataverse/src/prism/prism.ts @@ -541,6 +541,26 @@ type IMemo = { cachedValue: unknown } +/** + * Just like React's `useRef()`, `prism.ref()` allows us to create a prism that holds a reference to some value. + * The only difference is that `prism.ref()` requires a key to be passed into it, whlie `useRef()` doesn't. + * This means that we can call `prism.ref()` in any order, and we can call it multiple times with the same key. + * @param key - The key for the ref. Should be unique inside of the prism. + * @param initialValue - The initial value for the ref. + * @returns `{current: V}` - The ref object. + * + * Note that the ref object will always return its initial value if the prism is cold. It'll only record + * its current value if the prism is hot (and will forget again if the prism goes cold again). + * + * @example + * ```ts + * const pr = prism(() => { + * const ref1 = prism.ref("ref1", 0) + * console.log(ref1.current) // will print 0, and if the prism is hot, it'll print the current value + * ref1.current++ // changing the current value of the ref + * }) + * ``` + */ function ref(key: string, initialValue: T): IRef { const scope = hookScopeStack.peek() if (!scope) { @@ -585,12 +605,21 @@ function depsHaveChanged( } /** - * Store a value to this {@link prism} stack. + * `prism.memo()` works just like React's `useMemo()` hook. It's a way to cache the result of a function call. + * The only difference is that `prism.memo()` requires a key to be passed into it, whlie `useMemo()` doesn't. + * This means that we can call `prism.memo()` in any order, and we can call it multiple times with the same key. * - * 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 key - The key for the memo. Should be unique inside of the prism + * @param fn - The function to memoize + * @param deps - The dependency array. Provide `[]` if you want to the value to be memoized only once and never re-calculated. + * @returns The result of the function call * - * @param deps - Passing in `undefined` will always cause a recompute + * @example + * ```ts + * const pr = prism(() => { + * const memoizedReturnValueOfExpensiveFn = prism.memo("memo1", expensiveFn, []) + * }) + * ``` */ function memo( key: string, @@ -688,6 +717,16 @@ function scope(key: string, fn: () => T): T { return ret as $IntentionalAny as T } +/** + * Just an alias for `prism.memo(key, () => prism(fn), deps).getValue()`. It creates a new prism, memoizes it, and returns the value. + * `prism.sub()` is useful when you want to divide your prism into smaller prisms, each of which + * would _only_ recalculate when _certain_ dependencies change. In other words, it's an optimization tool. + * + * @param key - The key for the memo. Should be unique inside of the prism + * @param fn - The function to run inside the prism + * @param deps - The dependency array. Provide `[]` if you want to the value to be memoized only once and never re-calculated. + * @returns The value of the inner prism + */ function sub( key: string, fn: () => T, @@ -696,6 +735,9 @@ function sub( return memo(key, () => prism(fn), deps).getValue() } +/** + * @returns true if the current function is running inside a `prism()` call. + */ function inPrism(): boolean { return !!hookScopeStack.peek() } @@ -710,6 +752,34 @@ const possiblePrismToValue =

| unknown>( } } +/** + * `prism.source()` allow a prism to react to changes in some external source (other than other prisms). + * For example, `Atom.pointerToPrism()` uses `prism.source()` to create a prism that reacts to changes in the atom's value. + + * @param subscribe - The prism will call this function as soon as the prism goes hot. This function should return an unsubscribe function function which the prism will call when it goes cold. + * @param getValue - A function that returns the current value of the external source. + * @returns The current value of the source + * + * Example: + * ```ts + * function prismFromInputElement(input: HTMLInputElement): Prism { + * function listen(cb: (value: string) => void) { + * const listener = () => { + * cb(input.value) + * } + * input.addEventListener('input', listener) + * return () => { + * input.removeEventListener('input', listener) + * } + * } + * + * function get() { + * return input.value + * } + * return prism(() => prism.source(listen, get)) + * } + * ``` + */ function source( subscribe: (fn: (val: V) => void) => VoidFn, getValue: () => V, diff --git a/packages/dataverse/src/val.ts b/packages/dataverse/src/val.ts new file mode 100644 index 0000000..1bf175f --- /dev/null +++ b/packages/dataverse/src/val.ts @@ -0,0 +1,41 @@ +import type {Prism} from './prism/Interface' +import {isPrism} from './prism/Interface' +import type {PointerType} from './pointer' +import {isPointer} from './pointer' +import type {$IntentionalAny} from './types' +import {pointerToPrism} from './pointerToPrism' + +/** + * Convenience function that returns a plain value from its argument, whether it + * is a pointer, a prism or a plain value itself. + * + * @remarks + * For pointers, the value is returned by first creating a prism, so it is + * reactive e.g. when used in a `prism`. + * + * @param input - The argument to return a value from. + */ + +export const val = < + P extends + | PointerType<$IntentionalAny> + | Prism<$IntentionalAny> + | undefined + | null, +>( + input: P, +): P extends PointerType + ? T + : P extends Prism + ? T + : P extends undefined | null + ? P + : unknown => { + if (isPointer(input)) { + return pointerToPrism(input).getValue() as $IntentionalAny + } else if (isPrism(input)) { + return input.getValue() as $IntentionalAny + } else { + return input as $IntentionalAny + } +} diff --git a/packages/react/README.md b/packages/react/README.md index 0a2339f..adeacae 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -1,9 +1,80 @@ # @theatre/react -Utilities for using [Theatre.js](https://www.theatrejs.com) with React. +Utilities for using [Theatre.js](https://www.theatrejs.com) or [Dataverse](https://github.com/theatre-js/theatre/tree/main/packages/dataverse) with React. ## Documentation -* API Docs: [docs.theatrejs.com/api/react](https://docs.theatrejs.com/api/react.html) -* Guide: TODO +### `useVal(pointerOrPrism)` +A React hook that returns the value of the given prism or pointer. + +Usage with Dataverse pointers: + +```tsx +import {Atom} from '@theatre/dataverse' +import {useVal} from '@theatre/react' + +const atom = new Atom({foo: 'foo'}) + +function Component() { + const foo = useVal(atom.pointer.foo) + return

{foo}
+} +``` + +Usage with Dataverse prisms: + +```tsx +import {prism} from '@theatre/dataverse' +import {useVal} from '@theatre/react' + +const pr = prism(() => 'some value') + +function Component() { + const value = useVal(pr) + return
{value}
+} +``` + +Usage with Theatre.js pointers: + +```tsx +import {useVal} from '@theatre/react' +import {getProject} from '@theatre/core' + +const obj = getProject('my project').sheet('my sheet').object('my object', {foo: 'default value of props.foo'}) + +function Component() { + const value = useVal(obj.props.foo) + return
obj.foo is {value}
+} +``` + +_Note that `useVal()` is a React hook, so it can only be used inside a React component's render function. `val()` on the other hand, can be used either inside a `prism` (which would be reactive) or anywhere where reactive values are not needed._ + +### `usePrism(fn, deps)` + +Creates a prism out of `fn` and subscribes the element to the value of the created prism. + +```tsx +import {Atom, val, prism} from '@theatre/dataverse' +import {usePrism} from '@theatre/react' + +const state = new Atom({a: 1, b: 1}) + +function Component(props: {which: 'a' | 'b'}) { + const value = usePrism(() => { + prism.isPrism() // true + // note that this function is running inside a prism, so all of prism's + // hooks (prism.memo(), prism.effect(), etc) are available + const num = val(props.which === 'a' ? state.pointer.a : state.pointer.b) + return doExpensiveComputation(num) + }, + // since our prism reads `props.which`, we should include it in the deps array + [props.which] + ) + return
{value}
+} +``` + +> Note that just like `useMemo(..., deps)`, it's necessary to provide a `deps` array to `usePrism()`. \ No newline at end of file diff --git a/theatre/core/src/propTypes/index.ts b/theatre/core/src/propTypes/index.ts index fbb16cc..b82e81b 100644 --- a/theatre/core/src/propTypes/index.ts +++ b/theatre/core/src/propTypes/index.ts @@ -22,8 +22,7 @@ import type {Asset} from '@theatre/shared/utils/assets' // Notes on naming: // As of now, prop types are either `simple` or `composite`. // The compound type is a composite type. So is the upcoming enum type. -// Composite types are not directly sequenceable yet. Their simple sub/ancestor props are. -// We’ll provide a nice UX to manage keyframing of multiple sub-props. +// Composite types are not directly sequenceable yet. Their simple sub/descendent props are. /** * Validates the common options given to all prop types, such as `opts.label`