UNPKG

mobx-keystone

Version:

A MobX powered state management solution based on data trees with first class support for TypeScript, snapshots, patches and much more

316 lines (270 loc) 8.1 kB
import { IObjectDidChange, IObjectWillChange, intercept, isObservableObject, observable, observe, } from "mobx" import { assertCanWrite } from "../action/protection" import type { AnyModel } from "../model/BaseModel" import { modelTypeKey } from "../model/metadata" import type { ModelClass } from "../modelShared/BaseModelShared" import { getModelInfoForName } from "../modelShared/modelInfo" import { dataToModelNode } from "../parent/core" import type { ParentPath } from "../parent/path" import { setParent } from "../parent/setParent" import { InternalPatchRecorder } from "../patch/emitPatch" import { freezeInternalSnapshot, getInternalSnapshot, SnapshotTransformFn, setNewInternalSnapshot, updateInternalSnapshot, } from "../snapshot/internal" import { failure, isPlainObject, isPrimitive } from "../utils" import { setIfDifferent } from "../utils/setIfDifferent" import { runningWithoutSnapshotOrPatches, tweakedObjects } from "./core" import { TweakerPriority } from "./TweakerPriority" import { registerTweaker, tweak } from "./tweak" import { runTypeCheckingAfterChange } from "./typeChecking" /** * @internal */ export function tweakPlainObject<T extends Record<string, any>>( value: T, parentPath: ParentPath<any> | undefined, snapshotModelType: string | undefined, doNotTweakChildren: boolean, isDataObject: boolean ): T { const originalObj: Record<string, any> = value const tweakedObj = isObservableObject(originalObj) ? originalObj : observable.object({}, undefined, observableOptions) // biome-ignore lint/style/useConst: gets reassigned later let interceptDisposer: () => void // biome-ignore lint/style/useConst: gets reassigned later let observeDisposer: () => void const untweak = () => { interceptDisposer() observeDisposer() } tweakedObjects.set(tweakedObj, untweak) setParent( tweakedObj, // value parentPath, false, // indexChangeAllowed isDataObject, // an object shouldn't be cloned false // cloneIfApplicable ) const untransformedSn: any = {} // substitute initial values by tweaked values const originalObjKeys = Object.keys(originalObj) const originalObjKeysLen = originalObjKeys.length for (let i = 0; i < originalObjKeysLen; i++) { const k = originalObjKeys[i] const v = originalObj[k] if (isPrimitive(v)) { if (!doNotTweakChildren) { setIfDifferent(tweakedObj, k, v) } untransformedSn[k] = v } else { const path = { parent: tweakedObj, path: k } let tweakedValue: any if (doNotTweakChildren) { tweakedValue = v setParent( tweakedValue, // value path, // parentPath false, // indexChangeAllowed false, // isDataObject // the value is already a new value (the result of a fromSnapshot) false // cloneIfApplicable ) } else { tweakedValue = tweak(v, path) setIfDifferent(tweakedObj, k, tweakedValue) } const valueSn = getInternalSnapshot(tweakedValue)! untransformedSn[k] = valueSn.transformed } } let transformFn: SnapshotTransformFn | undefined if (snapshotModelType) { untransformedSn[modelTypeKey] = snapshotModelType const modelInfo = getModelInfoForName(snapshotModelType) if (!modelInfo) { throw failure(`model with name "${snapshotModelType}" not found in the registry`) } const originalTransformFn = (modelInfo.class as ModelClass<AnyModel>).toSnapshotProcessor if (originalTransformFn) { transformFn = (sn) => originalTransformFn(sn, dataToModelNode(tweakedObj)) } } setNewInternalSnapshot( isDataObject ? dataToModelNode(tweakedObj) : tweakedObj, untransformedSn, transformFn ) interceptDisposer = intercept(tweakedObj, interceptObjectMutation) observeDisposer = observe(tweakedObj, objectDidChange) return tweakedObj as any } const observableOptions = { deep: false, } function mutateSet(k: PropertyKey, v: unknown, sn: Record<PropertyKey, unknown>) { sn[k] = v } function mutateDelete(k: PropertyKey, sn: Record<PropertyKey, unknown>) { delete sn[k] } const patchRecorder = new InternalPatchRecorder() function objectDidChange(change: IObjectDidChange): void { const obj = change.object const actualNode = dataToModelNode(obj) const oldUntransformedSn = getInternalSnapshot(actualNode)!.untransformed patchRecorder.reset() let mutate: ((sn: any) => void) | undefined switch (change.type) { case "add": case "update": mutate = objectDidChangeAddOrUpdate(change, oldUntransformedSn) break case "remove": mutate = objectDidChangeRemove(change, oldUntransformedSn) break default: throw failure("assertion error: unsupported object change type") } runTypeCheckingAfterChange(obj, patchRecorder) if (!runningWithoutSnapshotOrPatches) { updateInternalSnapshot(actualNode, mutate) patchRecorder.emit(actualNode) } } function objectDidChangeRemove( change: IObjectDidChange & { type: "remove" }, oldUntransformedSn: any ) { const k = change.name const oldVal = oldUntransformedSn[k] const mutate = mutateDelete.bind(undefined, k) const path = [k as string] patchRecorder.record( [ { op: "remove", path, }, ], [ { op: "add", path, value: freezeInternalSnapshot(oldVal), }, ] ) return mutate } function objectDidChangeAddOrUpdate( change: IObjectWillChange & { type: "add" | "update" }, oldUntransformedSn: any ) { const k = change.name const val = change.newValue const oldVal = oldUntransformedSn[k] let newVal: any if (isPrimitive(val)) { newVal = val } else { const valueSn = getInternalSnapshot(val)! newVal = valueSn.transformed } const mutate = mutateSet.bind(undefined, k, newVal) const path = [k as string] if (change.type === "add") { patchRecorder.record( [ { op: "add", path, value: freezeInternalSnapshot(newVal), }, ], [ { op: "remove", path, }, ] ) } else { patchRecorder.record( [ { op: "replace", path, value: freezeInternalSnapshot(newVal), }, ], [ { op: "replace", path, value: freezeInternalSnapshot(oldVal), }, ] ) } return mutate } function interceptObjectMutation(change: IObjectWillChange) { assertCanWrite() if (typeof change.name === "symbol") { throw failure("symbol properties are not supported") } switch (change.type) { case "add": change.newValue = tweak(change.newValue, { parent: change.object, path: String(change.name), }) break case "remove": { tweak(change.object[change.name], undefined) break } case "update": { const oldVal = change.object[change.name] const newVal = change.newValue if (newVal !== oldVal) { tweak(oldVal, undefined) change.newValue = tweak(newVal, { parent: change.object, path: String(change.name), }) } break } default: throw failure("assertion error: unsupported object change type") } return change } /** * @internal */ export function registerPlainObjectTweaker() { registerTweaker(TweakerPriority.PlainObject, (value, parentPath) => { // plain object if (isObservableObject(value) || isPlainObject(value)) { return tweakPlainObject(value as Record<string, any>, parentPath, undefined, false, false) } return undefined }) }