mobx
Version:
Simple, scalable state management.
387 lines (355 loc) • 12 kB
text/typescript
import {
$mobx,
Atom,
ComputedValue,
IAtom,
IComputedValueOptions,
IEnhancer,
IInterceptable,
IListenable,
Lambda,
ObservableValue,
addHiddenProp,
assertPropertyConfigurable,
createInstanceofPredicate,
deepEnhancer,
endBatch,
getNextId,
hasInterceptors,
hasListeners,
interceptChange,
isObject,
isPlainObject,
isSpyEnabled,
notifyListeners,
referenceEnhancer,
registerInterceptor,
registerListener,
spyReportEnd,
spyReportStart,
startBatch,
stringifyKey,
globalState,
ADD,
UPDATE,
die,
defineProperty,
hasProp
} from "../internal"
export type IObjectDidChange<T = any> = {
observableKind: "object"
name: PropertyKey
object: T
debugObjectName: string
} & (
| {
type: "add"
newValue: any
}
| {
type: "update"
oldValue: any
newValue: any
}
| {
type: "remove"
oldValue: any
}
)
export type IObjectWillChange<T = any> =
| {
object: T
type: "update" | "add"
name: PropertyKey
newValue: any
}
| {
object: T
type: "remove"
name: PropertyKey
}
const REMOVE = "remove"
export class ObservableObjectAdministration
implements IInterceptable<IObjectWillChange>, IListenable {
keysAtom_: IAtom
changeListeners_
interceptors_
proxy_: any
private pendingKeys_: undefined | Map<PropertyKey, ObservableValue<boolean>>
private keysValue_: PropertyKey[] = []
private isStaledKeysValue_ = true
constructor(
public target_: any,
public values_ = new Map<PropertyKey, ObservableValue<any> | ComputedValue<any>>(),
public name_: string,
public defaultEnhancer_: IEnhancer<any>
) {
this.keysAtom_ = new Atom(name_ + ".keys")
}
read_(key: PropertyKey) {
return this.values_.get(key)!.get()
}
write_(key: PropertyKey, newValue) {
const instance = this.target_
const observable = this.values_.get(key)
if (observable instanceof ComputedValue) {
observable.set(newValue)
return
}
// intercept
if (hasInterceptors(this)) {
const change = interceptChange<IObjectWillChange>(this, {
type: UPDATE,
object: this.proxy_ || instance,
name: key,
newValue
})
if (!change) return
newValue = (change as any).newValue
}
newValue = (observable as any).prepareNewValue_(newValue)
// notify spy & observers
if (newValue !== globalState.UNCHANGED) {
const notify = hasListeners(this)
const notifySpy = __DEV__ && isSpyEnabled()
const change: IObjectDidChange | null =
notify || notifySpy
? {
type: UPDATE,
observableKind: "object",
debugObjectName: this.name_,
object: this.proxy_ || instance,
oldValue: (observable as any).value_,
name: key,
newValue
}
: null
if (__DEV__ && notifySpy) spyReportStart(change!)
;(observable as ObservableValue<any>).setNewValue_(newValue)
if (notify) notifyListeners(this, change)
if (__DEV__ && notifySpy) spyReportEnd()
}
}
has_(key: PropertyKey) {
const map = this.pendingKeys_ || (this.pendingKeys_ = new Map())
let entry = map.get(key)
if (entry) return entry.get()
else {
const exists = !!this.values_.get(key)
// Possible optimization: Don't have a separate map for non existing keys,
// but store them in the values map instead, using a special symbol to denote "not existing"
entry = new ObservableValue(
exists,
referenceEnhancer,
`${this.name_}.${stringifyKey(key)}?`,
false
)
map.set(key, entry)
return entry.get() // read to subscribe
}
}
addObservableProp_(
propName: PropertyKey,
newValue,
enhancer: IEnhancer<any> = this.defaultEnhancer_
) {
const { target_: target } = this
if (__DEV__) assertPropertyConfigurable(target, propName)
if (hasInterceptors(this)) {
const change = interceptChange<IObjectWillChange>(this, {
object: this.proxy_ || target,
name: propName,
type: ADD,
newValue
})
if (!change) return
newValue = (change as any).newValue
}
const observable = new ObservableValue(
newValue,
enhancer,
`${this.name_}.${stringifyKey(propName)}`,
false
)
this.values_.set(propName, observable)
newValue = (observable as any).value_ // observableValue might have changed it
defineProperty(target, propName, generateObservablePropConfig(propName))
this.notifyPropertyAddition_(propName, newValue)
}
addComputedProp_(
propertyOwner: any, // where is the property declared?
propName: PropertyKey,
options: IComputedValueOptions<any>
) {
const { target_: target } = this
options.name = options.name || `${this.name_}.${stringifyKey(propName)}`
options.context = this.proxy_ || target
this.values_.set(propName, new ComputedValue(options))
// Doesn't seem we need this condition:
// if (propertyOwner === target || isPropertyConfigurable(propertyOwner, propName))
defineProperty(propertyOwner, propName, generateComputedPropConfig(propName))
}
remove_(key: PropertyKey) {
if (!this.values_.has(key)) return
const { target_: target } = this
if (hasInterceptors(this)) {
const change = interceptChange<IObjectWillChange>(this, {
object: this.proxy_ || target,
name: key,
type: REMOVE
})
if (!change) return
}
try {
startBatch()
const notify = hasListeners(this)
const notifySpy = __DEV__ && isSpyEnabled()
const oldObservable = this.values_.get(key)
const oldValue = oldObservable && oldObservable.get()
oldObservable && oldObservable.set(undefined)
// notify key and keyset listeners
this.reportKeysChanged()
this.values_.delete(key)
if (this.pendingKeys_) {
const entry = this.pendingKeys_.get(key)
if (entry) entry.set(false)
}
// delete the prop
delete this.target_[key]
const change: IObjectDidChange | null =
notify || notifySpy
? ({
type: REMOVE,
observableKind: "object",
object: this.proxy_ || target,
debugObjectName: this.name_,
oldValue: oldValue,
name: key
} as const)
: null
if (__DEV__ && notifySpy) spyReportStart(change!)
if (notify) notifyListeners(this, change)
if (__DEV__ && notifySpy) spyReportEnd()
} finally {
endBatch()
}
}
/**
* Observes this object. Triggers for the events 'add', 'update' and 'delete'.
* See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/observe
* for callback details
*/
observe_(callback: (changes: IObjectDidChange) => void, fireImmediately?: boolean): Lambda {
if (__DEV__ && fireImmediately === true)
die("`observe` doesn't support the fire immediately property for observable objects.")
return registerListener(this, callback)
}
intercept_(handler): Lambda {
return registerInterceptor(this, handler)
}
notifyPropertyAddition_(key: PropertyKey, newValue) {
const notify = hasListeners(this)
const notifySpy = __DEV__ && isSpyEnabled()
const change: IObjectDidChange | null =
notify || notifySpy
? ({
type: ADD,
observableKind: "object",
debugObjectName: this.name_,
object: this.proxy_ || this.target_,
name: key,
newValue
} as const)
: null
if (__DEV__ && notifySpy) spyReportStart(change!)
if (notify) notifyListeners(this, change)
if (__DEV__ && notifySpy) spyReportEnd()
if (this.pendingKeys_) {
const entry = this.pendingKeys_.get(key)
if (entry) entry.set(true)
}
this.reportKeysChanged()
}
getKeys_(): PropertyKey[] {
this.keysAtom_.reportObserved()
if (!this.isStaledKeysValue_) {
return this.keysValue_
}
// return Reflect.ownKeys(this.values) as any
this.keysValue_ = []
for (const [key, value] of this.values_)
if (value instanceof ObservableValue) this.keysValue_.push(key)
if (__DEV__) Object.freeze(this.keysValue_)
this.isStaledKeysValue_ = false
return this.keysValue_
}
private reportKeysChanged() {
this.isStaledKeysValue_ = true
this.keysAtom_.reportChanged()
}
}
export interface IIsObservableObject {
$mobx: ObservableObjectAdministration
}
export function asObservableObject(
target: any,
name: PropertyKey = "",
defaultEnhancer: IEnhancer<any> = deepEnhancer
): ObservableObjectAdministration {
if (hasProp(target, $mobx)) return target[$mobx]
if (__DEV__ && !Object.isExtensible(target))
die("Cannot make the designated object observable; it is not extensible")
if (!isPlainObject(target))
name = (target.constructor.name || "ObservableObject") + "@" + getNextId()
if (!name) name = "ObservableObject@" + getNextId()
const adm = new ObservableObjectAdministration(
target,
new Map(),
stringifyKey(name),
defaultEnhancer
)
addHiddenProp(target, $mobx, adm)
return adm
}
const observablePropertyConfigs = Object.create(null)
const computedPropertyConfigs = Object.create(null)
export function generateObservablePropConfig(propName) {
return (
observablePropertyConfigs[propName] ||
(observablePropertyConfigs[propName] = {
configurable: true,
enumerable: true,
get() {
return this[$mobx].read_(propName)
},
set(v) {
this[$mobx].write_(propName, v)
}
})
)
}
export function generateComputedPropConfig(propName) {
return (
computedPropertyConfigs[propName] ||
(computedPropertyConfigs[propName] = {
configurable: true,
enumerable: false,
get() {
return this[$mobx].read_(propName)
},
set(v) {
this[$mobx].write_(propName, v)
}
})
)
}
const isObservableObjectAdministration = createInstanceofPredicate(
"ObservableObjectAdministration",
ObservableObjectAdministration
)
export function isObservableObject(thing: any): boolean {
if (isObject(thing)) {
return isObservableObjectAdministration((thing as any)[$mobx])
}
return false
}