import { $mobx, IEnhancer, IInterceptable, IInterceptor, IListenable, Lambda, ObservableValue, checkIfStateModificationsAreAllowed, createAtom, createInstanceofPredicate, deepEnhancer, getNextId, getPlainObjectKeys, hasInterceptors, hasListeners, interceptChange, isES6Map, isPlainObject, isSpyEnabled, makeIterable, notifyListeners, referenceEnhancer, registerInterceptor, registerListener, spyReportEnd, spyReportStart, stringifyKey, transaction, untracked, onBecomeUnobserved, globalState, die, isFunction, UPDATE, IAtom, PureSpyEvent, initObservable } from "../internal" export interface IKeyValueMap { [key: string]: V } export type IMapEntry = [K, V] export type IReadonlyMapEntry = readonly [K, V] export type IMapEntries = IMapEntry[] export type IReadonlyMapEntries = IReadonlyMapEntry[] export type IMapDidChange = { observableKind: "map"; debugObjectName: string } & ( | { object: ObservableMap name: K // actual the key or index, but this is based on the ancient .observe proposal for consistency type: "update" newValue: V oldValue: V } | { object: ObservableMap name: K type: "add" newValue: V } | { object: ObservableMap name: K type: "delete" oldValue: V } ) export interface IMapWillChange { object: ObservableMap type: "update" | "add" | "delete" name: K newValue?: V } const ObservableMapMarker = {} export const ADD = "add" export const DELETE = "delete" export type IObservableMapInitialValues = | IMapEntries | IReadonlyMapEntries | IKeyValueMap | Map // just extend Map? See also https://gist.github.com/nestharus/13b4d74f2ef4a2f4357dbd3fc23c1e54 // But: https://github.com/mobxjs/mobx/issues/1556 export class ObservableMap implements Map, IInterceptable>, IListenable { [$mobx] = ObservableMapMarker data_!: Map> hasMap_!: Map> // hasMap, not hashMap >-). keysAtom_!: IAtom interceptors_ changeListeners_ dehancer: any constructor( initialData?: IObservableMapInitialValues, public enhancer_: IEnhancer = deepEnhancer, public name_ = __DEV__ ? "ObservableMap@" + getNextId() : "ObservableMap" ) { if (!isFunction(Map)) { die(18) } initObservable(() => { this.keysAtom_ = createAtom(__DEV__ ? `${this.name_}.keys()` : "ObservableMap.keys()") this.data_ = new Map() this.hasMap_ = new Map() if (initialData) { this.merge(initialData) } }) } private has_(key: K): boolean { return this.data_.has(key) } has(key: K): boolean { if (!globalState.trackingDerivation) { return this.has_(key) } let entry = this.hasMap_.get(key) if (!entry) { const newEntry = (entry = new ObservableValue( this.has_(key), referenceEnhancer, __DEV__ ? `${this.name_}.${stringifyKey(key)}?` : "ObservableMap.key?", false )) this.hasMap_.set(key, newEntry) onBecomeUnobserved(newEntry, () => this.hasMap_.delete(key)) } return entry.get() } set(key: K, value: V) { const hasKey = this.has_(key) if (hasInterceptors(this)) { const change = interceptChange>(this, { type: hasKey ? UPDATE : ADD, object: this, newValue: value, name: key }) if (!change) { return this } value = change.newValue! } if (hasKey) { this.updateValue_(key, value) } else { this.addValue_(key, value) } return this } delete(key: K): boolean { checkIfStateModificationsAreAllowed(this.keysAtom_) if (hasInterceptors(this)) { const change = interceptChange>(this, { type: DELETE, object: this, name: key }) if (!change) { return false } } if (this.has_(key)) { const notifySpy = isSpyEnabled() const notify = hasListeners(this) const change: IMapDidChange | null = notify || notifySpy ? { observableKind: "map", debugObjectName: this.name_, type: DELETE, object: this, oldValue: (this.data_.get(key)).value_, name: key } : null if (__DEV__ && notifySpy) { spyReportStart(change! as PureSpyEvent) } // TODO fix type transaction(() => { this.keysAtom_.reportChanged() this.hasMap_.get(key)?.setNewValue_(false) const observable = this.data_.get(key)! observable.setNewValue_(undefined as any) this.data_.delete(key) }) if (notify) { notifyListeners(this, change) } if (__DEV__ && notifySpy) { spyReportEnd() } return true } return false } private updateValue_(key: K, newValue: V | undefined) { const observable = this.data_.get(key)! newValue = (observable as any).prepareNewValue_(newValue) as V if (newValue !== globalState.UNCHANGED) { const notifySpy = isSpyEnabled() const notify = hasListeners(this) const change: IMapDidChange | null = notify || notifySpy ? { observableKind: "map", debugObjectName: this.name_, type: UPDATE, object: this, oldValue: (observable as any).value_, name: key, newValue } : null if (__DEV__ && notifySpy) { spyReportStart(change! as PureSpyEvent) } // TODO fix type observable.setNewValue_(newValue as V) if (notify) { notifyListeners(this, change) } if (__DEV__ && notifySpy) { spyReportEnd() } } } private addValue_(key: K, newValue: V) { checkIfStateModificationsAreAllowed(this.keysAtom_) transaction(() => { const observable = new ObservableValue( newValue, this.enhancer_, __DEV__ ? `${this.name_}.${stringifyKey(key)}` : "ObservableMap.key", false ) this.data_.set(key, observable) newValue = (observable as any).value_ // value might have been changed this.hasMap_.get(key)?.setNewValue_(true) this.keysAtom_.reportChanged() }) const notifySpy = isSpyEnabled() const notify = hasListeners(this) const change: IMapDidChange | null = notify || notifySpy ? { observableKind: "map", debugObjectName: this.name_, type: ADD, object: this, name: key, newValue } : null if (__DEV__ && notifySpy) { spyReportStart(change! as PureSpyEvent) } // TODO fix type if (notify) { notifyListeners(this, change) } if (__DEV__ && notifySpy) { spyReportEnd() } } get(key: K): V | undefined { if (this.has(key)) { return this.dehanceValue_(this.data_.get(key)!.get()) } return this.dehanceValue_(undefined) } private dehanceValue_(value: X): X { if (this.dehancer !== undefined) { return this.dehancer(value) } return value } keys(): IterableIterator { this.keysAtom_.reportObserved() return this.data_.keys() } values(): IterableIterator { const self = this const keys = this.keys() return makeIterable({ next() { const { done, value } = keys.next() return { done, value: done ? (undefined as any) : self.get(value) } } }) } entries(): IterableIterator> { const self = this const keys = this.keys() return makeIterable({ next() { const { done, value } = keys.next() return { done, value: done ? (undefined as any) : ([value, self.get(value)!] as [K, V]) } } }) } [Symbol.iterator]() { return this.entries() } forEach(callback: (value: V, key: K, object: Map) => void, thisArg?) { for (const [key, value] of this) { callback.call(thisArg, value, key, this) } } /** Merge another object into this object, returns this. */ merge(other?: IObservableMapInitialValues): ObservableMap { if (isObservableMap(other)) { other = new Map(other) } transaction(() => { if (isPlainObject(other)) { getPlainObjectKeys(other).forEach((key: any) => this.set(key as K, (other as IKeyValueMap)[key]) ) } else if (Array.isArray(other)) { other.forEach(([key, value]) => this.set(key, value)) } else if (isES6Map(other)) { if (other.constructor !== Map) { die(19, other) } other.forEach((value, key) => this.set(key, value)) } else if (other !== null && other !== undefined) { die(20, other) } }) return this } clear() { transaction(() => { untracked(() => { for (const key of this.keys()) { this.delete(key) } }) }) } replace(values: IObservableMapInitialValues): ObservableMap { // Implementation requirements: // - respect ordering of replacement map // - allow interceptors to run and potentially prevent individual operations // - don't recreate observables that already exist in original map (so we don't destroy existing subscriptions) // - don't _keysAtom.reportChanged if the keys of resulting map are indentical (order matters!) // - note that result map may differ from replacement map due to the interceptors transaction(() => { // Convert to map so we can do quick key lookups const replacementMap = convertToMap(values) const orderedData = new Map() // Used for optimization let keysReportChangedCalled = false // Delete keys that don't exist in replacement map // if the key deletion is prevented by interceptor // add entry at the beginning of the result map for (const key of this.data_.keys()) { // Concurrently iterating/deleting keys // iterator should handle this correctly if (!replacementMap.has(key)) { const deleted = this.delete(key) // Was the key removed? if (deleted) { // _keysAtom.reportChanged() was already called keysReportChangedCalled = true } else { // Delete prevented by interceptor const value = this.data_.get(key) orderedData.set(key, value) } } } // Merge entries for (const [key, value] of replacementMap.entries()) { // We will want to know whether a new key is added const keyExisted = this.data_.has(key) // Add or update value this.set(key, value) // The addition could have been prevent by interceptor if (this.data_.has(key)) { // The update could have been prevented by interceptor // and also we want to preserve existing values // so use value from _data map (instead of replacement map) const value = this.data_.get(key) orderedData.set(key, value) // Was a new key added? if (!keyExisted) { // _keysAtom.reportChanged() was already called keysReportChangedCalled = true } } } // Check for possible key order change if (!keysReportChangedCalled) { if (this.data_.size !== orderedData.size) { // If size differs, keys are definitely modified this.keysAtom_.reportChanged() } else { const iter1 = this.data_.keys() const iter2 = orderedData.keys() let next1 = iter1.next() let next2 = iter2.next() while (!next1.done) { if (next1.value !== next2.value) { this.keysAtom_.reportChanged() break } next1 = iter1.next() next2 = iter2.next() } } } // Use correctly ordered map this.data_ = orderedData }) return this } get size(): number { this.keysAtom_.reportObserved() return this.data_.size } toString(): string { return "[object ObservableMap]" } toJSON(): [K, V][] { return Array.from(this) } get [Symbol.toStringTag]() { return "Map" } /** * 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_(listener: (changes: IMapDidChange) => void, fireImmediately?: boolean): Lambda { if (__DEV__ && fireImmediately === true) { die("`observe` doesn't support fireImmediately=true in combination with maps.") } return registerListener(this, listener) } intercept_(handler: IInterceptor>): Lambda { return registerInterceptor(this, handler) } } // eslint-disable-next-line export var isObservableMap = createInstanceofPredicate("ObservableMap", ObservableMap) as ( thing: any ) => thing is ObservableMap function convertToMap(dataStructure: any): Map { if (isES6Map(dataStructure) || isObservableMap(dataStructure)) { return dataStructure } else if (Array.isArray(dataStructure)) { return new Map(dataStructure) } else if (isPlainObject(dataStructure)) { const map = new Map() for (const key in dataStructure) { map.set(key, dataStructure[key]) } return map } else { return die(21, dataStructure) } }