UNPKG

epic-state

Version:

Reactive state management for frontend libraries.

261 lines (247 loc) 9 kB
import { Renderer } from 'epic-jsx' // TODO import should be optional and not required, pass along with connect. import { batch, scheduleUpdate } from './batching' import { list } from './data/list' import { load } from './data/load' import { objectMap, objectSet } from './data/polyfill' import { derive, isTracked, track } from './derive' import { canPolyfill, canProxy, createBaseObject, isLeaf, isObject, isSetter, log, needsRegister, newProxy, set, setTo, setValue, toggle, updateProxyValues, } from './helper' import { callPlugins, initializePlugins, plugin, removeAllPlugins } from './plugin' import { observe } from './plugin/observe' import { run } from './run' import { type AsRef, type ConfigurablePlugin, type ConfiguredPlugin, type Observation, type ObservationCallback, type Plugin, PluginAction, type PluginActions, type Property, type ProxyObject, type ProxyState, type RootState, type Value, } from './types' export type { Plugin, Property, Value, ConfigurablePlugin, ConfiguredPlugin, RootState, PluginActions, Observation, ObservationCallback } export { plugin, removeAllPlugins, list, load, run, batch, observe, set, setTo, toggle, setValue } // Shared State, Map with links to all states created. const proxyStateMap = new Map<ProxyObject, ProxyState>() const refSet = new WeakSet() const renderStateMap = new Map<number, ProxyState>() // proxy function renamed to state (proxy as hidden implementation detail). // @ts-ignore TODO figure out if object will work as expected export function state<T extends object, R extends object = undefined>( initialObject: T | (() => T) = {} as T, parent?: object, root?: R, ): T { if (Renderer.current?.id && renderStateMap.has(Renderer.current.id)) { return renderStateMap.get(Renderer.current.id) as T } let initialization = true if (typeof initialObject === 'function') { // biome-ignore lint/style/noParameterAssign: Much easier in this case. initialObject = initialObject() } if (!isObject(initialObject)) { log('Only objects can be made observable with state()', 'error') } if (!parent && Object.hasOwn(initialObject, 'parent')) { log('"parent" property is reserved on state objects to reference the parent', 'warning') } if (!root && Object.hasOwn(initialObject, 'root')) { log('"root" property is reserved on state objects to reference the root', 'warning') } derive(initialObject) let plugins: PluginActions[] = [] const baseObject = createBaseObject(initialObject) const id = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER) // Unique identifier for proxy objects. const handler: ProxyHandler<T> = { get(target, property, receiver) { if (property === 'parent') { return parent // Parent access untracked. } if (property === 'root') { return root // Root access untracked. } if (property === 'plugin') { return undefined // Plugin cannot be accessed or tracked. } if (property === '_plugin') { return plugins // Internal plugin access. } if (property === '_id') { return id } if (property === 'addPlugin') { return (newPlugin: Plugin | PluginActions) => plugins.push(typeof newPlugin === 'function' ? newPlugin('initialize', proxyObject) : newPlugin) // Add plugins after initialization. } const value = Reflect.get(target, property, receiver) if (!initialization && typeof value !== 'function') { callPlugins({ type: PluginAction.Get, target: receiver, initial: true, property, parent: receiver ?? root, leaf: isLeaf(value), value, }) track(root ?? receiver, property) } // Register receiver and property on custom data structures. // TODO should only be done on first access. if (needsRegister(value)) { ;(value as any)._register(receiver ?? root, property) } return value }, // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Difficult to fix, central part of the application. set(target, property, value, receiver: object) { if (property === 'parent' || property === 'root' || (!initialization && property === 'plugin')) { log(`"${property}" is reserved an cannot be changed`, 'warning') return false } const previousValue = Reflect.get(target, property, receiver) // Reflect skips other traps. if (value === previousValue) { // Skip unchanged values. return true } let nextValue = value if (value instanceof Promise) { value .then((result) => { // @ts-ignore NOTE custom but common pattern value.status = 'fulfilled' // @ts-ignore value.value = result // TODO schedule update PluginAction.Resolve property, result }) .catch((error) => { // @ts-ignore value.status = 'rejected' // @ts-ignore value.reason = error // TODO schedule update PluginAction.Reject property, error }) } else { if (initialization && typeof value === 'function' && value.requiresInitialization) { // Custom data structures. const { data, after } = value(state) nextValue = data if (typeof after === 'function') { after(nextValue) } } else if (!proxyStateMap.has(value) && canProxy(value, refSet)) { nextValue = state(value, receiver, root ?? receiver) } else if (canPolyfill(value)) { // TODO Necessary that Map or Set cannot be root? if (value instanceof Map) { nextValue = objectMap(state, value, parent, root ?? receiver) } else { nextValue = objectSet(state, value, parent, root ?? receiver) } } const childProxyState = !refSet.has(nextValue) && proxyStateMap.get(nextValue) if (childProxyState) { // TODO what's child proxy state??? } } // Call setters and getters on existing proxy. if (!initialization && typeof value === 'object' && typeof previousValue === 'object' && !Array.isArray(value)) { updateProxyValues(previousValue as unknown as ProxyObject, value) return true } if (previousValue === undefined && !isSetter(target, property)) { Object.defineProperty(target, property, { value: nextValue, writable: true, configurable: true, }) } else { Reflect.set(target, property, nextValue, receiver) } if (!initialization) { isTracked(root ?? receiver, property) // Mark changed values as "dirty" before plugins (rerenders). scheduleUpdate({ type: PluginAction.Set, target: receiver as ProxyObject, initial: true, property, parent: (receiver ?? root) as ProxyObject, value, previousValue, leaf: isLeaf(value), }) } return true }, deleteProperty(target, property) { const previousValue = Reflect.get(target, property) const deleted = Reflect.deleteProperty(target, property) if (deleted) { // TODO no receiver, no parent access? scheduleUpdate({ type: PluginAction.Delete, target: target as ProxyObject, initial: true, property, parent: proxyObject ?? root, previousValue, leaf: typeof previousValue !== 'object', }) } return deleted }, } const proxyObject = newProxy(baseObject, handler) const proxyState: ProxyState = [baseObject] proxyStateMap.set(proxyObject, proxyState) if (Renderer.current?.id) { renderStateMap.set(Renderer.current.id, proxyObject) } for (const key of Reflect.ownKeys(initialObject)) { const desc = Object.getOwnPropertyDescriptor(initialObject, key) as PropertyDescriptor if ('value' in desc) { proxyObject[key as keyof T] = initialObject[key as keyof T] // We need to delete desc.value because we already set it, // and delete desc.writable because we want to write it again. delete desc.value delete desc.writable } // This will recursively call the setter trap for any nested properties on the initialObject. Object.defineProperty(baseObject, key, desc) } // @ts-ignore plugins = initializePlugins(proxyObject, initialObject.plugin) initialization = false return proxyObject } export function ref<T extends object>(obj: T): T & AsRef { refSet.add(obj) return obj as T & AsRef } export function remove(proxyObject: unknown): boolean { if (proxyStateMap.has(proxyObject as ProxyObject)) { proxyStateMap.delete(proxyObject as ProxyObject) return true } return false }