UNPKG

25.1 kBPlain TextView Raw
1import {
2 CreateObservableOptions,
3 getAnnotationFromOptions,
4 propagateChanged,
5 isAnnotation,
6 $mobx,
7 Atom,
8 Annotation,
9 ComputedValue,
10 IAtom,
11 IComputedValueOptions,
12 IEnhancer,
13 IInterceptable,
14 IListenable,
15 Lambda,
16 ObservableValue,
17 addHiddenProp,
18 createInstanceofPredicate,
19 endBatch,
20 getNextId,
21 hasInterceptors,
22 hasListeners,
23 interceptChange,
24 isObject,
25 isPlainObject,
26 isSpyEnabled,
27 notifyListeners,
28 referenceEnhancer,
29 registerInterceptor,
30 registerListener,
31 spyReportEnd,
32 spyReportStart,
33 startBatch,
34 stringifyKey,
35 globalState,
36 ADD,
37 UPDATE,
38 die,
39 hasProp,
40 getDescriptor,
41 storedAnnotationsSymbol,
42 ownKeys,
43 isOverride,
44 defineProperty,
45 autoAnnotation,
46 getAdministration,
47 getDebugName,
48 objectPrototype,
49 MakeResult
50} from "../internal"
51
52const descriptorCache = Object.create(null)
53
54export type IObjectDidChange<T = any> = {
55 observableKind: "object"
56 name: PropertyKey
57 object: T
58 debugObjectName: string
59} & (
60 | {
61 type: "add"
62 newValue: any
63 }
64 | {
65 type: "update"
66 oldValue: any
67 newValue: any
68 }
69 | {
70 type: "remove"
71 oldValue: any
72 }
73)
74
75export type IObjectWillChange<T = any> =
76 | {
77 object: T
78 type: "update" | "add"
79 name: PropertyKey
80 newValue: any
81 }
82 | {
83 object: T
84 type: "remove"
85 name: PropertyKey
86 }
87
88const REMOVE = "remove"
89
90export class ObservableObjectAdministration
91 implements IInterceptable<IObjectWillChange>, IListenable {
92 keysAtom_: IAtom
93 changeListeners_
94 interceptors_
95 proxy_: any
96 isPlainObject_: boolean
97 appliedAnnotations_?: object
98 private pendingKeys_: undefined | Map<PropertyKey, ObservableValue<boolean>>
99
100 constructor(
101 public target_: any,
102 public values_ = new Map<PropertyKey, ObservableValue<any> | ComputedValue<any>>(),
103 public name_: string,
104 // Used anytime annotation is not explicitely provided
105 public defaultAnnotation_: Annotation = autoAnnotation
106 ) {
107 this.keysAtom_ = new Atom(__DEV__ ? `${this.name_}.keys` : "ObservableObject.keys")
108 // Optimization: we use this frequently
109 this.isPlainObject_ = isPlainObject(this.target_)
110 if (__DEV__ && !isAnnotation(this.defaultAnnotation_)) {
111 die(`defaultAnnotation must be valid annotation`)
112 }
113 if (__DEV__) {
114 // Prepare structure for tracking which fields were already annotated
115 this.appliedAnnotations_ = {}
116 }
117 }
118
119 getObservablePropValue_(key: PropertyKey): any {
120 return this.values_.get(key)!.get()
121 }
122
123 setObservablePropValue_(key: PropertyKey, newValue): boolean | null {
124 const observable = this.values_.get(key)
125 if (observable instanceof ComputedValue) {
126 observable.set(newValue)
127 return true
128 }
129
130 // intercept
131 if (hasInterceptors(this)) {
132 const change = interceptChange<IObjectWillChange>(this, {
133 type: UPDATE,
134 object: this.proxy_ || this.target_,
135 name: key,
136 newValue
137 })
138 if (!change) return null
139 newValue = (change as any).newValue
140 }
141 newValue = (observable as any).prepareNewValue_(newValue)
142
143 // notify spy & observers
144 if (newValue !== globalState.UNCHANGED) {
145 const notify = hasListeners(this)
146 const notifySpy = __DEV__ && isSpyEnabled()
147 const change: IObjectDidChange | null =
148 notify || notifySpy
149 ? {
150 type: UPDATE,
151 observableKind: "object",
152 debugObjectName: this.name_,
153 object: this.proxy_ || this.target_,
154 oldValue: (observable as any).value_,
155 name: key,
156 newValue
157 }
158 : null
159
160 if (__DEV__ && notifySpy) spyReportStart(change!)
161 ;(observable as ObservableValue<any>).setNewValue_(newValue)
162 if (notify) notifyListeners(this, change)
163 if (__DEV__ && notifySpy) spyReportEnd()
164 }
165 return true
166 }
167
168 get_(key: PropertyKey): any {
169 if (globalState.trackingDerivation && !hasProp(this.target_, key)) {
170 // Key doesn't exist yet, subscribe for it in case it's added later
171 this.has_(key)
172 }
173 return this.target_[key]
174 }
175
176 /**
177 * @param {PropertyKey} key
178 * @param {any} value
179 * @param {Annotation|boolean} annotation true - use default annotation, false - copy as is
180 * @param {boolean} proxyTrap whether it's called from proxy trap
181 * @returns {boolean|null} true on success, false on failure (proxyTrap + non-configurable), null when cancelled by interceptor
182 */
183 set_(key: PropertyKey, value: any, proxyTrap: boolean = false): boolean | null {
184 // Don't use .has(key) - we care about own
185 if (hasProp(this.target_, key)) {
186 // Existing prop
187 if (this.values_.has(key)) {
188 // Observable (can be intercepted)
189 return this.setObservablePropValue_(key, value)
190 } else if (proxyTrap) {
191 // Non-observable - proxy
192 return Reflect.set(this.target_, key, value)
193 } else {
194 // Non-observable
195 this.target_[key] = value
196 return true
197 }
198 } else {
199 // New prop
200 return this.extend_(
201 key,
202 { value, enumerable: true, writable: true, configurable: true },
203 this.defaultAnnotation_,
204 proxyTrap
205 )
206 }
207 }
208
209 // Trap for "in"
210 has_(key: PropertyKey): boolean {
211 if (!globalState.trackingDerivation) {
212 // Skip key subscription outside derivation
213 return key in this.target_
214 }
215 this.pendingKeys_ ||= new Map()
216 let entry = this.pendingKeys_.get(key)
217 if (!entry) {
218 entry = new ObservableValue(
219 key in this.target_,
220 referenceEnhancer,
221 __DEV__ ? `${this.name_}.${stringifyKey(key)}?` : "ObservableObject.key?",
222 false
223 )
224 this.pendingKeys_.set(key, entry)
225 }
226 return entry.get()
227 }
228
229 /**
230 * @param {PropertyKey} key
231 * @param {Annotation|boolean} annotation true - use default annotation, false - ignore prop
232 */
233 make_(key: PropertyKey, annotation: Annotation | boolean): void {
234 if (annotation === true) {
235 annotation = this.defaultAnnotation_
236 }
237 if (annotation === false) {
238 return
239 }
240 assertAnnotable(this, annotation, key)
241 if (!(key in this.target_)) {
242 // Throw on missing key, except for decorators:
243 // Decorator annotations are collected from whole prototype chain.
244 // When called from super() some props may not exist yet.
245 // However we don't have to worry about missing prop,
246 // because the decorator must have been applied to something.
247 if (this.target_[storedAnnotationsSymbol]?.[key]) {
248 return // will be annotated by subclass constructor
249 } else {
250 die(1, annotation.annotationType_, `${this.name_}.${key.toString()}`)
251 }
252 }
253 let source = this.target_
254 while (source && source !== objectPrototype) {
255 const descriptor = getDescriptor(source, key)
256 if (descriptor) {
257 const outcome = annotation.make_(this, key, descriptor, source)
258 if (outcome === MakeResult.Cancel) return
259 if (outcome === MakeResult.Break) break
260 }
261 source = Object.getPrototypeOf(source)
262 }
263 recordAnnotationApplied(this, annotation, key)
264 }
265
266 /**
267 * @param {PropertyKey} key
268 * @param {PropertyDescriptor} descriptor
269 * @param {Annotation|boolean} annotation true - use default annotation, false - copy as is
270 * @param {boolean} proxyTrap whether it's called from proxy trap
271 * @returns {boolean|null} true on success, false on failure (proxyTrap + non-configurable), null when cancelled by interceptor
272 */
273 extend_(
274 key: PropertyKey,
275 descriptor: PropertyDescriptor,
276 annotation: Annotation | boolean,
277 proxyTrap: boolean = false
278 ): boolean | null {
279 if (annotation === true) {
280 annotation = this.defaultAnnotation_
281 }
282 if (annotation === false) {
283 return this.defineProperty_(key, descriptor, proxyTrap)
284 }
285 assertAnnotable(this, annotation, key)
286 const outcome = annotation.extend_(this, key, descriptor, proxyTrap)
287 if (outcome) {
288 recordAnnotationApplied(this, annotation, key)
289 }
290 return outcome
291 }
292
293 /**
294 * @param {PropertyKey} key
295 * @param {PropertyDescriptor} descriptor
296 * @param {boolean} proxyTrap whether it's called from proxy trap
297 * @returns {boolean|null} true on success, false on failure (proxyTrap + non-configurable), null when cancelled by interceptor
298 */
299 defineProperty_(
300 key: PropertyKey,
301 descriptor: PropertyDescriptor,
302 proxyTrap: boolean = false
303 ): boolean | null {
304 try {
305 startBatch()
306
307 // Delete
308 const deleteOutcome = this.delete_(key)
309 if (!deleteOutcome) {
310 // Failure or intercepted
311 return deleteOutcome
312 }
313
314 // ADD interceptor
315 if (hasInterceptors(this)) {
316 const change = interceptChange<IObjectWillChange>(this, {
317 object: this.proxy_ || this.target_,
318 name: key,
319 type: ADD,
320 newValue: descriptor.value
321 })
322 if (!change) return null
323 const { newValue } = change as any
324 if (descriptor.value !== newValue) {
325 descriptor = {
326 ...descriptor,
327 value: newValue
328 }
329 }
330 }
331
332 // Define
333 if (proxyTrap) {
334 if (!Reflect.defineProperty(this.target_, key, descriptor)) {
335 return false
336 }
337 } else {
338 defineProperty(this.target_, key, descriptor)
339 }
340
341 // Notify
342 this.notifyPropertyAddition_(key, descriptor.value)
343 } finally {
344 endBatch()
345 }
346 return true
347 }
348
349 // If original descriptor becomes relevant, move this to annotation directly
350 defineObservableProperty_(
351 key: PropertyKey,
352 value: any,
353 enhancer: IEnhancer<any>,
354 proxyTrap: boolean = false
355 ): boolean | null {
356 try {
357 startBatch()
358
359 // Delete
360 const deleteOutcome = this.delete_(key)
361 if (!deleteOutcome) {
362 // Failure or intercepted
363 return deleteOutcome
364 }
365
366 // ADD interceptor
367 if (hasInterceptors(this)) {
368 const change = interceptChange<IObjectWillChange>(this, {
369 object: this.proxy_ || this.target_,
370 name: key,
371 type: ADD,
372 newValue: value
373 })
374 if (!change) return null
375 value = (change as any).newValue
376 }
377
378 const cachedDescriptor = getCachedObservablePropDescriptor(key)
379 const descriptor = {
380 configurable: globalState.safeDescriptors ? this.isPlainObject_ : true,
381 enumerable: true,
382 get: cachedDescriptor.get,
383 set: cachedDescriptor.set
384 }
385
386 // Define
387 if (proxyTrap) {
388 if (!Reflect.defineProperty(this.target_, key, descriptor)) {
389 return false
390 }
391 } else {
392 defineProperty(this.target_, key, descriptor)
393 }
394
395 const observable = new ObservableValue(
396 value,
397 enhancer,
398 __DEV__ ? `${this.name_}.${key.toString()}` : "ObservableObject.key",
399 false
400 )
401
402 this.values_.set(key, observable)
403
404 // Notify (value possibly changed by ObservableValue)
405 this.notifyPropertyAddition_(key, observable.value_)
406 } finally {
407 endBatch()
408 }
409 return true
410 }
411
412 // If original descriptor becomes relevant, move this to annotation directly
413 defineComputedProperty_(
414 key: PropertyKey,
415 options: IComputedValueOptions<any>,
416 proxyTrap: boolean = false
417 ): boolean | null {
418 try {
419 startBatch()
420
421 // Delete
422 const deleteOutcome = this.delete_(key)
423 if (!deleteOutcome) {
424 // Failure or intercepted
425 return deleteOutcome
426 }
427
428 // ADD interceptor
429 if (hasInterceptors(this)) {
430 const change = interceptChange<IObjectWillChange>(this, {
431 object: this.proxy_ || this.target_,
432 name: key,
433 type: ADD,
434 newValue: undefined
435 })
436 if (!change) return null
437 }
438 options.name ||= __DEV__ ? `${this.name_}.${key.toString()}` : "ObservableObject.key"
439 options.context = this.proxy_ || this.target_
440 const cachedDescriptor = getCachedObservablePropDescriptor(key)
441 const descriptor = {
442 configurable: globalState.safeDescriptors ? this.isPlainObject_ : true,
443 enumerable: false,
444 get: cachedDescriptor.get,
445 set: cachedDescriptor.set
446 }
447
448 // Define
449 if (proxyTrap) {
450 if (!Reflect.defineProperty(this.target_, key, descriptor)) {
451 return false
452 }
453 } else {
454 defineProperty(this.target_, key, descriptor)
455 }
456
457 this.values_.set(key, new ComputedValue(options))
458
459 // Notify
460 this.notifyPropertyAddition_(key, undefined)
461 } finally {
462 endBatch()
463 }
464 return true
465 }
466
467 /**
468 * @param {PropertyKey} key
469 * @param {PropertyDescriptor} descriptor
470 * @param {boolean} proxyTrap whether it's called from proxy trap
471 * @returns {boolean|null} true on success, false on failure (proxyTrap + non-configurable), null when cancelled by interceptor
472 */
473 delete_(key: PropertyKey, proxyTrap: boolean = false): boolean | null {
474 // No such prop
475 if (!hasProp(this.target_, key)) {
476 return true
477 }
478
479 // Intercept
480 if (hasInterceptors(this)) {
481 const change = interceptChange<IObjectWillChange>(this, {
482 object: this.proxy_ || this.target_,
483 name: key,
484 type: REMOVE
485 })
486 // Cancelled
487 if (!change) return null
488 }
489
490 // Delete
491 try {
492 startBatch()
493 const notify = hasListeners(this)
494 const notifySpy = __DEV__ && isSpyEnabled()
495 const observable = this.values_.get(key)
496 // Value needed for spies/listeners
497 let value = undefined
498 // Optimization: don't pull the value unless we will need it
499 if (!observable && (notify || notifySpy)) {
500 value = getDescriptor(this.target_, key)?.value
501 }
502 // delete prop (do first, may fail)
503 if (proxyTrap) {
504 if (!Reflect.deleteProperty(this.target_, key)) {
505 return false
506 }
507 } else {
508 delete this.target_[key]
509 }
510 // Allow re-annotating this field
511 if (__DEV__) {
512 delete this.appliedAnnotations_![key]
513 }
514 // Clear observable
515 if (observable) {
516 this.values_.delete(key)
517 // for computed, value is undefined
518 if (observable instanceof ObservableValue) {
519 value = observable.value_
520 }
521 // Notify: autorun(() => obj[key]), see #1796
522 propagateChanged(observable)
523 }
524 // Notify "keys/entries/values" observers
525 this.keysAtom_.reportChanged()
526
527 // Notify "has" observers
528 // "in" as it may still exist in proto
529 this.pendingKeys_?.get(key)?.set(key in this.target_)
530
531 // Notify spies/listeners
532 if (notify || notifySpy) {
533 const change: IObjectDidChange = {
534 type: REMOVE,
535 observableKind: "object",
536 object: this.proxy_ || this.target_,
537 debugObjectName: this.name_,
538 oldValue: value,
539 name: key
540 }
541 if (__DEV__ && notifySpy) spyReportStart(change!)
542 if (notify) notifyListeners(this, change)
543 if (__DEV__ && notifySpy) spyReportEnd()
544 }
545 } finally {
546 endBatch()
547 }
548 return true
549 }
550
551 /**
552 * Observes this object. Triggers for the events 'add', 'update' and 'delete'.
553 * See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/observe
554 * for callback details
555 */
556 observe_(callback: (changes: IObjectDidChange) => void, fireImmediately?: boolean): Lambda {
557 if (__DEV__ && fireImmediately === true)
558 die("`observe` doesn't support the fire immediately property for observable objects.")
559 return registerListener(this, callback)
560 }
561
562 intercept_(handler): Lambda {
563 return registerInterceptor(this, handler)
564 }
565
566 notifyPropertyAddition_(key: PropertyKey, value: any) {
567 const notify = hasListeners(this)
568 const notifySpy = __DEV__ && isSpyEnabled()
569 if (notify || notifySpy) {
570 const change: IObjectDidChange | null =
571 notify || notifySpy
572 ? ({
573 type: ADD,
574 observableKind: "object",
575 debugObjectName: this.name_,
576 object: this.proxy_ || this.target_,
577 name: key,
578 newValue: value
579 } as const)
580 : null
581
582 if (__DEV__ && notifySpy) spyReportStart(change!)
583 if (notify) notifyListeners(this, change)
584 if (__DEV__ && notifySpy) spyReportEnd()
585 }
586
587 this.pendingKeys_?.get(key)?.set(true)
588
589 // Notify "keys/entries/values" observers
590 this.keysAtom_.reportChanged()
591 }
592
593 ownKeys_(): PropertyKey[] {
594 this.keysAtom_.reportObserved()
595 return ownKeys(this.target_)
596 }
597
598 keys_(): PropertyKey[] {
599 // Returns enumerable && own, but unfortunately keysAtom will report on ANY key change.
600 // There is no way to distinguish between Object.keys(object) and Reflect.ownKeys(object) - both are handled by ownKeys trap.
601 // We can either over-report in Object.keys(object) or under-report in Reflect.ownKeys(object)
602 // We choose to over-report in Object.keys(object), because:
603 // - typically it's used with simple data objects
604 // - when symbolic/non-enumerable keys are relevant Reflect.ownKeys works as expected
605 this.keysAtom_.reportObserved()
606 return Object.keys(this.target_)
607 }
608}
609
610export interface IIsObservableObject {
611 $mobx: ObservableObjectAdministration
612}
613
614export function asObservableObject(
615 target: any,
616 options?: CreateObservableOptions
617): IIsObservableObject {
618 if (__DEV__ && options && isObservableObject(target)) {
619 die(`Options can't be provided for already observable objects.`)
620 }
621
622 if (hasProp(target, $mobx)) {
623 if (__DEV__ && !(getAdministration(target) instanceof ObservableObjectAdministration)) {
624 die(
625 `Cannot convert '${getDebugName(target)}' into observable object:` +
626 `\nThe target is already observable of different type.` +
627 `\nExtending builtins is not supported.`
628 )
629 }
630 return target
631 }
632
633 if (__DEV__ && !Object.isExtensible(target))
634 die("Cannot make the designated object observable; it is not extensible")
635
636 const name =
637 options?.name ??
638 (__DEV__
639 ? `${
640 isPlainObject(target) ? "ObservableObject" : target.constructor.name
641 }@${getNextId()}`
642 : "ObservableObject")
643
644 const adm = new ObservableObjectAdministration(
645 target,
646 new Map(),
647 String(name),
648 getAnnotationFromOptions(options)
649 )
650
651 addHiddenProp(target, $mobx, adm)
652
653 return target
654}
655
656const isObservableObjectAdministration = createInstanceofPredicate(
657 "ObservableObjectAdministration",
658 ObservableObjectAdministration
659)
660
661function getCachedObservablePropDescriptor(key) {
662 return (
663 descriptorCache[key] ||
664 (descriptorCache[key] = {
665 get() {
666 return this[$mobx].getObservablePropValue_(key)
667 },
668 set(value) {
669 return this[$mobx].setObservablePropValue_(key, value)
670 }
671 })
672 )
673}
674
675export function isObservableObject(thing: any): boolean {
676 if (isObject(thing)) {
677 return isObservableObjectAdministration((thing as any)[$mobx])
678 }
679 return false
680}
681
682export function recordAnnotationApplied(
683 adm: ObservableObjectAdministration,
684 annotation: Annotation,
685 key: PropertyKey
686) {
687 if (__DEV__) {
688 adm.appliedAnnotations_![key] = annotation
689 }
690 // Remove applied decorator annotation so we don't try to apply it again in subclass constructor
691 delete adm.target_[storedAnnotationsSymbol]?.[key]
692}
693
694function assertAnnotable(
695 adm: ObservableObjectAdministration,
696 annotation: Annotation,
697 key: PropertyKey
698) {
699 // Valid annotation
700 if (__DEV__ && !isAnnotation(annotation)) {
701 die(`Cannot annotate '${adm.name_}.${key.toString()}': Invalid annotation.`)
702 }
703
704 /*
705 // Configurable, not sealed, not frozen
706 // Possibly not needed, just a little better error then the one thrown by engine.
707 // Cases where this would be useful the most (subclass field initializer) are not interceptable by this.
708 if (__DEV__) {
709 const configurable = getDescriptor(adm.target_, key)?.configurable
710 const frozen = Object.isFrozen(adm.target_)
711 const sealed = Object.isSealed(adm.target_)
712 if (!configurable || frozen || sealed) {
713 const fieldName = `${adm.name_}.${key.toString()}`
714 const requestedAnnotationType = annotation.annotationType_
715 let error = `Cannot apply '${requestedAnnotationType}' to '${fieldName}':`
716 if (frozen) {
717 error += `\nObject is frozen.`
718 }
719 if (sealed) {
720 error += `\nObject is sealed.`
721 }
722 if (!configurable) {
723 error += `\nproperty is not configurable.`
724 // Mention only if caused by us to avoid confusion
725 if (hasProp(adm.appliedAnnotations!, key)) {
726 error += `\nTo prevent accidental re-definition of a field by a subclass, `
727 error += `all annotated fields of non-plain objects (classes) are not configurable.`
728 }
729 }
730 die(error)
731 }
732 }
733 */
734
735 // Not annotated
736 if (__DEV__ && !isOverride(annotation) && hasProp(adm.appliedAnnotations_!, key)) {
737 const fieldName = `${adm.name_}.${key.toString()}`
738 const currentAnnotationType = adm.appliedAnnotations_![key].annotationType_
739 const requestedAnnotationType = annotation.annotationType_
740 die(
741 `Cannot apply '${requestedAnnotationType}' to '${fieldName}':` +
742 `\nThe field is already annotated with '${currentAnnotationType}'.` +
743 `\nRe-annotating fields is not allowed.` +
744 `\nUse 'override' annotation for methods overriden by subclass.`
745 )
746 }
747}