UNPKG

mobx-bonsai

Version:

A fast lightweight alternative to MobX-State-Tree + Y.js two-way binding

432 lines (372 loc) 12.9 kB
import deepFreeze from "deep-freeze" import { IArrayDidChange, IAtom, IObjectDidChange, ObservableSet, action, createAtom, intercept, observable, observe, set, toJS, } from "mobx" import { failure } from "../error/failure" import { isArray, isObservablePlainStructure, isPrimitive } from "../plainTypes/checks" import { Dispose, disposeOnce } from "../utils/disposable" import { inDevMode } from "../utils/inDevMode" import { NodeWithAnyType, getNodeTypeAndKey, nodeTypeKey, tryRegisterNodeByTypeAndKey, } from "./nodeTypeKey/nodeType" import { reconcileData } from "./reconcileData" import { invalidateSnapshotTreeToRoot } from "./snapshot/getSnapshot" import { getParent } from "./tree/getParent" import { buildNodeFullPath } from "./utils/buildNodeFullPath" type ParentNode = { object: object path: string } export type NodeChange = IObjectDidChange | IArrayDidChange export type NodeChangeListener = (change: NodeChange) => void type NodeData = { parent: ParentNode | undefined parentAtom: IAtom | undefined onChangeListeners: NodeChangeListener[] childrenObjects: ObservableSet<object> frozen: boolean } export function getNodeData(node: object): NodeData { assertIsNode(node, "node") return nodes.get(node)! } export function reportNodeParentObserved(node: object): void { const data = getNodeData(node) if (!data.parentAtom) { data.parentAtom = createAtom("parent") } data.parentAtom.reportObserved() } const nodes = new WeakMap<object, NodeData>() function setParentNode(node: object, parentNode: ParentNode | undefined): void { const nodeData = getNodeData(node) nodeData.parent = parentNode nodeData.parentAtom?.reportChanged() } /** * Checks if the given object is a MobX-Bonsai node. * * @param struct The object to check. * @returns `true` if the object is a MobX-Bonsai node, `false` otherwise. */ export function isNode(struct: unknown): boolean { return nodes.has(struct as object) } /** * Checks if the given node is frozen. * A frozen node is a node that cannot be modified. * * @param node - The object to check. * @returns `true` if the object is a node and is frozen, `false` otherwise. */ export function isFrozenNode(node: object): boolean { return getNodeData(node).frozen } /** * Asserts that the given object is a mobx-bonsai node. * * @param node The object to check. * @param argName The name of the argument being checked. This is used in the error message. * @throws If the object is not a mobx-bonsai node. */ export function assertIsNode(node: object, argName: string): void { if (!isNode(node)) { throw failure(`${argName} must be a mobx-bonsai node`) } } function emitChange(eventTarget: object, change: IObjectDidChange | IArrayDidChange) { const changeListeners = getNodeData(eventTarget).onChangeListeners if (changeListeners.length > 0) { changeListeners.forEach((listener) => { listener(change) }) } } function emitChangeToRoot(eventTarget: object, change: IObjectDidChange | IArrayDidChange) { let currentTarget: object | undefined = eventTarget while (currentTarget) { emitChange(currentTarget, change) currentTarget = getParent(currentTarget) } } /** * Registers a deep change listener on the provided node. * * The listener is invoked whenever the node undergoes a change, such as additions, * updates, or removals within its observable structure. This includes receiving * events from both object and array mutations. * * @param node - The node to attach the change listener to. * @param listener - The callback function that is called when a change occurs. * The listener receives two parameters: * - changeTarget: The node where the change occurred. * - change: The change event, which is a NodeChange. * * @returns A disposer function that, when invoked, unregisters the listener. */ export function onDeepChange(node: object, listener: NodeChangeListener): Dispose { const changeListeners = getNodeData(node).onChangeListeners changeListeners.push(listener) return disposeOnce(() => { const index = changeListeners.indexOf(listener) if (index !== -1) { changeListeners.splice(index, 1) } }) } let detachDuplicatedNodes = 0 export const runDetachingDuplicatedNodes = (fn: () => void) => { detachDuplicatedNodes++ try { fn() } finally { detachDuplicatedNodes-- } } /** * Converts a plain/observable object or array into a mobx-bonsai node. * If the data is already a node it is returned as is. * If the data contains a type and key and they match an already existing node * then that node is reconciled with the new data and the existing node is returned. * * @param struct - The object or array to be converted. * @param options - Optional configuration object. * @property {boolean} [skipInit] - If true, skips the initialization phase. * * @returns The node, an enhanced observable structure. */ export const node = action( <T extends object>( struct: T, options?: { skipInit?: boolean } ): T => { if (isNode(struct)) { // nothing to do return struct } const { type, key } = getNodeTypeAndKey(struct) const keyProp = type && "key" in type ? type.key : undefined if (type !== undefined && key !== undefined) { const existingNode = "findByKey" in type ? (type.findByKey(key) as T | undefined) : undefined if (existingNode) { const result = reconcileData(existingNode, struct, existingNode) if (result !== existingNode) { throw failure("reconciliation should not create a new object") } return existingNode as T } } const frozen = type && "isFrozen" in type ? type.isFrozen : false const nodeData: NodeData = { parent: undefined, parentAtom: undefined, onChangeListeners: [], childrenObjects: observable.set([], { deep: false, }), frozen, } let nodeStruct: object const registerNode = () => { if (!nodeStruct) { throw failure("nodeStruct is not defined") } nodes.set(nodeStruct, nodeData) tryRegisterNodeByTypeAndKey(nodeStruct) } if (frozen) { const plainStruct = toJS(struct) if (inDevMode) { deepFreeze(plainStruct) } nodeStruct = plainStruct registerNode() } else { const observableStruct = (() => { if (isObservablePlainStructure(struct)) { return struct } return Array.isArray(struct) ? observable.array(struct, { deep: false }) : observable.object(struct, undefined, { deep: false }) })() nodeStruct = observableStruct registerNode() const attachAsChildNode = (v: any, path: string, setIfConverted: (n: object) => void) => { if (isPrimitive(v)) { return } let n = v if (isNode(n)) { // ensure it is detached first or at same position const parent = getNodeData(n).parent if (parent && (parent.object !== observableStruct || parent.path !== path)) { if (detachDuplicatedNodes > 0) { set(parent.object, parent.path, undefined) } else { throw failure( `The same node cannot appear twice in the same or different trees,` + ` trying to assign it to ${JSON.stringify(buildNodeFullPath(observableStruct, path))},` + ` but it already exists at ${JSON.stringify(buildNodeFullPath(parent.object, parent.path))}.` + ` If you are moving the node then remove it from the tree first before moving it.` + ` If you are copying the node then use 'cloneNode' to make a clone first.` ) } } } else { n = node(v) if (n !== v) { // actually needed conversion from plain object, or was a unique node that resolved to an existing node setIfConverted(n) } } nodeData.childrenObjects.add(n) setParentNode(n, { object: observableStruct, path }) } const detachAsChildNode = (v: any) => { // might not be a node if we convert from observable struct to an existing unique node if (!isPrimitive(v) && isNode(v)) { setParentNode(v, undefined) nodeData.childrenObjects.delete(v) } } const isArrayNode = isArray(observableStruct) // make current children nodes too (init) if (isArrayNode) { const array = observableStruct array.forEach((v, i) => { attachAsChildNode(v, i.toString(), (n) => { set(array, i, n) }) }) } else { const object = observableStruct as any Object.entries(object).forEach(([key, v]) => { attachAsChildNode(v, key, (n) => { set(object, key, n) }) }) } // and observe changes if (isArrayNode) { // we don't use change.object because it is bugged in some old versions of mobx const array = observableStruct intercept(array, (change) => { switch (change.type) { case "update": { const oldValue = array[change.index] detachAsChildNode(oldValue) attachAsChildNode(change.newValue, "" + change.index, (n) => { change.newValue = n }) break } case "splice": { for (let i = 0; i < change.removedCount; i++) { const removedValue = array[change.index + i] detachAsChildNode(removedValue) } for (let i = 0; i < change.added.length; i++) { attachAsChildNode(change.added[i], "" + (change.index + i), (n) => { change.added[i] = n }) } // we might also need to update the parent of the next indexes const oldNextIndex = change.index + change.removedCount const newNextIndex = change.index + change.added.length if (oldNextIndex !== newNextIndex) { for (let i = oldNextIndex, j = newNextIndex; i < array.length; i++, j++) { const value = array[i] if (isPrimitive(value)) { continue } if (!isNode(value)) { throw failure("node expected") } setParentNode( array[i], // value { object: array, path: "" + j, } // parentPath ) } } break } default: throw failure(`unsupported change type`) } invalidateSnapshotTreeToRoot(observableStruct) return change }) observe(array, (change) => { emitChangeToRoot(array, change) }) } else { const object = observableStruct intercept(object, (change) => { if (typeof change.name === "symbol") { throw failure("symbol keys are not supported on a mobx-bonsai node") } const propKey = "" + change.name if (propKey === nodeTypeKey || (keyProp !== undefined && propKey === keyProp)) { throw failure(`the property ${change.name} cannot be modified`) } switch (change.type) { case "add": { attachAsChildNode(change.newValue, propKey, (n) => { change.newValue = n }) break } case "update": { const oldValue = (object as any)[propKey] const newValue = change.newValue if (newValue !== oldValue) { detachAsChildNode(oldValue) attachAsChildNode(change.newValue, propKey, (n) => { change.newValue = n }) } break } case "remove": { const oldValue = (object as any)[propKey] detachAsChildNode(oldValue) break } default: throw failure(`unsupported change type`) } invalidateSnapshotTreeToRoot(observableStruct) return change }) observe(observableStruct, (change) => { emitChangeToRoot(observableStruct, change) }) } } // init node if needed const skipInit = options?.skipInit ?? false if (!skipInit) { type?._initNode(nodeStruct as NodeWithAnyType) } return nodeStruct as unknown as T } )