mobx-bonsai
Version:
A fast lightweight alternative to MobX-State-Tree + Y.js two-way binding
281 lines (231 loc) • 6.64 kB
text/typescript
import { action, computed, createAtom, IAtom, IComputedValue, observable } from "mobx"
import { getOrCreate } from "../utils/mapUtils"
import { assertIsNode, isNode } from "./node"
import { getParent } from "./tree/getParent"
/**
* A context.
*/
export interface Context<T> {
/**
* Gets the context default value.
*
* @returns
*/
getDefault(): T
/**
* Sets the context default value.
* @param value
*/
setDefault(value: T): void
/**
* Sets the context default value resolver.
* @param valueFn
*/
setDefaultComputed(valueFn: () => T): void
/**
* Gets the context value for a given node.
* @param node
*/
get(node: object): T
/**
* Gets node that will provide the context value, or `undefined`
* if it comes from the default.
* @param node
*/
getProviderNode(node: object): object | undefined
/**
* Sets the context value for a given node, effectively making it a provider.
* @param node
* @param value
*/
set(node: object, value: T): void
/**
* Sets the context value resolver for a given node, effectively making it a provider.
* @param node
* @param valueFn
*/
setComputed(node: object, valueFn: () => T): void
/**
* Unsets the context value for a given node, therefore it won't be a provider anymore.
* @param node
*/
unset(node: object): void
/**
* Applies a value override while the given function is running and, if a node is returned,
* sets the node as a provider of the value.
*
* @template R
* @param fn Function to run.
* @param value Value to apply.
* @returns The value returned from the function.
*/
apply<R>(fn: () => R, value: T): R
/**
* Applies a computed value override while the given function is running and, if a node is returned,
* sets the node as a provider of the computed value.
*
* @template R
* @param fn Function to run.
* @param value Value to apply.
* @returns The value returned from the function.
*/
applyComputed<R>(fn: () => R, valueFn: () => T): R
}
type ContextValue<T> =
| {
type: "value"
value: T
}
| {
type: "computed"
value: IComputedValue<T>
}
function resolveContextValue<T>(contextValue: ContextValue<T>): T {
if (contextValue.type === "value") {
return contextValue.value
} else {
return contextValue.value.get()
}
}
const createContextValueAtom = () => createAtom("contextValue")
class ContextClass<T> implements Context<T> {
private defaultContextValue = observable.box<ContextValue<T>>(undefined, { deep: false })
private overrideContextValue = observable.box<ContextValue<T> | undefined>(undefined, {
deep: false,
})
private readonly nodeContextValue = new WeakMap<object, ContextValue<T>>()
private readonly nodeAtom = new WeakMap<object, IAtom>()
private reportNodeAtomObserved(node: object) {
getOrCreate(this.nodeAtom, node, createContextValueAtom).reportObserved()
}
private reportNodeAtomChanged(node: object) {
this.nodeAtom.get(node)?.reportChanged()
}
private internalGet(node: object): T {
this.reportNodeAtomObserved(node)
const obsForNode = this.nodeContextValue.get(node)
if (obsForNode) {
return resolveContextValue(obsForNode)
}
const parent = getParent(node)
if (!parent) {
const overrideValue = this.overrideContextValue.get()
if (overrideValue) {
return resolveContextValue(overrideValue)
}
return this.getDefault()
}
return this.internalGet(parent)
}
get(node: object) {
assertIsNode(node, "node")
return this.internalGet(node)
}
private internalGetProviderNode(node: object): object | undefined {
this.reportNodeAtomObserved(node)
const obsForNode = this.nodeContextValue.get(node)
if (obsForNode) {
return node
}
const parent = getParent(node)
if (!parent) {
return undefined
}
return this.internalGetProviderNode(parent)
}
getProviderNode(node: object): object | undefined {
assertIsNode(node, "node")
return this.internalGetProviderNode(node)
}
getDefault(): T {
return resolveContextValue(this.defaultContextValue.get()!)
}
setDefault = action((value: T) => {
this.defaultContextValue.set({
type: "value",
value,
})
})
setDefaultComputed = action((valueFn: () => T) => {
this.defaultContextValue.set({
type: "computed",
value: computed(valueFn),
})
})
set = action((node: object, value: T) => {
assertIsNode(node, "node")
this.nodeContextValue.set(node, {
type: "value",
value,
})
this.reportNodeAtomChanged(node)
})
private _setComputed(node: object, computedValueFn: IComputedValue<T>) {
assertIsNode(node, "node")
this.nodeContextValue.set(node, { type: "computed", value: computedValueFn })
this.reportNodeAtomChanged(node)
}
setComputed = action((node: object, valueFn: () => T) => {
this._setComputed(node, computed(valueFn))
})
unset = action((node: object) => {
assertIsNode(node, "node")
this.nodeContextValue.delete(node)
this.reportNodeAtomChanged(node)
})
apply = action(<R>(fn: () => R, value: T): R => {
const old = this.overrideContextValue.get()
this.overrideContextValue.set({
type: "value",
value,
})
try {
const ret = fn()
if (isNode(ret)) {
this.set(ret as object, value)
}
return ret
} finally {
this.overrideContextValue.set(old)
}
})
applyComputed = action(<R>(fn: () => R, valueFn: () => T): R => {
const computedValueFn = computed(valueFn)
const old = this.overrideContextValue.get()
this.overrideContextValue.set({
type: "computed",
value: computedValueFn,
})
try {
const ret = fn()
if (isNode(ret)) {
this._setComputed(ret as object, computedValueFn)
}
return ret
} finally {
this.overrideContextValue.set(old)
}
})
constructor(defaultValue?: T) {
this.setDefault(defaultValue as T)
}
}
/**
* Creates a new context with no default value, thus making its default value undefined.
*
* @template T Context value type.
* @returns
*/
export function createContext<T>(): Context<T | undefined>
/**
* Creates a new context with a default value.
*
* @template T Context value type.
* @param defaultValue
* @returns
*/
export function createContext<T>(defaultValue: T): Context<T>
// base
export function createContext<T>(defaultValue?: T): Context<T> {
return new ContextClass(defaultValue)
}