mobx-bonsai
Version:
A fast lightweight alternative to MobX-State-Tree + Y.js two-way binding
752 lines (648 loc) • 24.8 kB
text/typescript
import deepFreeze from "deep-freeze"
import {
action,
createAtom,
IArrayDidChange,
IArrayWillChange,
IArrayWillSplice,
IAtom,
IObjectDidChange,
IObjectWillChange,
intercept,
ObservableSet,
observable,
observe,
set,
toJS,
} from "mobx"
import { failure } from "../error/failure"
import { getGlobalConfig } from "../globalConfig"
import { isArray, isObservablePlainStructure, isPrimitive } from "../plainTypes/checks"
import { Dispose, disposeOnce } from "../utils/disposable"
import { inDevMode } from "../utils/inDevMode"
import {
getNodeTypeAndKey,
NodeWithAnyType,
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
/**
* Represents a change that is about to happen to a node during the intercept phase.
*/
export type NodeInterceptedChange =
| IObjectWillChange
| IArrayWillChange<any>
| IArrayWillSplice<any>
/**
* Listener for intercepted changes. Must return:
* - The change object (possibly modified) to accept the change
* - null to cancel the change and stop propagation to parent listeners
*/
export type NodeInterceptedChangeListener = (
change: NodeInterceptedChange
) => NodeInterceptedChange | null
type NodeData = {
parent: ParentNode | undefined
parentAtom: IAtom | undefined
onChangeListeners: NodeChangeListener[] | undefined
onInterceptedChangeListeners: NodeInterceptedChangeListener[] | undefined
childrenObjects: ObservableSet<object> | undefined
frozen: boolean
// Observer management (intercept is always attached, only observe is lazy)
observeDisposer: Dispose | undefined
// Reference count for "has onChangeListeners in path to root"
// > 0 means this node or some ancestor has onChangeListeners
ancestorChangeListenerRefCount: number
}
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)
const oldParent = nodeData.parent
// Handle reference count updates when parent changes
if (oldParent && !parentNode) {
// Node was detached - check if old parent had onChangeListeners
if (shouldHaveChangeListeners(oldParent.object)) {
decrementAncestorChangeListenerRefCount(node)
}
} else if (!oldParent && parentNode) {
// Node was attached - check if new parent has onChangeListeners
if (shouldHaveChangeListeners(parentNode.object)) {
incrementAncestorChangeListenerRefCount(node)
}
} else if (oldParent && parentNode && oldParent.object !== parentNode.object) {
// This should never happen - nodes should be detached before being attached elsewhere
throw failure(
"assertion failed: node moved from one parent to another without being detached first"
)
}
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 emitToListeners<TChange>(
listeners: Array<(change: TChange) => void> | undefined,
change: TChange
) {
if (listeners && listeners.length > 0) {
listeners.forEach((listener) => {
listener(change)
})
}
}
function emitChangeToRoot(eventTarget: object, change: IObjectDidChange | IArrayDidChange) {
let currentTarget: object | undefined = eventTarget
while (currentTarget) {
const changeListeners = getNodeData(currentTarget).onChangeListeners
emitToListeners(changeListeners, change)
currentTarget = getParent(currentTarget)
}
}
function emitInterceptedChangeToRoot(
eventTarget: object,
change: IObjectWillChange | IArrayWillChange<any> | IArrayWillSplice<any>
): NodeInterceptedChange | null {
let currentChange: NodeInterceptedChange = change
let currentTarget: object | undefined = eventTarget
while (currentTarget) {
const interceptedChangeListeners = getNodeData(currentTarget).onInterceptedChangeListeners
if (interceptedChangeListeners && interceptedChangeListeners.length > 0) {
// Call all listeners on the current node
for (const listener of interceptedChangeListeners) {
const result = listener(currentChange)
if (result === undefined) {
throw failure(
"onDeepInterceptedChange listener must return either the change object or null, but returned undefined"
)
}
if (result === null) {
// Change was cancelled - stop propagation immediately
return null
}
// Update the change object with any modifications
currentChange = result
}
}
currentTarget = getParent(currentTarget)
}
return currentChange
}
function registerListener<TListener>(
node: object,
listener: TListener,
listenersProperty: "onChangeListeners" | "onInterceptedChangeListeners"
): Dispose {
const nodeData = getNodeData(node)
let listeners = nodeData[listenersProperty] as TListener[] | undefined
const wasEmpty = !listeners || listeners.length === 0
if (!listeners) {
listeners = []
nodeData[listenersProperty] = listeners as any
}
listeners.push(listener)
// If this is the first onChangeListener, propagate down the tree
if (wasEmpty && listenersProperty === "onChangeListeners") {
incrementAncestorChangeListenerRefCount(node)
}
return disposeOnce(() => {
const currentListeners = nodeData[listenersProperty] as TListener[] | undefined
if (currentListeners) {
const index = currentListeners.indexOf(listener)
if (index !== -1) {
currentListeners.splice(index, 1)
const isEmpty = currentListeners.length === 0
if (isEmpty) {
nodeData[listenersProperty] = undefined
// If this was the last onChangeListener, propagate down the tree
if (listenersProperty === "onChangeListeners") {
decrementAncestorChangeListenerRefCount(node)
}
}
}
}
})
}
/**
* Registers a deep change listener on the provided node that is called BEFORE changes are committed.
*
* The listener is invoked whenever the node is about to undergo a change, such as additions,
* updates, or removals within its observable structure. This is called during the intercept phase,
* before the change is actually applied. This includes receiving events from both object and array mutations.
*
* Note: The listener is called before the change is committed, so the node still has its old state.
*
* The listener must return one of the following:
* - The received change object (possibly modified) to accept and apply the change
* - null to cancel the change and stop propagation to parent listeners
*
* The listener can also throw an exception to prevent the change (e.g., if an invariant isn't met).
*
* @param node - The node to attach the intercepted change listener to.
* @param listener - The callback function that is called when a change is about to occur.
* The listener receives a NodeInterceptedChange parameter and must return either the change
* object (to accept it) or null (to cancel it).
*
* @returns A disposer function that, when invoked, unregisters the listener.
*/
export function onDeepInterceptedChange(
node: object,
listener: NodeInterceptedChangeListener
): Dispose {
return registerListener(node, listener, "onInterceptedChangeListeners")
}
/**
* 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 {
return registerListener(node, listener, "onChangeListeners")
}
/**
* Attach MobX observe hook to a node
*/
function attachObserveHook(node: object): void {
const nodeData = getNodeData(node)
if (!nodeData.observeDisposer) {
nodeData.observeDisposer = observe(node, (change) => {
emitChangeToRoot(node, change)
})
}
}
/**
* Detach MobX observe hook from a node
*/
function detachObserveHook(node: object): void {
const nodeData = getNodeData(node)
if (nodeData.observeDisposer) {
nodeData.observeDisposer()
nodeData.observeDisposer = undefined
}
}
/**
* Increment the ancestor change listener ref count for this node and all its descendants
*/
function incrementAncestorChangeListenerRefCount(node: object): void {
const nodeData = getNodeData(node)
// Skip frozen nodes entirely - they never get observe hooks
if (nodeData.frozen) {
return
}
// Increment this node's count
nodeData.ancestorChangeListenerRefCount++
// If count went from 0 to 1, we need to attach observe hook
if (nodeData.ancestorChangeListenerRefCount === 1) {
attachObserveHook(node)
}
// Propagate to all children
if (nodeData.childrenObjects) {
nodeData.childrenObjects.forEach((child) => {
incrementAncestorChangeListenerRefCount(child)
})
}
}
/**
* Decrement the ancestor change listener ref count for this node and all its descendants
*/
function decrementAncestorChangeListenerRefCount(node: object): void {
const nodeData = getNodeData(node)
// Skip frozen nodes entirely - they never get observe hooks
if (nodeData.frozen) {
return
}
// Decrement this node's count
nodeData.ancestorChangeListenerRefCount--
// If count went to 0, we can detach observe hook
if (nodeData.ancestorChangeListenerRefCount === 0) {
detachObserveHook(node)
}
// Propagate to all children
if (nodeData.childrenObjects) {
nodeData.childrenObjects.forEach((child) => {
decrementAncestorChangeListenerRefCount(child)
})
}
}
/**
* Check if a node or any of its ancestors has onChangeListeners
*/
function shouldHaveChangeListeners(node: object): boolean {
// The ancestorChangeListenerRefCount already tells us if this node
// or any ancestor has listeners
return getNodeData(node).ancestorChangeListenerRefCount > 0
}
let detachDuplicatedNodes = 0
export const runDetachingDuplicatedNodes = (fn: () => void) => {
detachDuplicatedNodes++
try {
fn()
} finally {
detachDuplicatedNodes--
}
}
function checkForCircularReferences(observableStruct: object, n: object, path: string): void {
// Check if we're trying to create a circular reference
// (making a node a child of one of its own descendants)
if (!getGlobalConfig().checkCircularReferences) {
return
}
let currentParent: object | undefined = observableStruct
const visited = new Set<object>()
while (currentParent) {
if (visited.has(currentParent)) {
// Cycle detected in the parent chain - this indicates a corrupted tree structure
throw failure(
`assertion error: cycle detected in parent chain while checking for circular reference,` +
` this should never happen and indicates a bug in mobx-bonsai.`
)
}
visited.add(currentParent)
if (currentParent === n) {
throw failure(
`Cannot create a circular reference.` +
` Trying to assign a node to ${JSON.stringify(buildNodeFullPath(observableStruct, path))},` +
` but this would make the node an ancestor of itself.`
)
}
currentParent = getParent(currentParent)
}
}
/**
* 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: undefined,
onInterceptedChangeListeners: undefined,
childrenObjects: undefined,
frozen,
observeDisposer: undefined,
ancestorChangeListenerRefCount: 0,
}
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)) {
checkForCircularReferences(observableStruct, n, path)
// 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 'clone' to make a clone first.`
)
}
}
} else {
n = node(v)
checkForCircularReferences(observableStruct, n, path)
// if the converted node has a parent, we need to detach it first
// note: this only happens when snapshots resolve to existing unique nodes
const parent = getNodeData(n).parent
if (parent && (parent.object !== observableStruct || parent.path !== path)) {
set(parent.object, parent.path, undefined)
}
// n !== v is TRUE when:
// 1. v was a plain (non-observable) object/array that got converted to an observable node
// 2. v had a type+key that resolved to an existing node (reconciliation)
// n === v is TRUE (no conversion needed) when:
// 1. v was already an observable structure but not yet registered as a node
// This happens during reconciliation/applySnapshot when passing observable objects
// Both unique nodes (with type+key) and regular nodes can hit this path
if (n !== v) {
// actually needed conversion from plain object, or was a unique node snapshot that
// got resolved to an existing node
setIfConverted(n)
}
}
// Lazily create childrenObjects set
if (!nodeData.childrenObjects) {
nodeData.childrenObjects = observable.set([], { deep: false })
}
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)
if (nodeData.childrenObjects) {
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) => {
// Emit intercepted change BEFORE processing
const wrappedChange = emitInterceptedChangeToRoot(array, change)
if (wrappedChange === null) {
// Change was cancelled
return null
}
let changed = false
switch (change.type) {
case "update": {
const oldValue = array[change.index]
changed = oldValue !== change.newValue
if (changed) {
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
})
}
changed = change.removedCount > 0 || change.added.length > 0
// 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(
value,
{
object: array,
path: "" + j,
} // parentPath
)
}
}
break
}
default:
throw failure(`unsupported change type`)
}
if (changed) {
invalidateSnapshotTreeToRoot(observableStruct)
return change
}
return null
})
// observe() hook is attached lazily when first onChangeListener is registered
} else {
const object = observableStruct
intercept(object, (change) => {
// Emit intercepted change BEFORE processing
const wrappedChange = emitInterceptedChangeToRoot(object, change)
if (wrappedChange === null) {
// Change was cancelled
return null
}
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`)
}
let changed = false
switch (change.type) {
case "add": {
changed = true
attachAsChildNode(change.newValue, propKey, (n) => {
change.newValue = n
})
break
}
case "update": {
const oldValue = (object as any)[propKey]
changed = oldValue !== change.newValue
if (changed) {
detachAsChildNode(oldValue)
attachAsChildNode(change.newValue, propKey, (n) => {
change.newValue = n
})
}
break
}
case "remove": {
changed = true
const oldValue = (object as any)[propKey]
detachAsChildNode(oldValue)
break
}
default:
throw failure(`unsupported change type`)
}
if (changed) {
invalidateSnapshotTreeToRoot(observableStruct)
return change
}
return null
})
// observe() hook is attached lazily when first onChangeListener is registered
}
}
// init node if needed
const skipInit = options?.skipInit ?? false
if (!skipInit) {
type?._initNode(nodeStruct as NodeWithAnyType)
}
return nodeStruct as unknown as T
}
)