import get from 'lodash-es/get' import isPlainObject from 'lodash-es/isPlainObject' import last from 'lodash-es/last' import DerivationFromSource from './derivations/DerivationFromSource' import type {IDerivation} from './derivations/IDerivation' import {isDerivation} from './derivations/IDerivation' import type {Pointer, PointerType} from './pointer' import {isPointer} from './pointer' import pointer, {getPointerMeta} from './pointer' import type {$FixMe, $IntentionalAny} from './types' import type {PathBasedReducer} from './utils/PathBasedReducer' import updateDeep from './utils/updateDeep' type Listener = (newVal: unknown) => void enum ValueTypes { Dict, Array, Other, } /** * Interface for objects that can provide a derivation at a certain path. */ export interface IdentityDerivationProvider { /** * @internal */ readonly $$isIdentityDerivationProvider: true /** * Returns a derivation of the value at the provided path. * * @param path - The path to create the derivation at. */ getIdentityDerivation(path: Array): IDerivation } 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 = new Map() identityChangeListeners: Set = 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) } } } /** * Wraps an object whose (sub)properties can be individually tracked. */ export default class Atom implements IdentityDerivationProvider { private _currentState: State /** * @internal */ readonly $$isIdentityDerivationProvider = true private readonly _rootScope: Scope /** * Convenience property that gives you a pointer to the root of the atom. * * @remarks * Equivalent to `pointer({ root: thisAtom, path: [] })`. */ readonly pointer: Pointer constructor(initialState: State) { this._currentState = initialState this._rootScope = new Scope(undefined, []) this.pointer = pointer({root: this as $FixMe, path: []}) } /** * Sets the state of the atom. * * @param newState - The new state of the atom. */ setState(newState: State) { const oldState = this._currentState this._currentState = newState this._checkUpdates(this._rootScope, oldState, newState) } /** * Gets the current state of the atom. */ getState() { return this._currentState } /** * Gets the state of the atom at `path`. */ getIn(path: (string | number)[]): unknown { return path.length === 0 ? this.getState() : get(this.getState(), path) } /** * Creates a new state object from the current one, where the value at `path` * is replaced by the return value of `reducer`, then sets it. * * @remarks * Doesn't mutate the old state, and preserves referential equality between * values of the old state and the new state where possible. * * @example * ```ts * someAtom.getIn(['a']) // 1 * someAtom.reduceState(['a'], (state) => state + 1); * someAtom.getIn(['a']) // 2 * ``` * * @param path - The path to call the reducer at. * @param reducer - The function to use for creating the new state. */ // TODO: Why is this a property and not a method? reduceState: PathBasedReducer = ( path: $IntentionalAny[], reducer: $IntentionalAny, ) => { const newState = updateDeep(this.getState(), path, reducer) this.setState(newState) return newState } /** * Sets the state of the atom at `path`. */ setIn(path: $FixMe[], val: $FixMe) { return this.reduceState(path, () => val) } private _checkUpdates(scope: Scope, oldState: unknown, newState: unknown) { if (oldState === newState) return scope.identityChangeListeners.forEach((cb) => cb(newState)) if (scope.children.size === 0) return // @todo we can probably skip checking value types const oldValueType = getTypeOfValue(oldState) const newValueType = getTypeOfValue(newState) if (oldValueType === ValueTypes.Other && oldValueType === newValueType) return scope.children.forEach((childScope, childKey) => { const oldChildVal = getKeyOfValue(oldState, childKey, oldValueType) const newChildVal = getKeyOfValue(newState, childKey, newValueType) this._checkUpdates(childScope, oldChildVal, newChildVal) }) } 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 } /** * Returns a new derivation of the value at the provided path. * * @param path - The path to create the derivation at. */ getIdentityDerivation(path: Array): IDerivation { return new DerivationFromSource<$IntentionalAny>( (listener) => this._onPathValueChange(path, listener), () => this.getIn(path), ) } } const identityDerivationWeakMap = new WeakMap<{}, IDerivation>() /** * Returns a derivation of the value at the provided pointer. Derivations are * cached per pointer. * * @param pointer - The pointer to return the derivation at. */ export const valueDerivation =

>( pointer: P, ): IDerivation

? T : void> => { const meta = getPointerMeta(pointer) let derivation = identityDerivationWeakMap.get(meta) if (!derivation) { const root = meta.root if (!isIdentityChangeProvider(root)) { throw new Error( `Cannot run valueDerivation() on a pointer whose root is not an IdentityChangeProvider`, ) } const {path} = meta derivation = root.getIdentityDerivation(path) identityDerivationWeakMap.set(meta, derivation) } return derivation as $IntentionalAny } // TODO: Rename it to isIdentityDerivationProvider function isIdentityChangeProvider( val: unknown, ): val is IdentityDerivationProvider { return ( typeof val === 'object' && val !== null && (val as $IntentionalAny)['$$isIdentityDerivationProvider'] === true ) } /** * Convenience function that returns a plain value from its argument, whether it * is a pointer, a derivation or a plain value itself. * * @remarks * For pointers, the value is returned by first creating a derivation, 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> | IDerivation<$IntentionalAny> | undefined | null, >( input: P, ): P extends PointerType ? T : P extends IDerivation ? T : P extends undefined | null ? P : unknown => { if (isPointer(input)) { return valueDerivation(input).getValue() as $IntentionalAny } else if (isDerivation(input)) { return input.getValue() as $IntentionalAny } else { return input as $IntentionalAny } }