mobx-keystone
Version:
A MobX powered state management solution based on data trees with first class support for TypeScript, snapshots, patches and much more
200 lines (169 loc) • 5.63 kB
text/typescript
import { action, isAction } from "mobx"
import { fastGetParentPath } from "../parent/path"
import type { PathElement } from "../parent/pathTypes"
import { freezeInternalSnapshot, getInternalSnapshot } from "../snapshot/internal"
import { assertTweakedObject } from "../tweaker/core"
import { assertIsFunction, deleteFromArray, isPrimitive } from "../utils"
import type { Patch } from "./Patch"
const emptyPatchArray: Patch[] = []
/**
* @internal
*/
export class InternalPatchRecorder {
patches: Patch[] = emptyPatchArray
invPatches: Patch[] = emptyPatchArray
reset() {
this.patches = emptyPatchArray
this.invPatches = emptyPatchArray
}
record(patches: Patch[], invPatches: Patch[]) {
this.patches = patches
this.invPatches = invPatches
}
emit(obj: object) {
emitPatches(obj, this.patches, this.invPatches)
this.reset()
}
}
/**
* @internal
*/
export function emitPatches(obj: object, patches: Patch[], invPatches: Patch[]): void {
if (patches.length > 0 || invPatches.length > 0) {
emitGlobalPatch(obj, patches, invPatches)
emitPatch(obj, patches, invPatches)
}
}
/**
* A function that gets called when a patch is emitted.
*/
export type OnPatchesListener = (patches: Patch[], inversePatches: Patch[]) => void
/**
* A function that gets called when a global patch is emitted.
*/
export type OnGlobalPatchesListener = (
target: object,
patches: Patch[],
inversePatches: Patch[]
) => void
/**
* Disposer function to stop listening to patches.
*/
export type OnPatchesDisposer = () => void
const patchListeners = new WeakMap<object, OnPatchesListener[]>()
const globalPatchListeners: OnGlobalPatchesListener[] = []
/**
* Adds a listener that will be called every time a patch is generated for the tree of the given target object.
*
* @param subtreeRoot Subtree root object of the patch listener.
* @param listener The listener function that will be called everytime a patch is generated for the object or its children.
* @returns A disposer to stop listening to patches.
*/
export function onPatches(subtreeRoot: object, listener: OnPatchesListener): OnPatchesDisposer {
assertTweakedObject(subtreeRoot, "subtreeRoot")
assertIsFunction(listener, "listener")
if (!isAction(listener)) {
listener = action(listener.name || "onPatchesListener", listener)
}
let listenersForObject = patchListeners.get(subtreeRoot)
if (!listenersForObject) {
listenersForObject = []
patchListeners.set(subtreeRoot, listenersForObject)
}
listenersForObject.push(listener)
return () => {
deleteFromArray(listenersForObject, listener)
}
}
/**
* Adds a listener that will be called every time a patch is generated anywhere.
* Usually prefer using `onPatches`.
*
* @param listener The listener function that will be called everytime a patch is generated anywhere.
* @returns A disposer to stop listening to patches.
*/
export function onGlobalPatches(listener: OnGlobalPatchesListener): OnPatchesDisposer {
assertIsFunction(listener, "listener")
if (!isAction(listener)) {
listener = action(listener.name || "onGlobalPatchesListener", listener)
}
globalPatchListeners.push(listener)
return () => {
deleteFromArray(globalPatchListeners, listener)
}
}
function emitGlobalPatch(obj: object, patches: Patch[], inversePatches: Patch[]): void {
for (let i = 0; i < globalPatchListeners.length; i++) {
const listener = globalPatchListeners[i]
listener(obj, patches, inversePatches)
}
}
function emitPatchForTarget(
obj: object,
patches: Patch[],
inversePatches: Patch[],
pathPrefix: PathElement[]
): void {
const listenersForObject = patchListeners.get(obj)
if (!listenersForObject || listenersForObject.length === 0) {
return
}
const fixPath = (patchesArray: Patch[]) =>
pathPrefix.length > 0 ? patchesArray.map((p) => addPathToPatch(p, pathPrefix)) : patchesArray
const patchesWithPathPrefix = fixPath(patches)
const invPatchesWithPathPrefix = fixPath(inversePatches)
for (let i = 0; i < listenersForObject.length; i++) {
const listener = listenersForObject[i]
listener(patchesWithPathPrefix, invPatchesWithPathPrefix)
}
}
function emitPatch(obj: object, patches: Patch[], inversePatches: Patch[]): void {
const pathPrefix: PathElement[] = []
emitPatchForTarget(obj, patches, inversePatches, pathPrefix)
// and also emit subtree listeners all the way to the root
let parentPath = fastGetParentPath(obj, false)
while (parentPath) {
pathPrefix.unshift(parentPath.path)
emitPatchForTarget(parentPath.parent, patches, inversePatches, pathPrefix)
parentPath = fastGetParentPath(parentPath.parent, false)
}
}
function addPathToPatch(patch: Patch, pathPrefix: readonly PathElement[]): Patch {
return {
...patch,
path: [...pathPrefix, ...patch.path],
}
}
const getValueSnapshotForPatch = (v: unknown) => {
if (isPrimitive(v)) {
return v
}
const internalSnapshot = getInternalSnapshot(v as object)
if (!internalSnapshot) {
// probably a plain value
return v
}
return freezeInternalSnapshot(internalSnapshot.transformed)
}
/**
* @internal
*/
export function createPatchForObjectValueChange(
path: readonly PathElement[],
oldValue: unknown,
newValue: unknown
): Patch {
return newValue === undefined
? { op: "remove", path }
: oldValue === undefined
? {
op: "add",
path,
value: getValueSnapshotForPatch(newValue),
}
: {
op: "replace",
path,
value: getValueSnapshotForPatch(newValue),
}
}