import { action, type IMapWillChange, type IObjectDidChange, type IObservableArray, intercept, isObservableArray, isObservableObject, type ObservableMap, observable, observe, remove, runInAction, transaction, untracked, } from "mobx" import { assertIsMap, assertIsObservableArray, assertIsObservableObject, failure, getMobxVersion, inDevMode, isArray, } from "../utils" import { setIfDifferent } from "../utils/setIfDifferent" import { tag } from "../utils/tag" type Ts6MapExtras = { getOrInsert(key: K, defaultValue: V): V getOrInsertComputed(key: K, callback: (key: K) => V): V } type ObservableMapWithDataObject = ObservableMap & Ts6MapExtras & { dataObject: TDataObject } function addTs6MapExtras(map: ObservableMap): ObservableMap & Ts6MapExtras { const mapWithExtras = map as ObservableMap & Partial> if (!mapWithExtras.getOrInsert) { mapWithExtras.getOrInsert = function (key: K, defaultValue: V): V { if (this.has(key)) { return this.get(key) as V } this.set(key, defaultValue) return defaultValue } } if (!mapWithExtras.getOrInsertComputed) { mapWithExtras.getOrInsertComputed = function (key: K, callback: (key: K) => V): V { if (this.has(key)) { return this.get(key) as V } const value = callback(key) this.set(key, value) return value } } return mapWithExtras as ObservableMap & Ts6MapExtras } const observableMapBackedByObservableObject = action( (obj: object): ObservableMapWithDataObject => { if (inDevMode) { if (!isObservableObject(obj)) { throw failure("assertion failed: expected an observable object") } } const map = transaction(() => untracked(() => { const map = observable.map() const keys = Object.keys(obj) for (let i = 0; i < keys.length; i++) { const k = keys[i] map.set(k, (obj as any)[k]) } return map }) ) const mapWithExtras = addTs6MapExtras(map) ;(mapWithExtras as any).dataObject = obj let mapAlreadyChanged = false let objectAlreadyChanged = false // when the object changes the map changes observe( obj, action((change: IObjectDidChange) => { if (mapAlreadyChanged) { return } objectAlreadyChanged = true try { switch (change.type) { case "add": case "update": { mapWithExtras.set(change.name as string, change.newValue) break } case "remove": { mapWithExtras.delete(change.name as string) break } default: throw failure(`assertion error: unsupported object change type`) } } finally { objectAlreadyChanged = false } }) ) // when the map changes also change the object intercept( map, action((change: IMapWillChange) => { if (mapAlreadyChanged) { return null } if (objectAlreadyChanged) { return change } mapAlreadyChanged = true try { switch (change.type) { case "add": case "update": { setIfDifferent(obj, change.name, change.newValue) break } case "delete": { remove(obj, change.name) break } default: throw failure(`assertion error: unsupported map change type`) } return change } finally { mapAlreadyChanged = false } }) ) return mapWithExtras as any } ) const observableMapBackedByObservableArray = ( array: IObservableArray<[string, T]> ): ObservableMapWithDataObject => { if (inDevMode) { if (!isObservableArray(array)) { throw failure("assertion failed: expected an observable array") } } const map: ObservableMap = untracked(() => { if (getMobxVersion() >= 6) { return observable.map(array) } else { const map = observable.map() runInAction(() => { array.forEach(([k, v]) => { map.set(k, v) }) }) return map } }) const mapWithExtras = addTs6MapExtras(map) ;(mapWithExtras as any).dataObject = array if (mapWithExtras.size !== array.length) { throw failure("arrays backing a map cannot contain duplicate keys") } let mapAlreadyChanged = false let arrayAlreadyChanged = false // for speed reasons we will just assume distinct values are only once in the array // also we assume tuples themselves are immutable // when the array changes the map changes observe( array, action((change: any /*IArrayDidChange<[string, T]>*/) => { if (mapAlreadyChanged) { return } arrayAlreadyChanged = true try { switch (change.type) { case "splice": { { const removed = change.removed for (let i = 0; i < removed.length; i++) { mapWithExtras.delete(removed[i][0]) } } { const added = change.added for (let i = 0; i < added.length; i++) { mapWithExtras.set(added[i][0], added[i][1]) } } break } case "update": { mapWithExtras.delete(change.oldValue[0]) mapWithExtras.set(change.newValue[0], change.newValue[1]) break } default: throw failure(`assertion error: unsupported array change type`) } } finally { arrayAlreadyChanged = false } }) ) // when the map changes also change the array intercept( map, action((change: IMapWillChange) => { if (mapAlreadyChanged) { return null } if (arrayAlreadyChanged) { return change } mapAlreadyChanged = true try { switch (change.type) { case "update": { // replace the whole tuple to keep tuple immutability const i = array.findIndex((i) => i[0] === change.name) array[i] = [change.name, change.newValue!] break } case "add": { array.push([change.name, change.newValue!]) break } case "delete": { const i = array.findIndex((i) => i[0] === change.name) if (i >= 0) { array.splice(i, 1) } break } default: throw failure(`assertion error: unsupported map change type`) } return change } finally { mapAlreadyChanged = false } }) ) return mapWithExtras as any } const asMapTag = tag((objOrArray: Record | Array<[string, any]>) => { if (isArray(objOrArray)) { assertIsObservableArray(objOrArray, "objOrArray") return observableMapBackedByObservableArray(objOrArray) } else { assertIsObservableObject(objOrArray, "objOrArray") return observableMapBackedByObservableObject(objOrArray) } }) /** * Wraps an observable object or a tuple array to offer a map like interface. * * @param array Array. */ export function asMap(array: Array<[K, V]>): ObservableMapWithDataObject> /** * Wraps an observable object or a tuple array to offer a map like interface. * * @param object Object. */ export function asMap( object: Record ): ObservableMapWithDataObject> /** * Wraps an observable object or a tuple array to offer a map like interface. * * @param objOrArray Object or array. */ export function asMap( objOrArray: Record | Array<[unknown, unknown]> ): ObservableMapWithDataObject { return asMapTag.for(objOrArray) as any } /** * Converts a map to an object. If the map is a collection wrapper it will return the backed object. * * @param map */ export function mapToObject(map: Pick, "forEach">): Record { assertIsMap(map, "map") const dataObject = (map as any).dataObject if (dataObject && !isArray(dataObject)) { return dataObject } const obj: Record = {} map.forEach((v, k) => { obj[k] = v }) return obj } /** * Converts a map to an array. If the map is a collection wrapper it will return the backed array. * * @param map */ export function mapToArray(map: Pick, "forEach">): Array<[K, V]> { assertIsMap(map, "map") const dataObject = (map as any).dataObject if (dataObject && isArray(dataObject)) { return dataObject } const arr: [K, V][] = [] map.forEach((v, k) => { arr.push([k, v]) }) return arr }