import { type IObjectDidChange, type IObjectWillChange, intercept, isObservableObject, observable, observe, } from "mobx" import { assertCanWrite } from "../action/protection" import { DeepChangeType, emitDeepChange, getIsInInitPhase } from "../deepChange/onDeepChange" import type { AnyModel } from "../model/BaseModel" import { modelTypeKey } from "../model/metadata" import type { ModelClass } from "../modelShared/BaseModelShared" import { getModelInfoForName, getModelNotRegisteredErrorMessage } from "../modelShared/modelInfo" import { dataToModelNode } from "../parent/core" import type { ParentPath } from "../parent/path" import type { Path } from "../parent/pathTypes" import { setParent } from "../parent/setParent" import { InternalPatchRecorder } from "../patch/emitPatch" import { freezeInternalSnapshot, getInternalSnapshot, type SnapshotTransformFn, setNewInternalSnapshot, updateInternalSnapshot, } from "../snapshot/internal" import { failure, isPlainObject, isPrimitive, setOwnProp } 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>( value: T, parentPath: ParentPath | undefined, snapshotModelType: string | undefined, doNotTweakChildren: boolean, isDataObject: boolean ): T { const originalObj: Record = value const tweakedObj = isObservableObject(originalObj) ? originalObj : observable.object({}, undefined, observableOptions) let interceptDisposer: () => void 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) } setOwnProp(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)! setOwnProp(untransformedSn, k, valueSn.transformed) } } let transformFn: SnapshotTransformFn | undefined if (snapshotModelType) { setOwnProp(untransformedSn, modelTypeKey, snapshotModelType) const modelInfo = getModelInfoForName(snapshotModelType) if (!modelInfo) { throw failure(getModelNotRegisteredErrorMessage(snapshotModelType)) } const originalTransformFn = (modelInfo.class as ModelClass).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) { setOwnProp(sn, k, v) } function mutateDelete(k: PropertyKey, sn: Record) { delete sn[k] } const patchRecorder = new InternalPatchRecorder() const emptyPath: Path = Object.freeze([]) function objectDidChange(change: IObjectDidChange): void { if (typeof change.name !== "string") { throw failure("change.name is not a string") } 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": { mutate = objectDidChangeAddOrUpdate(change, oldUntransformedSn) // Emit deep change for add emitDeepChange(actualNode, { type: DeepChangeType.ObjectAdd, target: obj as object, path: emptyPath, key: change.name, newValue: change.newValue, isInit: getIsInInitPhase(), }) break } case "update": { mutate = objectDidChangeAddOrUpdate(change, oldUntransformedSn) // Emit deep change for update emitDeepChange(actualNode, { type: DeepChangeType.ObjectUpdate, target: obj as object, path: emptyPath, key: change.name, newValue: change.newValue, oldValue: change.oldValue, isInit: getIsInInitPhase(), }) break } case "remove": { mutate = objectDidChangeRemove(change, oldUntransformedSn) // Emit deep change for remove emitDeepChange(actualNode, { type: DeepChangeType.ObjectRemove, target: obj as object, path: emptyPath, key: change.name, oldValue: change.oldValue, isInit: getIsInInitPhase(), }) 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, parentPath, undefined, false, false) } return undefined }) }