theatre/packages/dataverse/src/Atom.ts

316 lines
8.3 KiB
TypeScript
Raw Normal View History

2021-06-18 13:05:06 +02:00
import get from 'lodash-es/get'
import isPlainObject from 'lodash-es/isPlainObject'
import last from 'lodash-es/last'
2022-12-01 15:09:53 +01:00
import type {Prism} from './prism/Interface'
import {isPrism} from './prism/Interface'
2021-06-18 13:05:06 +02:00
import type {Pointer, PointerType} from './pointer'
import {getPointerParts} from './pointer'
import {isPointer} from './pointer'
2021-06-18 13:05:06 +02:00
import pointer, {getPointerMeta} from './pointer'
import type {$FixMe, $IntentionalAny} from './types'
import updateDeep from './utils/updateDeep'
2022-12-01 15:09:53 +01:00
import prism from './prism/prism'
2021-06-18 13:05:06 +02:00
type Listener = (newVal: unknown) => void
enum ValueTypes {
Dict,
Array,
Other,
}
2022-01-19 13:06:13 +01:00
/**
2022-12-01 14:41:46 +01:00
* Interface for objects that can provide a prism at a certain path.
2022-01-19 13:06:13 +01:00
*/
2022-12-01 14:28:52 +01:00
export interface IdentityPrismProvider {
2022-01-19 13:06:13 +01:00
/**
* @internal
2022-12-01 14:28:52 +01:00
* Future: We could consider using a `Symbol.for("dataverse/IdentityPrismProvider")` as a key here, similar to
* how {@link Iterable} works for `of`.
2022-01-19 13:06:13 +01:00
*/
2022-12-01 14:28:52 +01:00
readonly $$isIdentityPrismProvider: true
2022-01-19 13:06:13 +01:00
/**
2022-12-01 14:41:46 +01:00
* Returns a prism of the value at the provided path.
2022-01-19 13:06:13 +01:00
*
2022-12-01 14:41:46 +01:00
* @param path - The path to create the prism at.
2022-01-19 13:06:13 +01:00
*/
2022-12-01 14:28:52 +01:00
getIdentityPrism(path: Array<string | number>): Prism<unknown>
2021-06-18 13:05:06 +02:00
}
const getTypeOfValue = (v: unknown): ValueTypes => {
if (Array.isArray(v)) return ValueTypes.Array
if (isPlainObject(v)) return ValueTypes.Dict
return ValueTypes.Other
}
const getKeyOfValue = (
v: unknown,
key: string | number,
vType: ValueTypes = getTypeOfValue(v),
): unknown => {
if (vType === ValueTypes.Dict && typeof key === 'string') {
return (v as $IntentionalAny)[key]
} else if (vType === ValueTypes.Array && isValidArrayIndex(key)) {
return (v as $IntentionalAny)[key]
} else {
return undefined
}
}
const isValidArrayIndex = (key: string | number): boolean => {
const inNumber = typeof key === 'number' ? key : parseInt(key, 10)
return (
!isNaN(inNumber) &&
inNumber >= 0 &&
inNumber < Infinity &&
(inNumber | 0) === inNumber
)
}
class Scope {
children: Map<string | number, Scope> = new Map()
identityChangeListeners: Set<Listener> = new Set()
constructor(
readonly _parent: undefined | Scope,
readonly _path: (string | number)[],
) {}
addIdentityChangeListener(cb: Listener) {
this.identityChangeListeners.add(cb)
}
removeIdentityChangeListener(cb: Listener) {
this.identityChangeListeners.delete(cb)
this._checkForGC()
}
removeChild(key: string | number) {
this.children.delete(key)
this._checkForGC()
}
getChild(key: string | number) {
return this.children.get(key)
}
getOrCreateChild(key: string | number) {
let child = this.children.get(key)
if (!child) {
child = child = new Scope(this, this._path.concat([key]))
this.children.set(key, child)
}
return child
}
_checkForGC() {
if (this.identityChangeListeners.size > 0) return
if (this.children.size > 0) return
if (this._parent) {
this._parent.removeChild(last(this._path) as string | number)
}
}
}
2022-01-19 13:06:13 +01:00
/**
* Wraps an object whose (sub)properties can be individually tracked.
*/
2022-12-01 15:37:19 +01:00
export default class Atom<State> implements IdentityPrismProvider {
2021-06-18 13:05:06 +02:00
private _currentState: State
2022-01-19 13:06:13 +01:00
/**
* @internal
*/
2022-12-01 14:28:52 +01:00
readonly $$isIdentityPrismProvider = true
2021-06-18 13:05:06 +02:00
private readonly _rootScope: Scope
2022-01-19 13:06:13 +01:00
/**
* Convenience property that gives you a pointer to the root of the atom.
*
* @remarks
* Equivalent to `pointer({ root: thisAtom, path: [] })`.
*/
2021-06-18 13:05:06 +02:00
readonly pointer: Pointer<State>
2022-12-01 15:37:19 +01:00
readonly prism: Prism<State> = this.getIdentityPrism([]) as $IntentionalAny
2021-06-18 13:05:06 +02:00
constructor(initialState: State) {
this._currentState = initialState
this._rootScope = new Scope(undefined, [])
this.pointer = pointer({root: this as $FixMe, path: []})
}
2022-01-19 13:06:13 +01:00
/**
* Sets the state of the atom.
*
2022-02-23 22:53:39 +01:00
* @param newState - The new state of the atom.
2022-01-19 13:06:13 +01:00
*/
2022-12-01 15:37:19 +01:00
set(newState: State) {
2021-06-18 13:05:06 +02:00
const oldState = this._currentState
this._currentState = newState
this._checkUpdates(this._rootScope, oldState, newState)
}
2023-01-15 22:04:27 +01:00
get(): State {
2023-01-15 12:42:28 +01:00
return this._currentState
2022-12-01 15:37:19 +01:00
}
2023-01-15 22:04:27 +01:00
getByPointer<S>(fn: (p: Pointer<State>) => Pointer<S>): S {
const pointer = fn(this.pointer)
const path = getPointerParts(pointer).path
return this._getIn(path) as S
}
2022-01-19 13:06:13 +01:00
/**
* Gets the state of the atom at `path`.
*/
2023-01-15 22:04:27 +01:00
private _getIn(path: (string | number)[]): unknown {
2023-01-15 12:42:28 +01:00
return path.length === 0 ? this.get() : get(this.get(), path)
2021-06-18 13:05:06 +02:00
}
reduce(fn: (state: State) => State) {
this.set(fn(this.get()))
2021-06-18 13:05:06 +02:00
}
reduceByPointer<S>(
fn: (p: Pointer<State>) => Pointer<S>,
reducer: (s: S) => S,
) {
const pointer = fn(this.pointer)
const path = getPointerParts(pointer).path
const newState = updateDeep(this.get(), path, reducer)
this.set(newState)
}
setByPointer<S>(fn: (p: Pointer<State>) => Pointer<S>, val: S) {
this.reduceByPointer(fn, () => val)
2021-06-18 13:05:06 +02:00
}
private _checkUpdates(scope: Scope, oldState: unknown, newState: unknown) {
if (oldState === newState) return
for (const cb of scope.identityChangeListeners) {
cb(newState)
}
2021-06-18 13:05:06 +02:00
if (scope.children.size === 0) return
// @todo we can probably skip checking value types
2021-06-18 13:05:06 +02:00
const oldValueType = getTypeOfValue(oldState)
const newValueType = getTypeOfValue(newState)
if (oldValueType === ValueTypes.Other && oldValueType === newValueType)
return
for (const [childKey, childScope] of scope.children) {
2021-06-18 13:05:06 +02:00
const oldChildVal = getKeyOfValue(oldState, childKey, oldValueType)
const newChildVal = getKeyOfValue(newState, childKey, newValueType)
this._checkUpdates(childScope, oldChildVal, newChildVal)
}
2021-06-18 13:05:06 +02:00
}
private _getOrCreateScopeForPath(path: (string | number)[]): Scope {
let curScope = this._rootScope
for (const pathEl of path) {
curScope = curScope.getOrCreateChild(pathEl)
}
return curScope
}
private _onPathValueChange = (
path: (string | number)[],
cb: (v: unknown) => void,
) => {
const scope = this._getOrCreateScopeForPath(path)
scope.identityChangeListeners.add(cb)
const untap = () => {
scope.identityChangeListeners.delete(cb)
}
return untap
}
2022-01-19 13:06:13 +01:00
/**
2022-12-01 14:41:46 +01:00
* Returns a new prism of the value at the provided path.
2022-01-19 13:06:13 +01:00
*
2022-12-01 14:41:46 +01:00
* @param path - The path to create the prism at.
2022-01-19 13:06:13 +01:00
*/
2022-12-01 14:28:52 +01:00
getIdentityPrism(path: Array<string | number>): Prism<unknown> {
const subscribe = (listener: (val: unknown) => void) =>
this._onPathValueChange(path, listener)
2023-01-15 22:04:27 +01:00
const getValue = () => this._getIn(path)
return prism(() => {
2022-12-01 12:58:59 +01:00
return prism.source(subscribe, getValue)
})
2021-06-18 13:05:06 +02:00
}
}
2022-12-01 14:29:15 +01:00
const identifyPrismWeakMap = new WeakMap<{}, Prism<unknown>>()
2021-06-18 13:05:06 +02:00
2022-01-19 13:06:13 +01:00
/**
2022-12-01 14:41:46 +01:00
* Returns a prism of the value at the provided pointer. Prisms are
2022-01-19 13:06:13 +01:00
* cached per pointer.
*
2022-12-01 14:41:46 +01:00
* @param pointer - The pointer to return the prism at.
2022-01-19 13:06:13 +01:00
*/
2022-12-01 14:26:17 +01:00
export const pointerToPrism = <P extends PointerType<$IntentionalAny>>(
2021-06-18 13:05:06 +02:00
pointer: P,
2022-12-01 14:20:50 +01:00
): Prism<P extends PointerType<infer T> ? T : void> => {
2021-06-18 13:05:06 +02:00
const meta = getPointerMeta(pointer)
2022-12-01 14:41:46 +01:00
let prismInstance = identifyPrismWeakMap.get(meta)
if (!prismInstance) {
2021-06-18 13:05:06 +02:00
const root = meta.root
2022-12-01 14:28:52 +01:00
if (!isIdentityPrismProvider(root)) {
2021-06-18 13:05:06 +02:00
throw new Error(
2022-12-01 14:28:52 +01:00
`Cannot run pointerToPrism() on a pointer whose root is not an IdentityPrismProvider`,
2021-06-18 13:05:06 +02:00
)
}
const {path} = meta
2022-12-01 14:41:46 +01:00
prismInstance = root.getIdentityPrism(path)
identifyPrismWeakMap.set(meta, prismInstance)
2021-06-18 13:05:06 +02:00
}
2022-12-01 14:41:46 +01:00
return prismInstance as $IntentionalAny
2021-06-18 13:05:06 +02:00
}
2022-12-01 14:28:52 +01:00
function isIdentityPrismProvider(val: unknown): val is IdentityPrismProvider {
2021-06-18 13:05:06 +02:00
return (
typeof val === 'object' &&
val !== null &&
2022-12-01 14:28:52 +01:00
(val as $IntentionalAny)['$$isIdentityPrismProvider'] === true
2021-06-18 13:05:06 +02:00
)
}
2022-01-19 13:06:13 +01:00
/**
* Convenience function that returns a plain value from its argument, whether it
2022-12-01 14:41:46 +01:00
* is a pointer, a prism or a plain value itself.
2022-01-19 13:06:13 +01:00
*
* @remarks
2022-12-01 14:41:46 +01:00
* For pointers, the value is returned by first creating a prism, so it is
2022-01-19 13:06:13 +01:00
* reactive e.g. when used in a `prism`.
*
* @param input - The argument to return a value from.
2022-01-19 13:06:13 +01:00
*/
export const val = <
P extends
| PointerType<$IntentionalAny>
2022-12-01 14:20:50 +01:00
| Prism<$IntentionalAny>
| undefined
| null,
>(
input: P,
2021-06-18 13:05:06 +02:00
): P extends PointerType<infer T>
? T
2022-12-01 14:20:50 +01:00
: P extends Prism<infer T>
2021-06-18 13:05:06 +02:00
? T
2021-07-02 20:47:25 +02:00
: P extends undefined | null
? P
2021-06-18 13:05:06 +02:00
: unknown => {
if (isPointer(input)) {
2022-12-01 14:26:17 +01:00
return pointerToPrism(input).getValue() as $IntentionalAny
2022-12-01 14:22:49 +01:00
} else if (isPrism(input)) {
return input.getValue() as $IntentionalAny
2021-06-18 13:05:06 +02:00
} else {
return input as $IntentionalAny
2021-06-18 13:05:06 +02:00
}
}