mobx-keystone
Version:
A MobX powered state management solution based on data trees with first class support for TypeScript, snapshots, patches and much more
452 lines (382 loc) • 12.2 kB
text/typescript
import {
IArrayWillChange,
IArrayWillSplice,
IObservableArray,
intercept,
isObservableArray,
observable,
observe,
} from "mobx"
import { assertCanWrite } from "../action/protection"
import { getGlobalConfig } from "../globalConfig"
import type { ParentPath } from "../parent/path"
import { setParent } from "../parent/setParent"
import { InternalPatchRecorder } from "../patch/emitPatch"
import type { Patch } from "../patch/Patch"
import {
freezeInternalSnapshot,
getInternalSnapshot,
setNewInternalSnapshot,
updateInternalSnapshot,
} from "../snapshot/internal"
import { failure, inDevMode, isArray, 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 tweakArray<T extends any[]>(
value: T,
parentPath: ParentPath<any> | undefined,
doNotTweakChildren: boolean
): T {
const originalArr: ReadonlyArray<any> = value
const arrLn = originalArr.length
const tweakedArr = isObservableArray(originalArr)
? originalArr
: observable.array(undefined, observableOptions)
if (tweakedArr !== originalArr) {
tweakedArr.length = originalArr.length
}
// 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(tweakedArr, untweak)
setParent(
tweakedArr, // value
parentPath,
false, // indexChangeAllowed
false, // isDataObject
// arrays shouldn't be cloned anyway
false // cloneIfApplicable
)
const untransformedSn: any[] = []
untransformedSn.length = arrLn
// substitute initial values by proxied values
for (let i = 0; i < arrLn; i++) {
const v = originalArr[i]
if (isPrimitive(v)) {
if (!doNotTweakChildren) {
setIfDifferent(tweakedArr, i, v)
}
untransformedSn[i] = v
} else {
const path = { parent: tweakedArr, path: i }
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(tweakedArr, i, tweakedValue)
}
const valueSn = getInternalSnapshot(tweakedValue)!
untransformedSn[i] = valueSn.transformed
}
}
setNewInternalSnapshot(tweakedArr, untransformedSn, undefined)
interceptDisposer = intercept(tweakedArr, interceptArrayMutation.bind(undefined, tweakedArr))
observeDisposer = observe(tweakedArr, arrayDidChange)
return tweakedArr as any
}
function mutateSet(k: number, v: unknown, sn: unknown[]) {
sn[k] = v
}
function mutateSplice(index: number, removedCount: number, addedItems: any[], sn: any[]) {
sn.splice(index, removedCount, ...addedItems)
}
const patchRecorder = new InternalPatchRecorder()
function arrayDidChange(change: any /*IArrayDidChange*/) {
const arr = change.object
const oldSnapshot = getInternalSnapshot(arr as Array<unknown>)!.untransformed
patchRecorder.reset()
let mutate: ((sn: any[]) => void) | undefined
switch (change.type) {
case "splice":
mutate = arrayDidChangeSplice(change, oldSnapshot)
break
case "update":
mutate = arrayDidChangeUpdate(change, oldSnapshot)
break
default:
break
}
runTypeCheckingAfterChange(arr, patchRecorder)
if (!runningWithoutSnapshotOrPatches && mutate) {
updateInternalSnapshot(arr, mutate)
patchRecorder.emit(arr)
}
}
const undefinedInsideArrayErrorMsg =
"undefined is not supported inside arrays since it is not serializable in JSON, consider using null instead"
function arrayDidChangeUpdate(change: any /*IArrayDidChange*/, oldSnapshot: any) {
const k = change.index
const val = change.newValue
const oldVal = oldSnapshot[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]
patchRecorder.record(
[
{
op: "replace",
path,
value: freezeInternalSnapshot(newVal),
},
],
[
{
op: "replace",
path,
value: freezeInternalSnapshot(oldVal),
},
]
)
return mutate
}
function arrayDidChangeSplice(change: any /*IArrayDidChange*/, oldSnapshot: any) {
const index = change.index as number
const addedCount = change.addedCount as number
const removedCount = change.removedCount as number
const addedItems: any[] = []
addedItems.length = addedCount
for (let i = 0; i < addedCount; i++) {
const v = change.added[i]
if (isPrimitive(v)) {
addedItems[i] = v
} else {
addedItems[i] = getInternalSnapshot(v)!.transformed
}
}
const oldLen = oldSnapshot.length
const mutate = mutateSplice.bind(undefined, index, removedCount, addedItems)
const patches: Patch[] = []
const invPatches: Patch[] = []
// optimization: if we add as many as we remove then remove/readd instead
// we cannot replace since we might end up in a situation where the same node
// might attempt to be temporarily twice inside the same tree (e.g. sorting)
// it would be faster to keep holes rather than remove/readd, but if we do that then
// validation might fail
if (addedCount === removedCount) {
const readdPatches: Patch[] = []
const readdInvPatches: Patch[] = []
let removed = 0
for (let i = 0; i < addedCount; i++) {
const realIndex = index + i
const newVal = getValueAfterSplice(oldSnapshot, realIndex, index, removedCount, addedItems)
const oldVal = oldSnapshot[realIndex]
if (newVal !== oldVal) {
const removePath = [realIndex - removed]
patches.push({
op: "remove",
path: removePath,
})
invPatches.push({
op: "remove",
path: removePath,
})
removed++
const readdPath = [realIndex]
readdPatches.push({
op: "add",
path: readdPath,
value: freezeInternalSnapshot(newVal),
})
readdInvPatches.push({
op: "add",
path: readdPath,
value: freezeInternalSnapshot(oldVal),
})
}
}
patches.push(...readdPatches)
invPatches.push(...readdInvPatches)
// we need to reverse since inverse patches are applied in reverse
invPatches.reverse()
} else {
const interimLen = oldLen - removedCount
// first remove items
if (removedCount > 0) {
// optimization, when removing from the end set the length instead
const removeUsingSetLength = index >= interimLen
if (removeUsingSetLength) {
patches.push({
op: "replace",
path: ["length"],
value: interimLen,
})
}
for (let i = removedCount - 1; i >= 0; i--) {
const realIndex = index + i
const path = [realIndex]
if (!removeUsingSetLength) {
// remove ...2, 1, 0
patches.push({
op: "remove",
path,
})
}
// add 0, 1, 2... since inverse patches are applied in reverse
invPatches.push({
op: "add",
path,
value: freezeInternalSnapshot(oldSnapshot[realIndex]),
})
}
}
// then add items
if (addedCount > 0) {
// optimization, for inverse patches, when adding from the end set the length to restore instead
const restoreUsingSetLength = index >= interimLen
if (restoreUsingSetLength) {
invPatches.push({
op: "replace",
path: ["length"],
value: interimLen,
})
}
for (let i = 0; i < addedCount; i++) {
const realIndex = index + i
const path = [realIndex]
// add 0, 1, 2...
patches.push({
op: "add",
path,
value: freezeInternalSnapshot(
getValueAfterSplice(oldSnapshot, realIndex, index, removedCount, addedItems)
),
})
// remove ...2, 1, 0 since inverse patches are applied in reverse
if (!restoreUsingSetLength) {
invPatches.push({
op: "remove",
path,
})
}
}
}
}
patchRecorder.record(patches, invPatches)
return mutate
}
// TODO: remove array parameter and just use change.object once mobx update event is fixed
function interceptArrayMutation(
array: IObservableArray,
change: IArrayWillChange | IArrayWillSplice
) {
assertCanWrite()
switch (change.type) {
case "splice":
interceptArrayMutationSplice(change)
break
case "update":
interceptArrayMutationUpdate(change, array)
break
default:
break
}
return change
}
function interceptArrayMutationUpdate(change: IArrayWillChange, array: IObservableArray) {
if (
inDevMode &&
!getGlobalConfig().allowUndefinedArrayElements &&
change.newValue === undefined
) {
throw failure(undefinedInsideArrayErrorMsg)
}
// TODO: should be change.object, but mobx is bugged and doesn't send the proxy
const oldVal = array[change.index]
tweak(oldVal, undefined) // set old prop obj parent to undefined
change.newValue = tweak(change.newValue, { parent: array, path: change.index })
}
function interceptArrayMutationSplice(change: IArrayWillSplice) {
if (inDevMode && !getGlobalConfig().allowUndefinedArrayElements) {
const len = change.added.length
for (let i = 0; i < len; i++) {
const v = change.added[i]
if (v === undefined) {
throw failure(undefinedInsideArrayErrorMsg)
}
}
}
for (let i = 0; i < change.removedCount; i++) {
const removedValue = change.object[change.index + i]
tweak(removedValue, undefined)
}
for (let i = 0; i < change.added.length; i++) {
change.added[i] = tweak(change.added[i], {
parent: change.object,
path: change.index + i,
})
}
// 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 < change.object.length; i++, j++) {
setParent(
change.object[i], // value
{
parent: change.object,
path: j,
}, // parentPath
true, // indexChangeAllowed
false, // isDataObject
// just re-indexing
false // cloneIfApplicable
)
}
}
}
/**
* @internal
*/
export function registerArrayTweaker() {
registerTweaker(TweakerPriority.Array, (value, parentPath) => {
if (isArray(value)) {
return tweakArray(value, parentPath, false)
}
return undefined
})
}
const observableOptions = {
deep: false,
}
function getValueAfterSplice<T>(
array: readonly T[],
i: number,
index: number,
remove: number,
addedItems: readonly T[]
) {
const base = i - index
if (base < 0) {
return array[i]
}
if (base < addedItems.length) {
return addedItems[base]
}
return array[i - addedItems.length + remove]
}