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'
|
2023-01-21 21:57:28 +01:00
|
|
|
import type {Pointer} from './pointer'
|
2022-12-01 18:54:14 +01:00
|
|
|
import {getPointerParts} from './pointer'
|
2021-06-27 13:37:10 +02:00
|
|
|
import {isPointer} from './pointer'
|
2023-01-21 21:57:28 +01:00
|
|
|
import pointer from './pointer'
|
2021-06-18 13:05:06 +02:00
|
|
|
import type {$FixMe, $IntentionalAny} from './types'
|
|
|
|
import updateDeep from './utils/updateDeep'
|
2022-12-01 15:09:53 +01:00
|
|
|
import prism from './prism/prism'
|
2023-01-21 21:57:28 +01:00
|
|
|
import type {PointerToPrismProvider} from './pointerToPrism'
|
2021-06-18 13:05:06 +02:00
|
|
|
|
|
|
|
type Listener = (newVal: unknown) => void
|
|
|
|
|
|
|
|
enum ValueTypes {
|
|
|
|
Dict,
|
|
|
|
Array,
|
|
|
|
Other,
|
|
|
|
}
|
|
|
|
|
|
|
|
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.
|
|
|
|
*/
|
2023-01-16 17:25:44 +01:00
|
|
|
export default class Atom<State> implements PointerToPrismProvider {
|
2021-06-18 13:05:06 +02:00
|
|
|
private _currentState: State
|
2022-01-19 13:06:13 +01:00
|
|
|
/**
|
|
|
|
* @internal
|
|
|
|
*/
|
2023-01-16 17:25:44 +01:00
|
|
|
readonly $$isPointerToPrismProvider = 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: [] })`.
|
|
|
|
*/
|
2023-01-16 17:25:44 +01:00
|
|
|
readonly pointer: Pointer<State> = pointer({root: this as $FixMe, path: []})
|
2021-06-18 13:05:06 +02:00
|
|
|
|
2023-01-16 17:25:44 +01:00
|
|
|
readonly prism: Prism<State> = this.pointerToPrism(
|
|
|
|
this.pointer,
|
|
|
|
) as $IntentionalAny
|
2022-12-01 15:37:19 +01:00
|
|
|
|
2021-06-18 13:05:06 +02:00
|
|
|
constructor(initialState: State) {
|
|
|
|
this._currentState = initialState
|
|
|
|
this._rootScope = new Scope(undefined, [])
|
|
|
|
}
|
|
|
|
|
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-21 21:57:28 +01:00
|
|
|
/**
|
|
|
|
* 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)
|
|
|
|
|
2023-01-15 22:04:27 +01:00
|
|
|
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
|
|
|
}
|
|
|
|
|
2022-12-01 18:54:14 +01:00
|
|
|
reduce(fn: (state: State) => State) {
|
|
|
|
this.set(fn(this.get()))
|
2021-06-18 13:05:06 +02:00
|
|
|
}
|
|
|
|
|
2023-01-21 21:57:28 +01:00
|
|
|
/**
|
|
|
|
* 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
|
|
|
|
* ```
|
|
|
|
*/
|
2022-12-01 18:54:14 +01:00
|
|
|
reduceByPointer<S>(
|
2023-01-21 21:57:28 +01:00
|
|
|
pointerOrFn: Pointer<S> | ((p: Pointer<State>) => Pointer<S>),
|
2022-12-01 18:54:14 +01:00
|
|
|
reducer: (s: S) => S,
|
|
|
|
) {
|
2023-01-21 21:57:28 +01:00
|
|
|
const pointer = isPointer(pointerOrFn)
|
|
|
|
? pointerOrFn
|
|
|
|
: (pointerOrFn as $IntentionalAny)(this.pointer)
|
|
|
|
|
2022-12-01 18:54:14 +01:00
|
|
|
const path = getPointerParts(pointer).path
|
|
|
|
const newState = updateDeep(this.get(), path, reducer)
|
|
|
|
this.set(newState)
|
|
|
|
}
|
|
|
|
|
2023-01-21 21:57:28 +01:00
|
|
|
/**
|
|
|
|
* 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)
|
2021-06-18 13:05:06 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
private _checkUpdates(scope: Scope, oldState: unknown, newState: unknown) {
|
|
|
|
if (oldState === newState) return
|
2022-06-09 19:12:40 +02:00
|
|
|
for (const cb of scope.identityChangeListeners) {
|
|
|
|
cb(newState)
|
|
|
|
}
|
2021-06-18 13:05:06 +02:00
|
|
|
|
|
|
|
if (scope.children.size === 0) return
|
2021-06-27 13:37:10 +02:00
|
|
|
|
|
|
|
// @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
|
|
|
|
|
2022-06-09 19:12:40 +02:00
|
|
|
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)
|
2022-06-09 19:12:40 +02:00
|
|
|
}
|
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
|
|
|
|
}
|
|
|
|
|
2023-01-21 21:57:28 +01:00
|
|
|
private _onPointerValueChange = <P>(
|
|
|
|
pointer: Pointer<P>,
|
|
|
|
cb: (v: P) => void,
|
|
|
|
): (() => void) => {
|
|
|
|
const {path} = getPointerParts(pointer)
|
2021-06-18 13:05:06 +02:00
|
|
|
const scope = this._getOrCreateScopeForPath(path)
|
2023-01-21 21:57:28 +01:00
|
|
|
scope.identityChangeListeners.add(cb as $IntentionalAny)
|
|
|
|
const unsubscribe = () => {
|
|
|
|
scope.identityChangeListeners.delete(cb as $IntentionalAny)
|
2021-06-18 13:05:06 +02:00
|
|
|
}
|
2023-01-21 21:57:28 +01:00
|
|
|
return unsubscribe
|
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 new prism of the value at the provided path.
|
2022-01-19 13:06:13 +01:00
|
|
|
*
|
2023-01-21 21:57:28 +01:00
|
|
|
* @param pointer - The path to create the prism at.
|
|
|
|
*
|
|
|
|
* ```ts
|
|
|
|
* const pr = atom({ a: { b: 1 } }).pointerToPrism(atom.pointer.a.b)
|
|
|
|
* pr.getValue() // 1
|
|
|
|
* ```
|
2022-01-19 13:06:13 +01:00
|
|
|
*/
|
2023-01-16 17:25:44 +01:00
|
|
|
pointerToPrism<P>(pointer: Pointer<P>): Prism<P> {
|
|
|
|
const {path} = getPointerParts(pointer)
|
2022-11-28 14:45:39 +01:00
|
|
|
const subscribe = (listener: (val: unknown) => void) =>
|
2023-01-21 21:57:28 +01:00
|
|
|
this._onPointerValueChange(pointer, listener)
|
2022-11-28 14:45:39 +01:00
|
|
|
|
2023-01-15 22:04:27 +01:00
|
|
|
const getValue = () => this._getIn(path)
|
2022-11-28 14:45:39 +01:00
|
|
|
|
|
|
|
return prism(() => {
|
2022-12-01 12:58:59 +01:00
|
|
|
return prism.source(subscribe, getValue)
|
2023-01-16 17:25:44 +01:00
|
|
|
}) as Prism<P>
|
2021-06-18 13:05:06 +02:00
|
|
|
}
|
|
|
|
}
|