UNPKG

18.4 kBPlain TextView Raw
1import {
2 $mobx,
3 Atom,
4 EMPTY_ARRAY,
5 IAtom,
6 IEnhancer,
7 IInterceptable,
8 IInterceptor,
9 IListenable,
10 Lambda,
11 addHiddenFinalProp,
12 checkIfStateModificationsAreAllowed,
13 createInstanceofPredicate,
14 getNextId,
15 hasInterceptors,
16 hasListeners,
17 interceptChange,
18 isObject,
19 isSpyEnabled,
20 notifyListeners,
21 registerInterceptor,
22 registerListener,
23 spyReportEnd,
24 spyReportStart,
25 allowStateChangesStart,
26 allowStateChangesEnd,
27 assertProxies,
28 reserveArrayBuffer,
29 hasProp,
30 die,
31 globalState
32} from "../internal"
33
34const SPLICE = "splice"
35export const UPDATE = "update"
36export const MAX_SPLICE_SIZE = 10000 // See e.g. https://github.com/mobxjs/mobx/issues/859
37
38export interface IObservableArray<T = any> extends Array<T> {
39 spliceWithArray(index: number, deleteCount?: number, newItems?: T[]): T[]
40 clear(): T[]
41 replace(newItems: T[]): T[]
42 remove(value: T): boolean
43 toJSON(): T[]
44}
45
46interface IArrayBaseChange<T> {
47 object: IObservableArray<T>
48 observableKind: "array"
49 debugObjectName: string
50 index: number
51}
52
53export type IArrayDidChange<T = any> = IArrayUpdate<T> | IArraySplice<T>
54
55export interface IArrayUpdate<T = any> extends IArrayBaseChange<T> {
56 type: "update"
57 newValue: T
58 oldValue: T
59}
60
61export interface IArraySplice<T = any> extends IArrayBaseChange<T> {
62 type: "splice"
63 added: T[]
64 addedCount: number
65 removed: T[]
66 removedCount: number
67}
68
69export interface IArrayWillChange<T = any> {
70 object: IObservableArray<T>
71 index: number
72 type: "update"
73 newValue: T
74}
75
76export interface IArrayWillSplice<T = any> {
77 object: IObservableArray<T>
78 index: number
79 type: "splice"
80 added: T[]
81 removedCount: number
82}
83
84const arrayTraps = {
85 get(target, name) {
86 const adm: ObservableArrayAdministration = target[$mobx]
87 if (name === $mobx) return adm
88 if (name === "length") return adm.getArrayLength_()
89 if (typeof name === "string" && !isNaN(name as any)) {
90 return adm.get_(parseInt(name))
91 }
92 if (hasProp(arrayExtensions, name)) {
93 return arrayExtensions[name]
94 }
95 return target[name]
96 },
97 set(target, name, value): boolean {
98 const adm: ObservableArrayAdministration = target[$mobx]
99 if (name === "length") {
100 adm.setArrayLength_(value)
101 }
102 if (typeof name === "symbol" || isNaN(name)) {
103 target[name] = value
104 } else {
105 // numeric string
106 adm.set_(parseInt(name), value)
107 }
108 return true
109 },
110 preventExtensions() {
111 die(15)
112 }
113}
114
115export class ObservableArrayAdministration
116 implements IInterceptable<IArrayWillChange<any> | IArrayWillSplice<any>>, IListenable {
117 atom_: IAtom
118 readonly values_: any[] = [] // this is the prop that gets proxied, so can't replace it!
119 interceptors_
120 changeListeners_
121 enhancer_: (newV: any, oldV: any | undefined) => any
122 dehancer: any
123 proxy_!: IObservableArray<any>
124 lastKnownLength_ = 0
125
126 constructor(
127 name = __DEV__ ? "ObservableArray@" + getNextId() : "ObservableArray",
128 enhancer: IEnhancer<any>,
129 public owned_: boolean,
130 public legacyMode_: boolean
131 ) {
132 this.atom_ = new Atom(name)
133 this.enhancer_ = (newV, oldV) =>
134 enhancer(newV, oldV, __DEV__ ? name + "[..]" : "ObservableArray[..]")
135 }
136
137 dehanceValue_(value: any): any {
138 if (this.dehancer !== undefined) return this.dehancer(value)
139 return value
140 }
141
142 dehanceValues_(values: any[]): any[] {
143 if (this.dehancer !== undefined && values.length > 0)
144 return values.map(this.dehancer) as any
145 return values
146 }
147
148 intercept_(handler: IInterceptor<IArrayWillChange<any> | IArrayWillSplice<any>>): Lambda {
149 return registerInterceptor<IArrayWillChange<any> | IArrayWillSplice<any>>(this, handler)
150 }
151
152 observe_(
153 listener: (changeData: IArrayDidChange<any>) => void,
154 fireImmediately = false
155 ): Lambda {
156 if (fireImmediately) {
157 listener(<IArraySplice<any>>{
158 observableKind: "array",
159 object: this.proxy_ as any,
160 debugObjectName: this.atom_.name_,
161 type: "splice",
162 index: 0,
163 added: this.values_.slice(),
164 addedCount: this.values_.length,
165 removed: [],
166 removedCount: 0
167 })
168 }
169 return registerListener(this, listener)
170 }
171
172 getArrayLength_(): number {
173 this.atom_.reportObserved()
174 return this.values_.length
175 }
176
177 setArrayLength_(newLength: number) {
178 if (typeof newLength !== "number" || isNaN(newLength) || newLength < 0)
179 die("Out of range: " + newLength)
180 let currentLength = this.values_.length
181 if (newLength === currentLength) return
182 else if (newLength > currentLength) {
183 const newItems = new Array(newLength - currentLength)
184 for (let i = 0; i < newLength - currentLength; i++) newItems[i] = undefined // No Array.fill everywhere...
185 this.spliceWithArray_(currentLength, 0, newItems)
186 } else this.spliceWithArray_(newLength, currentLength - newLength)
187 }
188
189 updateArrayLength_(oldLength: number, delta: number) {
190 if (oldLength !== this.lastKnownLength_) die(16)
191 this.lastKnownLength_ += delta
192 if (this.legacyMode_ && delta > 0) reserveArrayBuffer(oldLength + delta + 1)
193 }
194
195 spliceWithArray_(index: number, deleteCount?: number, newItems?: any[]): any[] {
196 checkIfStateModificationsAreAllowed(this.atom_)
197 const length = this.values_.length
198
199 if (index === undefined) index = 0
200 else if (index > length) index = length
201 else if (index < 0) index = Math.max(0, length + index)
202
203 if (arguments.length === 1) deleteCount = length - index
204 else if (deleteCount === undefined || deleteCount === null) deleteCount = 0
205 else deleteCount = Math.max(0, Math.min(deleteCount, length - index))
206
207 if (newItems === undefined) newItems = EMPTY_ARRAY
208
209 if (hasInterceptors(this)) {
210 const change = interceptChange<IArrayWillSplice<any>>(this as any, {
211 object: this.proxy_ as any,
212 type: SPLICE,
213 index,
214 removedCount: deleteCount,
215 added: newItems
216 })
217 if (!change) return EMPTY_ARRAY
218 deleteCount = change.removedCount
219 newItems = change.added
220 }
221
222 newItems =
223 newItems.length === 0 ? newItems : newItems.map(v => this.enhancer_(v, undefined))
224 if (this.legacyMode_ || __DEV__) {
225 const lengthDelta = newItems.length - deleteCount
226 this.updateArrayLength_(length, lengthDelta) // checks if internal array wasn't modified
227 }
228 const res = this.spliceItemsIntoValues_(index, deleteCount, newItems)
229
230 if (deleteCount !== 0 || newItems.length !== 0)
231 this.notifyArraySplice_(index, newItems, res)
232 return this.dehanceValues_(res)
233 }
234
235 spliceItemsIntoValues_(index: number, deleteCount: number, newItems: any[]): any[] {
236 if (newItems.length < MAX_SPLICE_SIZE) {
237 return this.values_.splice(index, deleteCount, ...newItems)
238 } else {
239 // The items removed by the splice
240 const res = this.values_.slice(index, index + deleteCount)
241 // The items that that should remain at the end of the array
242 let oldItems = this.values_.slice(index + deleteCount)
243 // New length is the previous length + addition count - deletion count
244 this.values_.length += newItems.length - deleteCount
245 for (let i = 0; i < newItems.length; i++) this.values_[index + i] = newItems[i]
246 for (let i = 0; i < oldItems.length; i++)
247 this.values_[index + newItems.length + i] = oldItems[i]
248 return res
249 }
250 }
251
252 notifyArrayChildUpdate_(index: number, newValue: any, oldValue: any) {
253 const notifySpy = !this.owned_ && isSpyEnabled()
254 const notify = hasListeners(this)
255 const change: IArrayDidChange | null =
256 notify || notifySpy
257 ? ({
258 observableKind: "array",
259 object: this.proxy_,
260 type: UPDATE,
261 debugObjectName: this.atom_.name_,
262 index,
263 newValue,
264 oldValue
265 } as const)
266 : null
267
268 // The reason why this is on right hand side here (and not above), is this way the uglifier will drop it, but it won't
269 // cause any runtime overhead in development mode without NODE_ENV set, unless spying is enabled
270 if (__DEV__ && notifySpy) spyReportStart(change!)
271 this.atom_.reportChanged()
272 if (notify) notifyListeners(this, change)
273 if (__DEV__ && notifySpy) spyReportEnd()
274 }
275
276 notifyArraySplice_(index: number, added: any[], removed: any[]) {
277 const notifySpy = !this.owned_ && isSpyEnabled()
278 const notify = hasListeners(this)
279 const change: IArraySplice | null =
280 notify || notifySpy
281 ? ({
282 observableKind: "array",
283 object: this.proxy_,
284 debugObjectName: this.atom_.name_,
285 type: SPLICE,
286 index,
287 removed,
288 added,
289 removedCount: removed.length,
290 addedCount: added.length
291 } as const)
292 : null
293
294 if (__DEV__ && notifySpy) spyReportStart(change!)
295 this.atom_.reportChanged()
296 // conform: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/observe
297 if (notify) notifyListeners(this, change)
298 if (__DEV__ && notifySpy) spyReportEnd()
299 }
300
301 get_(index: number): any | undefined {
302 if (index < this.values_.length) {
303 this.atom_.reportObserved()
304 return this.dehanceValue_(this.values_[index])
305 }
306 console.warn(
307 __DEV__
308 ? `[mobx] Out of bounds read: ${index}`
309 : `[mobx.array] Attempt to read an array index (${index}) that is out of bounds (${this.values_.length}). Please check length first. Out of bound indices will not be tracked by MobX`
310 )
311 }
312
313 set_(index: number, newValue: any) {
314 const values = this.values_
315 if (index < values.length) {
316 // update at index in range
317 checkIfStateModificationsAreAllowed(this.atom_)
318 const oldValue = values[index]
319 if (hasInterceptors(this)) {
320 const change = interceptChange<IArrayWillChange<any>>(this as any, {
321 type: UPDATE,
322 object: this.proxy_ as any, // since "this" is the real array we need to pass its proxy
323 index,
324 newValue
325 })
326 if (!change) return
327 newValue = change.newValue
328 }
329 newValue = this.enhancer_(newValue, oldValue)
330 const changed = newValue !== oldValue
331 if (changed) {
332 values[index] = newValue
333 this.notifyArrayChildUpdate_(index, newValue, oldValue)
334 }
335 } else if (index === values.length) {
336 // add a new item
337 this.spliceWithArray_(index, 0, [newValue])
338 } else {
339 // out of bounds
340 die(17, index, values.length)
341 }
342 }
343}
344
345export function createObservableArray<T>(
346 initialValues: T[] | undefined,
347 enhancer: IEnhancer<T>,
348 name = __DEV__ ? "ObservableArray@" + getNextId() : "ObservableArray",
349 owned = false
350): IObservableArray<T> {
351 assertProxies()
352 const adm = new ObservableArrayAdministration(name, enhancer, owned, false)
353 addHiddenFinalProp(adm.values_, $mobx, adm)
354 const proxy = new Proxy(adm.values_, arrayTraps) as any
355 adm.proxy_ = proxy
356 if (initialValues && initialValues.length) {
357 const prev = allowStateChangesStart(true)
358 adm.spliceWithArray_(0, 0, initialValues)
359 allowStateChangesEnd(prev)
360 }
361 return proxy
362}
363
364// eslint-disable-next-line
365export var arrayExtensions = {
366 clear(): any[] {
367 return this.splice(0)
368 },
369
370 replace(newItems: any[]) {
371 const adm: ObservableArrayAdministration = this[$mobx]
372 return adm.spliceWithArray_(0, adm.values_.length, newItems)
373 },
374
375 // Used by JSON.stringify
376 toJSON(): any[] {
377 return this.slice()
378 },
379
380 /*
381 * functions that do alter the internal structure of the array, (based on lib.es6.d.ts)
382 * since these functions alter the inner structure of the array, the have side effects.
383 * Because the have side effects, they should not be used in computed function,
384 * and for that reason the do not call dependencyState.notifyObserved
385 */
386 splice(index: number, deleteCount?: number, ...newItems: any[]): any[] {
387 const adm: ObservableArrayAdministration = this[$mobx]
388 switch (arguments.length) {
389 case 0:
390 return []
391 case 1:
392 return adm.spliceWithArray_(index)
393 case 2:
394 return adm.spliceWithArray_(index, deleteCount)
395 }
396 return adm.spliceWithArray_(index, deleteCount, newItems)
397 },
398
399 spliceWithArray(index: number, deleteCount?: number, newItems?: any[]): any[] {
400 return (this[$mobx] as ObservableArrayAdministration).spliceWithArray_(
401 index,
402 deleteCount,
403 newItems
404 )
405 },
406
407 push(...items: any[]): number {
408 const adm: ObservableArrayAdministration = this[$mobx]
409 adm.spliceWithArray_(adm.values_.length, 0, items)
410 return adm.values_.length
411 },
412
413 pop() {
414 return this.splice(Math.max(this[$mobx].values_.length - 1, 0), 1)[0]
415 },
416
417 shift() {
418 return this.splice(0, 1)[0]
419 },
420
421 unshift(...items: any[]): number {
422 const adm: ObservableArrayAdministration = this[$mobx]
423 adm.spliceWithArray_(0, 0, items)
424 return adm.values_.length
425 },
426
427 reverse(): any[] {
428 // reverse by default mutates in place before returning the result
429 // which makes it both a 'derivation' and a 'mutation'.
430 if (globalState.trackingDerivation) {
431 die(37, "reverse")
432 }
433 this.replace(this.slice().reverse())
434 return this
435 },
436
437 sort(): any[] {
438 // sort by default mutates in place before returning the result
439 // which goes against all good practices. Let's not change the array in place!
440 if (globalState.trackingDerivation) {
441 die(37, "sort")
442 }
443 const copy = this.slice()
444 copy.sort.apply(copy, arguments)
445 this.replace(copy)
446 return this
447 },
448
449 remove(value: any): boolean {
450 const adm: ObservableArrayAdministration = this[$mobx]
451 const idx = adm.dehanceValues_(adm.values_).indexOf(value)
452 if (idx > -1) {
453 this.splice(idx, 1)
454 return true
455 }
456 return false
457 }
458}
459
460/**
461 * Wrap function from prototype
462 * Without this, everything works as well, but this works
463 * faster as everything works on unproxied values
464 */
465addArrayExtension("concat", simpleFunc)
466addArrayExtension("flat", simpleFunc)
467addArrayExtension("includes", simpleFunc)
468addArrayExtension("indexOf", simpleFunc)
469addArrayExtension("join", simpleFunc)
470addArrayExtension("lastIndexOf", simpleFunc)
471addArrayExtension("slice", simpleFunc)
472addArrayExtension("toString", simpleFunc)
473addArrayExtension("toLocaleString", simpleFunc)
474// map
475addArrayExtension("every", mapLikeFunc)
476addArrayExtension("filter", mapLikeFunc)
477addArrayExtension("find", mapLikeFunc)
478addArrayExtension("findIndex", mapLikeFunc)
479addArrayExtension("flatMap", mapLikeFunc)
480addArrayExtension("forEach", mapLikeFunc)
481addArrayExtension("map", mapLikeFunc)
482addArrayExtension("some", mapLikeFunc)
483// reduce
484addArrayExtension("reduce", reduceLikeFunc)
485addArrayExtension("reduceRight", reduceLikeFunc)
486
487function addArrayExtension(funcName, funcFactory) {
488 if (typeof Array.prototype[funcName] === "function") {
489 arrayExtensions[funcName] = funcFactory(funcName)
490 }
491}
492
493// Report and delegate to dehanced array
494function simpleFunc(funcName) {
495 return function () {
496 const adm: ObservableArrayAdministration = this[$mobx]
497 adm.atom_.reportObserved()
498 const dehancedValues = adm.dehanceValues_(adm.values_)
499 return dehancedValues[funcName].apply(dehancedValues, arguments)
500 }
501}
502
503// Make sure callbacks recieve correct array arg #2326
504function mapLikeFunc(funcName) {
505 return function (callback, thisArg) {
506 const adm: ObservableArrayAdministration = this[$mobx]
507 adm.atom_.reportObserved()
508 const dehancedValues = adm.dehanceValues_(adm.values_)
509 return dehancedValues[funcName]((element, index) => {
510 return callback.call(thisArg, element, index, this)
511 })
512 }
513}
514
515// Make sure callbacks recieve correct array arg #2326
516function reduceLikeFunc(funcName) {
517 return function () {
518 const adm: ObservableArrayAdministration = this[$mobx]
519 adm.atom_.reportObserved()
520 const dehancedValues = adm.dehanceValues_(adm.values_)
521 // #2432 - reduce behavior depends on arguments.length
522 const callback = arguments[0]
523 arguments[0] = (accumulator, currentValue, index) => {
524 return callback(accumulator, currentValue, index, this)
525 }
526 return dehancedValues[funcName].apply(dehancedValues, arguments)
527 }
528}
529
530const isObservableArrayAdministration = createInstanceofPredicate(
531 "ObservableArrayAdministration",
532 ObservableArrayAdministration
533)
534
535export function isObservableArray(thing): thing is IObservableArray<any> {
536 return isObject(thing) && isObservableArrayAdministration(thing[$mobx])
537}