Add tests and docs to dataverse
This commit is contained in:
parent
bab95ddad9
commit
ef279eddff
17 changed files with 1225 additions and 147 deletions
|
@ -6,4 +6,4 @@ Dataverse is currently an internal library. It is used within Theatre.js, but it
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
> TODO: Write new docs. Enough has changed between 0.5 and 0.6, that the 0.5 docs are not useful anymore.
|
* [The exhaustive guide to dataverse](./src/dataverse.test.ts)
|
|
@ -2,14 +2,14 @@ import get from 'lodash-es/get'
|
||||||
import isPlainObject from 'lodash-es/isPlainObject'
|
import isPlainObject from 'lodash-es/isPlainObject'
|
||||||
import last from 'lodash-es/last'
|
import last from 'lodash-es/last'
|
||||||
import type {Prism} from './prism/Interface'
|
import type {Prism} from './prism/Interface'
|
||||||
import {isPrism} from './prism/Interface'
|
import type {Pointer} from './pointer'
|
||||||
import type {Pointer, PointerType} from './pointer'
|
|
||||||
import {getPointerParts} from './pointer'
|
import {getPointerParts} from './pointer'
|
||||||
import {isPointer} from './pointer'
|
import {isPointer} from './pointer'
|
||||||
import pointer, {getPointerMeta} from './pointer'
|
import pointer from './pointer'
|
||||||
import type {$FixMe, $IntentionalAny} from './types'
|
import type {$FixMe, $IntentionalAny} from './types'
|
||||||
import updateDeep from './utils/updateDeep'
|
import updateDeep from './utils/updateDeep'
|
||||||
import prism from './prism/prism'
|
import prism from './prism/prism'
|
||||||
|
import type {PointerToPrismProvider} from './pointerToPrism'
|
||||||
|
|
||||||
type Listener = (newVal: unknown) => void
|
type Listener = (newVal: unknown) => void
|
||||||
|
|
||||||
|
@ -19,22 +19,6 @@ enum ValueTypes {
|
||||||
Other,
|
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<P>(pointer: Pointer<P>): Prism<P>
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTypeOfValue = (v: unknown): ValueTypes => {
|
const getTypeOfValue = (v: unknown): ValueTypes => {
|
||||||
if (Array.isArray(v)) return ValueTypes.Array
|
if (Array.isArray(v)) return ValueTypes.Array
|
||||||
if (isPlainObject(v)) return ValueTypes.Dict
|
if (isPlainObject(v)) return ValueTypes.Dict
|
||||||
|
@ -153,8 +137,25 @@ export default class Atom<State> implements PointerToPrismProvider {
|
||||||
return this._currentState
|
return this._currentState
|
||||||
}
|
}
|
||||||
|
|
||||||
getByPointer<S>(fn: (p: Pointer<State>) => Pointer<S>): 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<S>(
|
||||||
|
pointerOrFn: Pointer<S> | ((p: Pointer<State>) => Pointer<S>),
|
||||||
|
): S {
|
||||||
|
const pointer = isPointer(pointerOrFn)
|
||||||
|
? pointerOrFn
|
||||||
|
: (pointerOrFn as $IntentionalAny)(this.pointer)
|
||||||
|
|
||||||
const path = getPointerParts(pointer).path
|
const path = getPointerParts(pointer).path
|
||||||
return this._getIn(path) as S
|
return this._getIn(path) as S
|
||||||
}
|
}
|
||||||
|
@ -170,18 +171,48 @@ export default class Atom<State> implements PointerToPrismProvider {
|
||||||
this.set(fn(this.get()))
|
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<S>(
|
reduceByPointer<S>(
|
||||||
fn: (p: Pointer<State>) => Pointer<S>,
|
pointerOrFn: Pointer<S> | ((p: Pointer<State>) => Pointer<S>),
|
||||||
reducer: (s: S) => S,
|
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 path = getPointerParts(pointer).path
|
||||||
const newState = updateDeep(this.get(), path, reducer)
|
const newState = updateDeep(this.get(), path, reducer)
|
||||||
this.set(newState)
|
this.set(newState)
|
||||||
}
|
}
|
||||||
|
|
||||||
setByPointer<S>(fn: (p: Pointer<State>) => Pointer<S>, 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<S>(
|
||||||
|
pointerOrFn: Pointer<S> | ((p: Pointer<State>) => Pointer<S>),
|
||||||
|
val: S,
|
||||||
|
) {
|
||||||
|
this.reduceByPointer(pointerOrFn, () => val)
|
||||||
}
|
}
|
||||||
|
|
||||||
private _checkUpdates(scope: Scope, oldState: unknown, newState: unknown) {
|
private _checkUpdates(scope: Scope, oldState: unknown, newState: unknown) {
|
||||||
|
@ -214,27 +245,33 @@ export default class Atom<State> implements PointerToPrismProvider {
|
||||||
return curScope
|
return curScope
|
||||||
}
|
}
|
||||||
|
|
||||||
private _onPathValueChange = (
|
private _onPointerValueChange = <P>(
|
||||||
path: (string | number)[],
|
pointer: Pointer<P>,
|
||||||
cb: (v: unknown) => void,
|
cb: (v: P) => void,
|
||||||
) => {
|
): (() => void) => {
|
||||||
|
const {path} = getPointerParts(pointer)
|
||||||
const scope = this._getOrCreateScopeForPath(path)
|
const scope = this._getOrCreateScopeForPath(path)
|
||||||
scope.identityChangeListeners.add(cb)
|
scope.identityChangeListeners.add(cb as $IntentionalAny)
|
||||||
const untap = () => {
|
const unsubscribe = () => {
|
||||||
scope.identityChangeListeners.delete(cb)
|
scope.identityChangeListeners.delete(cb as $IntentionalAny)
|
||||||
}
|
}
|
||||||
return untap
|
return unsubscribe
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a new prism of the value at the provided path.
|
* 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<P>(pointer: Pointer<P>): Prism<P> {
|
pointerToPrism<P>(pointer: Pointer<P>): Prism<P> {
|
||||||
const {path} = getPointerParts(pointer)
|
const {path} = getPointerParts(pointer)
|
||||||
const subscribe = (listener: (val: unknown) => void) =>
|
const subscribe = (listener: (val: unknown) => void) =>
|
||||||
this._onPathValueChange(path, listener)
|
this._onPointerValueChange(pointer, listener)
|
||||||
|
|
||||||
const getValue = () => this._getIn(path)
|
const getValue = () => this._getIn(path)
|
||||||
|
|
||||||
|
@ -243,72 +280,3 @@ export default class Atom<State> implements PointerToPrismProvider {
|
||||||
}) as Prism<P>
|
}) as Prism<P>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const identifyPrismWeakMap = new WeakMap<{}, Prism<unknown>>()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 = <P extends PointerType<$IntentionalAny>>(
|
|
||||||
pointer: P,
|
|
||||||
): Prism<P extends PointerType<infer T> ? 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<infer T>
|
|
||||||
? T
|
|
||||||
: P extends Prism<infer T>
|
|
||||||
? 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import type {PointerToPrismProvider} from './Atom'
|
import Atom from './Atom'
|
||||||
import Atom, {val} from './Atom'
|
import {val} from './val'
|
||||||
import type {Pointer} from './pointer'
|
import type {Pointer} from './pointer'
|
||||||
import {getPointerMeta} from './pointer'
|
import {getPointerMeta} from './pointer'
|
||||||
import pointer from './pointer'
|
import pointer from './pointer'
|
||||||
import type {$FixMe, $IntentionalAny} from './types'
|
import type {$FixMe, $IntentionalAny} from './types'
|
||||||
import prism from './prism/prism'
|
import prism from './prism/prism'
|
||||||
import type {Prism} from './prism/Interface'
|
import type {Prism} from './prism/Interface'
|
||||||
|
import type {PointerToPrismProvider} from './pointerToPrism'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allows creating pointer-prisms where the pointer can be switched out.
|
* Allows creating pointer-prisms where the pointer can be switched out.
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import Atom, {val} from './Atom'
|
import Atom from './Atom'
|
||||||
|
import {val} from './val'
|
||||||
import {expectType, _any} from './utils/typeTestUtils'
|
import {expectType, _any} from './utils/typeTestUtils'
|
||||||
;() => {
|
;() => {
|
||||||
const p = new Atom<{foo: string; bar: number; optional?: boolean}>(_any)
|
const p = new Atom<{foo: string; bar: number; optional?: boolean}>(_any)
|
||||||
|
|
|
@ -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`, () => {
|
describe(`The exhaustive guide to dataverse`, () => {
|
||||||
// Hi there! I'm writing this test suite as an ever-green guide to dataverse. You should be able
|
// 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 understand the concepts of dataverse.
|
// 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)
|
// 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.
|
// 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:
|
// There 4 main concepts in dataverse:
|
||||||
// - Atoms, hold the state of your application.
|
// - Atoms, hold the state of your application.
|
||||||
// - Pointers are a type-safe way to get/set/react-to changes in Atoms.
|
// - 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.
|
// - 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.
|
// 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,
|
// In this setup, we're gonna write a program that renders an image of a sunset,
|
||||||
// like this:
|
// 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.
|
// A Prism is a way to create a value that depends on other values.
|
||||||
|
|
||||||
// let's start with a simple example:
|
// 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
|
// 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)
|
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.
|
// 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 calculateA = jest.fn(() => 1)
|
||||||
const a = prism(calculateA)
|
const a = prism(calculateA)
|
||||||
|
|
||||||
|
@ -276,15 +294,15 @@ describe(`Dataverse guide`, () => {
|
||||||
unsubcribeFromAOnStale()
|
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.
|
// 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?
|
// 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:
|
// 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:
|
test('1.3.1 - The wrong way to depend on state', () => {
|
||||||
let value = 0
|
// 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
|
// the _wrong_ way to do this, is to create a prism that directly reads this value
|
||||||
const p = prism(() => value)
|
const p = prism(() => value)
|
||||||
|
|
||||||
|
@ -305,10 +323,10 @@ describe(`Dataverse guide`, () => {
|
||||||
expect(p.getValue()).toBe(1)
|
expect(p.getValue()).toBe(1)
|
||||||
|
|
||||||
unsubscribe()
|
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.
|
// 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.
|
// let's skip the `listen` function for now, and just focus on the `getValue` function.
|
||||||
const listen = jest.fn(() => () => {})
|
const listen = jest.fn(() => () => {})
|
||||||
|
@ -350,9 +368,9 @@ describe(`Dataverse guide`, () => {
|
||||||
expect(get).toHaveBeenCalledTimes(0)
|
expect(get).toHaveBeenCalledTimes(0)
|
||||||
|
|
||||||
unsubscribe()
|
unsubscribe()
|
||||||
}
|
})
|
||||||
|
|
||||||
{
|
test('1.3.3 - The right way to depend on state', () => {
|
||||||
let value = 0
|
let value = 0
|
||||||
// now let's implement an actual `listen` function.
|
// 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.
|
// and that's how we create a prism that depends on a changing value.
|
||||||
|
|
||||||
unsubscribe()
|
unsubscribe()
|
||||||
}
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// in practice, we'll almost never need to use the `source` hook directly,
|
// 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.
|
// 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,
|
// 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
|
// 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
|
// 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.
|
// 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<Data>({
|
||||||
|
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, Value>(
|
||||||
|
root: Root,
|
||||||
|
getPointer: (ptr: Pointer<Root>) => Pointer<Value>,
|
||||||
|
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<Root>
|
||||||
|
// 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<V>(ptr: Pointer<V>): Prism<V> {
|
||||||
|
// 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<Pointer<any>, Prism<unknown>>()
|
||||||
|
function pointerToPrismV2<V>(ptr: Pointer<V>): Prism<V> {
|
||||||
|
// 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<V>(ptr: Pointer<V>): Prism<V> {
|
||||||
|
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<any>,
|
||||||
|
): '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<string> {
|
||||||
|
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<undefined | HTMLElement>('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.
|
||||||
})
|
})
|
||||||
|
|
|
@ -4,14 +4,16 @@
|
||||||
* @packageDocumentation
|
* @packageDocumentation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type {PointerToPrismProvider} from './Atom'
|
export type {PointerToPrismProvider} from './pointerToPrism'
|
||||||
export {default as Atom, val, pointerToPrism} from './Atom'
|
export {default as Atom} from './Atom'
|
||||||
|
export {val} from './val'
|
||||||
|
export {pointerToPrism} from './pointerToPrism'
|
||||||
export {isPrism} from './prism/Interface'
|
export {isPrism} from './prism/Interface'
|
||||||
export type {Prism} from './prism/Interface'
|
export type {Prism} from './prism/Interface'
|
||||||
export {default as iterateAndCountTicks} from './prism/iterateAndCountTicks'
|
export {default as iterateAndCountTicks} from './prism/iterateAndCountTicks'
|
||||||
export {default as iterateOver} from './prism/iterateOver'
|
export {default as iterateOver} from './prism/iterateOver'
|
||||||
export {default as prism} from './prism/prism'
|
export {default as prism} from './prism/prism'
|
||||||
export {default as pointer, getPointerParts, isPointer} from './pointer'
|
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 Ticker} from './Ticker'
|
||||||
export {default as PointerProxy} from './PointerProxy'
|
export {default as PointerProxy} from './PointerProxy'
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
/*
|
/*
|
||||||
* @jest-environment jsdom
|
* @jest-environment jsdom
|
||||||
*/
|
*/
|
||||||
import Atom, {val} from './Atom'
|
import Atom from './Atom'
|
||||||
|
import {val} from './val'
|
||||||
import prism from './prism/prism'
|
import prism from './prism/prism'
|
||||||
import Ticker from './Ticker'
|
import Ticker from './Ticker'
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@ import type {$IntentionalAny} from './types'
|
||||||
|
|
||||||
type PathToProp = Array<string | number>
|
type PathToProp = Array<string | number>
|
||||||
|
|
||||||
type PointerMeta = {
|
export type PointerMeta = {
|
||||||
root: {}
|
root: {}
|
||||||
path: (string | number)[]
|
path: (string | number)[]
|
||||||
}
|
}
|
||||||
|
|
58
packages/dataverse/src/pointerToPrism.ts
Normal file
58
packages/dataverse/src/pointerToPrism.ts
Normal file
|
@ -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<unknown>>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<P>(pointer: Pointer<P>): Prism<P>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = <P extends PointerType<$IntentionalAny>>(
|
||||||
|
pointer: P,
|
||||||
|
): Prism<P extends PointerType<infer T> ? 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
|
||||||
|
}
|
|
@ -64,5 +64,5 @@ export interface Prism<V> {
|
||||||
* Returns whether `d` is a prism.
|
* Returns whether `d` is a prism.
|
||||||
*/
|
*/
|
||||||
export function isPrism(d: any): d is Prism<unknown> {
|
export function isPrism(d: any): d is Prism<unknown> {
|
||||||
return d && d.isPrism && d.isPrism === true
|
return !!(d && d.isPrism && d.isPrism === true)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import {pointerToPrism} from '../Atom'
|
import {pointerToPrism} from '../pointerToPrism'
|
||||||
import type {Pointer} from '../pointer'
|
import type {Pointer} from '../pointer'
|
||||||
import {isPointer} from '../pointer'
|
import {isPointer} from '../pointer'
|
||||||
import type {Prism} from './Interface'
|
import type {Prism} from './Interface'
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import {pointerToPrism} from '../Atom'
|
import {pointerToPrism} from '../pointerToPrism'
|
||||||
import type {Pointer} from '../pointer'
|
import type {Pointer} from '../pointer'
|
||||||
import {isPointer} from '../pointer'
|
import {isPointer} from '../pointer'
|
||||||
import Ticker from '../Ticker'
|
import Ticker from '../Ticker'
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
/*
|
/*
|
||||||
* @jest-environment jsdom
|
* @jest-environment jsdom
|
||||||
*/
|
*/
|
||||||
import Atom, {val} from '../Atom'
|
import Atom from '../Atom'
|
||||||
|
import {val} from '../val'
|
||||||
import Ticker from '../Ticker'
|
import Ticker from '../Ticker'
|
||||||
import type {$FixMe, $IntentionalAny} from '../types'
|
import type {$FixMe, $IntentionalAny} from '../types'
|
||||||
import iterateAndCountTicks from './iterateAndCountTicks'
|
import iterateAndCountTicks from './iterateAndCountTicks'
|
||||||
|
|
|
@ -541,6 +541,26 @@ type IMemo = {
|
||||||
cachedValue: unknown
|
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<T>(key: string, initialValue: T): IRef<T> {
|
function ref<T>(key: string, initialValue: T): IRef<T> {
|
||||||
const scope = hookScopeStack.peek()
|
const scope = hookScopeStack.peek()
|
||||||
if (!scope) {
|
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
|
* @param key - The key for the memo. Should be unique inside of the prism
|
||||||
* we can call `prism.memo` in any order, and conditionally.
|
* @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<T>(
|
function memo<T>(
|
||||||
key: string,
|
key: string,
|
||||||
|
@ -688,6 +717,16 @@ function scope<T>(key: string, fn: () => T): T {
|
||||||
return ret as $IntentionalAny as 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<T>(
|
function sub<T>(
|
||||||
key: string,
|
key: string,
|
||||||
fn: () => T,
|
fn: () => T,
|
||||||
|
@ -696,6 +735,9 @@ function sub<T>(
|
||||||
return memo(key, () => prism(fn), deps).getValue()
|
return memo(key, () => prism(fn), deps).getValue()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns true if the current function is running inside a `prism()` call.
|
||||||
|
*/
|
||||||
function inPrism(): boolean {
|
function inPrism(): boolean {
|
||||||
return !!hookScopeStack.peek()
|
return !!hookScopeStack.peek()
|
||||||
}
|
}
|
||||||
|
@ -710,6 +752,34 @@ const possiblePrismToValue = <P extends Prism<$IntentionalAny> | 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<string> {
|
||||||
|
* 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<V>(
|
function source<V>(
|
||||||
subscribe: (fn: (val: V) => void) => VoidFn,
|
subscribe: (fn: (val: V) => void) => VoidFn,
|
||||||
getValue: () => V,
|
getValue: () => V,
|
||||||
|
|
41
packages/dataverse/src/val.ts
Normal file
41
packages/dataverse/src/val.ts
Normal file
|
@ -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<infer T>
|
||||||
|
? T
|
||||||
|
: P extends Prism<infer T>
|
||||||
|
? 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,80 @@
|
||||||
# @theatre/react
|
# @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
|
## Documentation
|
||||||
|
|
||||||
* API Docs: [docs.theatrejs.com/api/react](https://docs.theatrejs.com/api/react.html)
|
### `useVal(pointerOrPrism)`
|
||||||
* Guide: TODO
|
|
||||||
|
|
||||||
|
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 <div>{foo}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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 <div>{value}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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 <div>obj.foo is {value}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
_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 <div>{value}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note that just like `useMemo(..., deps)`, it's necessary to provide a `deps` array to `usePrism()`.
|
|
@ -22,8 +22,7 @@ import type {Asset} from '@theatre/shared/utils/assets'
|
||||||
// Notes on naming:
|
// Notes on naming:
|
||||||
// As of now, prop types are either `simple` or `composite`.
|
// As of now, prop types are either `simple` or `composite`.
|
||||||
// The compound type is a composite type. So is the upcoming enum type.
|
// 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.
|
// Composite types are not directly sequenceable yet. Their simple sub/descendent props are.
|
||||||
// We’ll provide a nice UX to manage keyframing of multiple sub-props.
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates the common options given to all prop types, such as `opts.label`
|
* Validates the common options given to all prop types, such as `opts.label`
|
||||||
|
|
Loading…
Reference in a new issue