mobx-react 7.6.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,44 +1,80 @@
1
- import { PureComponent, Component } from "react"
1
+ import { PureComponent, Component, ComponentClass, ClassAttributes } from "react"
2
2
  import {
3
3
  createAtom,
4
4
  _allowStateChanges,
5
5
  Reaction,
6
- $mobx,
7
6
  _allowStateReadsStart,
8
- _allowStateReadsEnd
7
+ _allowStateReadsEnd,
8
+ _getGlobalState,
9
+ IAtom
9
10
  } from "mobx"
10
- import { isUsingStaticRendering } from "mobx-react-lite"
11
+ import {
12
+ isUsingStaticRendering,
13
+ _observerFinalizationRegistry as observerFinalizationRegistry
14
+ } from "mobx-react-lite"
15
+ import { shallowEqual, patch } from "./utils/utils"
16
+
17
+ const administrationSymbol = Symbol("ObserverAdministration")
18
+ const isMobXReactObserverSymbol = Symbol("isMobXReactObserver")
11
19
 
12
- import { newSymbol, shallowEqual, setHiddenProp, patch } from "./utils/utils"
20
+ type ObserverAdministration = {
21
+ reaction: Reaction | null // also serves as disposed flag
22
+ forceUpdate: Function | null
23
+ mounted: boolean // we could use forceUpdate as mounted flag
24
+ name: string
25
+ propsAtom: IAtom
26
+ stateAtom: IAtom
27
+ contextAtom: IAtom
28
+ props: any
29
+ state: any
30
+ context: any
31
+ // Setting this.props causes forceUpdate, because this.props is observable.
32
+ // forceUpdate sets this.props.
33
+ // This flag is used to avoid the loop.
34
+ isUpdating: boolean
35
+ }
13
36
 
14
- const mobxAdminProperty = $mobx || "$mobx" // BC
15
- const mobxObserverProperty = newSymbol("isMobXReactObserver")
16
- const mobxIsUnmounted = newSymbol("isUnmounted")
17
- const skipRenderKey = newSymbol("skipRender")
18
- const isForcingUpdateKey = newSymbol("isForcingUpdate")
37
+ function getAdministration(component: Component): ObserverAdministration {
38
+ // We create administration lazily, because we can't patch constructor
39
+ // and the exact moment of initialization partially depends on React internals.
40
+ // At the time of writing this, the first thing invoked is one of the observable getter/setter (state/props/context).
41
+ return (component[administrationSymbol] ??= {
42
+ reaction: null,
43
+ mounted: false,
44
+ forceUpdate: null,
45
+ name: getDisplayName(component.constructor as ComponentClass),
46
+ state: undefined,
47
+ props: undefined,
48
+ context: undefined,
49
+ propsAtom: createAtom("props"),
50
+ stateAtom: createAtom("state"),
51
+ contextAtom: createAtom("context"),
52
+ isUpdating: false
53
+ })
54
+ }
19
55
 
20
56
  export function makeClassComponentObserver(
21
- componentClass: React.ComponentClass<any, any>
22
- ): React.ComponentClass<any, any> {
23
- const target = componentClass.prototype
57
+ componentClass: ComponentClass<any, any>
58
+ ): ComponentClass<any, any> {
59
+ const { prototype } = componentClass
24
60
 
25
- if (componentClass[mobxObserverProperty]) {
26
- const displayName = getDisplayName(target)
61
+ if (componentClass[isMobXReactObserverSymbol]) {
62
+ const displayName = getDisplayName(componentClass)
27
63
  console.warn(
28
64
  `The provided component class (${displayName})
29
65
  has already been declared as an observer component.`
30
66
  )
31
67
  } else {
32
- componentClass[mobxObserverProperty] = true
68
+ componentClass[isMobXReactObserverSymbol] = true
33
69
  }
34
70
 
35
- if (target.componentWillReact) {
71
+ if (prototype.componentWillReact) {
36
72
  throw new Error("The componentWillReact life-cycle event is no longer supported")
37
73
  }
38
74
  if (componentClass["__proto__"] !== PureComponent) {
39
- if (!target.shouldComponentUpdate) {
40
- target.shouldComponentUpdate = observerSCU
41
- } else if (target.shouldComponentUpdate !== observerSCU) {
75
+ if (!prototype.shouldComponentUpdate) {
76
+ prototype.shouldComponentUpdate = observerSCU
77
+ } else if (prototype.shouldComponentUpdate !== observerSCU) {
42
78
  // n.b. unequal check, instead of existence check, as @observer might be on superclass as well
43
79
  throw new Error(
44
80
  "It is not allowed to use shouldComponentUpdate in observer based components."
@@ -50,142 +86,154 @@ export function makeClassComponentObserver(
50
86
  // are defined inside the component, and which rely on state or props, re-compute if state or props change
51
87
  // (otherwise the computed wouldn't update and become stale on props change, since props are not observable)
52
88
  // However, this solution is not without it's own problems: https://github.com/mobxjs/mobx-react/issues?utf8=%E2%9C%93&q=is%3Aissue+label%3Aobservable-props-or-not+
53
- makeObservableProp(target, "props")
54
- makeObservableProp(target, "state")
55
- if (componentClass.contextType) {
56
- makeObservableProp(target, "context")
57
- }
89
+ Object.defineProperties(prototype, {
90
+ props: observablePropsDescriptor,
91
+ state: observableStateDescriptor,
92
+ context: observableContextDescriptor
93
+ })
58
94
 
59
- const originalRender = target.render
95
+ const originalRender = prototype.render
60
96
  if (typeof originalRender !== "function") {
61
- const displayName = getDisplayName(target)
97
+ const displayName = getDisplayName(componentClass)
62
98
  throw new Error(
63
99
  `[mobx-react] class component (${displayName}) is missing \`render\` method.` +
64
100
  `\n\`observer\` requires \`render\` being a function defined on prototype.` +
65
101
  `\n\`render = () => {}\` or \`render = function() {}\` is not supported.`
66
102
  )
67
103
  }
68
- target.render = function () {
69
- this.render = isUsingStaticRendering()
70
- ? originalRender
71
- : createReactiveRender.call(this, originalRender)
104
+
105
+ prototype.render = function () {
106
+ Object.defineProperty(this, "render", {
107
+ // There is no safe way to replace render, therefore it's forbidden.
108
+ configurable: false,
109
+ writable: false,
110
+ value: isUsingStaticRendering()
111
+ ? originalRender
112
+ : createReactiveRender.call(this, originalRender)
113
+ })
72
114
  return this.render()
73
115
  }
74
- patch(target, "componentDidMount", function () {
75
- this[mobxIsUnmounted] = false
76
- if (!this.render[mobxAdminProperty]) {
77
- // Reaction is re-created automatically during render, but a component can re-mount and skip render #3395.
78
- // To re-create the reaction and re-subscribe to relevant observables we have to force an update.
79
- Component.prototype.forceUpdate.call(this)
116
+
117
+ patch(prototype, "componentDidMount", function () {
118
+ // `componentDidMount` may not be called at all. React can abandon the instance after `render`.
119
+ // That's why we use finalization registry to dispose reaction created during render.
120
+ // Happens with `<Suspend>` see #3492
121
+ //
122
+ // `componentDidMount` can be called immediately after `componentWillUnmount` without calling `render` in between.
123
+ // Happens with `<StrictMode>`see #3395.
124
+ //
125
+ // If `componentDidMount` is called, it's guaranteed to run synchronously with render (similary to `useLayoutEffect`).
126
+ // Therefore we don't have to worry about external (observable) state being updated before mount (no state version checking).
127
+ //
128
+ // Things may change: "In the future, React will provide a feature that lets components preserve state between unmounts"
129
+
130
+ const admin = getAdministration(this)
131
+
132
+ admin.mounted = true
133
+
134
+ // Component instance committed, prevent reaction disposal.
135
+ observerFinalizationRegistry.unregister(admin)
136
+
137
+ // We don't set forceUpdate before mount because it requires a reference to `this`,
138
+ // therefore `this` could NOT be garbage collected before mount,
139
+ // preventing reaction disposal by FinalizationRegistry and leading to memory leak.
140
+ // As an alternative we could have `admin.instanceRef = new WeakRef(this)`, but lets avoid it if possible.
141
+ admin.forceUpdate = () => this.forceUpdate()
142
+
143
+ if (!admin.reaction) {
144
+ // 1. Instance was unmounted (reaction disposed) and immediately remounted without running render #3395.
145
+ // 2. Reaction was disposed by finalization registry before mount. Shouldn't ever happen for class components:
146
+ // `componentDidMount` runs synchronously after render, but our registry are deferred (can't run in between).
147
+ // In any case we lost subscriptions to observables, so we have to create new reaction and re-render to resubscribe.
148
+ // The reaction will be created lazily by following render.
149
+ admin.forceUpdate()
80
150
  }
81
151
  })
82
- patch(target, "componentWillUnmount", function () {
152
+
153
+ patch(prototype, "componentWillUnmount", function () {
83
154
  if (isUsingStaticRendering()) {
84
155
  return
85
156
  }
86
-
87
- const reaction = this.render[mobxAdminProperty]
88
- if (reaction) {
89
- reaction.dispose()
90
- // Forces reaction to be re-created on next render
91
- this.render[mobxAdminProperty] = null
92
- } else {
93
- // Render may have been hot-swapped and/or overridden by a subclass.
94
- const displayName = getDisplayName(this)
95
- console.warn(
96
- `The reactive render of an observer class component (${displayName})
97
- was overridden after MobX attached. This may result in a memory leak if the
98
- overridden reactive render was not properly disposed.`
99
- )
100
- }
101
-
102
- this[mobxIsUnmounted] = true
157
+ const admin = getAdministration(this)
158
+ admin.reaction?.dispose()
159
+ admin.reaction = null
160
+ admin.forceUpdate = null
161
+ admin.mounted = false
103
162
  })
163
+
104
164
  return componentClass
105
165
  }
106
166
 
107
167
  // Generates a friendly name for debugging
108
- function getDisplayName(comp: any) {
109
- return (
110
- comp.displayName ||
111
- comp.name ||
112
- (comp.constructor && (comp.constructor.displayName || comp.constructor.name)) ||
113
- "<component>"
114
- )
168
+ function getDisplayName(componentClass: ComponentClass) {
169
+ return componentClass.displayName || componentClass.name || "<component>"
115
170
  }
116
171
 
117
172
  function createReactiveRender(originalRender: any) {
118
- /**
119
- * If props are shallowly modified, react will render anyway,
120
- * so atom.reportChanged() should not result in yet another re-render
121
- */
122
- setHiddenProp(this, skipRenderKey, false)
123
- /**
124
- * forceUpdate will re-assign this.props. We don't want that to cause a loop,
125
- * so detect these changes
126
- */
127
- setHiddenProp(this, isForcingUpdateKey, false)
128
-
129
- const initialName = getDisplayName(this)
130
173
  const boundOriginalRender = originalRender.bind(this)
131
174
 
132
- let isRenderingPending = false
133
-
134
- const createReaction = () => {
135
- const reaction = new Reaction(`${initialName}.render()`, () => {
136
- if (!isRenderingPending) {
137
- // N.B. Getting here *before mounting* means that a component constructor has side effects (see the relevant test in misc.test.tsx)
138
- // This unidiomatic React usage but React will correctly warn about this so we continue as usual
139
- // See #85 / Pull #44
140
- isRenderingPending = true
141
- if (this[mobxIsUnmounted] !== true) {
142
- let hasError = true
143
- try {
144
- setHiddenProp(this, isForcingUpdateKey, true)
145
- if (!this[skipRenderKey]) {
146
- Component.prototype.forceUpdate.call(this)
147
- }
148
- hasError = false
149
- } finally {
150
- setHiddenProp(this, isForcingUpdateKey, false)
151
- if (hasError) {
152
- reaction.dispose()
153
- // Forces reaction to be re-created on next render
154
- this.render[mobxAdminProperty] = null
155
- }
156
- }
157
- }
158
- }
159
- })
160
- reaction["reactComponent"] = this
161
- return reaction
162
- }
175
+ const admin = getAdministration(this)
163
176
 
164
177
  function reactiveRender() {
165
- isRenderingPending = false
166
- // Create reaction lazily to support re-mounting #3395
167
- const reaction = (reactiveRender[mobxAdminProperty] ??= createReaction())
168
- let exception: unknown = undefined
169
- let rendering = undefined
170
- reaction.track(() => {
178
+ if (!admin.reaction) {
179
+ // Create reaction lazily to support re-mounting #3395
180
+ admin.reaction = createReaction(admin)
181
+ if (!admin.mounted) {
182
+ // React can abandon this instance and never call `componentDidMount`/`componentWillUnmount`,
183
+ // we have to make sure reaction will be disposed.
184
+ observerFinalizationRegistry.register(this, admin, this)
185
+ }
186
+ }
187
+
188
+ let error: unknown = undefined
189
+ let renderResult = undefined
190
+ admin.reaction.track(() => {
171
191
  try {
172
192
  // TODO@major
173
193
  // Optimization: replace with _allowStateChangesStart/End (not available in mobx@6.0.0)
174
- rendering = _allowStateChanges(false, boundOriginalRender)
194
+ renderResult = _allowStateChanges(false, boundOriginalRender)
175
195
  } catch (e) {
176
- exception = e
196
+ error = e
177
197
  }
178
198
  })
179
- if (exception) {
180
- throw exception
199
+ if (error) {
200
+ throw error
181
201
  }
182
- return rendering
202
+ return renderResult
183
203
  }
184
204
 
185
205
  return reactiveRender
186
206
  }
187
207
 
188
- function observerSCU(nextProps: React.ClassAttributes<any>, nextState: any): boolean {
208
+ function createReaction(admin: ObserverAdministration) {
209
+ return new Reaction(`${admin.name}.render()`, () => {
210
+ if (admin.isUpdating) {
211
+ // Reaction is suppressed when setting new state/props/context,
212
+ // this is when component is already being updated.
213
+ return
214
+ }
215
+
216
+ if (!admin.mounted) {
217
+ // This is neccessary to avoid react warning about calling forceUpdate on component that isn't mounted yet.
218
+ // This happens when component is abandoned after render - our reaction is already created and reacts to changes.
219
+ // Due to the synchronous nature of `componenDidMount`, we don't have to worry that component could eventually mount and require update.
220
+ return
221
+ }
222
+
223
+ try {
224
+ // forceUpdate sets new `props`, since we made it observable, it would `reportChanged`, causing a loop.
225
+ admin.isUpdating = true
226
+ admin.forceUpdate?.()
227
+ } catch (error) {
228
+ admin.reaction?.dispose()
229
+ admin.reaction = null
230
+ } finally {
231
+ admin.isUpdating = false
232
+ }
233
+ })
234
+ }
235
+
236
+ function observerSCU(nextProps: ClassAttributes<any>, nextState: any): boolean {
189
237
  if (isUsingStaticRendering()) {
190
238
  console.warn(
191
239
  "[mobx-react] It seems that a re-rendering of a React component is triggered while in static (server-side) mode. Please make sure components are rendered only once server-side."
@@ -202,45 +250,41 @@ function observerSCU(nextProps: React.ClassAttributes<any>, nextState: any): boo
202
250
  return !shallowEqual(this.props, nextProps)
203
251
  }
204
252
 
205
- function makeObservableProp(target: any, propName: string): void {
206
- const valueHolderKey = newSymbol(`reactProp_${propName}_valueHolder`)
207
- const atomHolderKey = newSymbol(`reactProp_${propName}_atomHolder`)
208
- function getAtom() {
209
- if (!this[atomHolderKey]) {
210
- setHiddenProp(this, atomHolderKey, createAtom("reactive " + propName))
211
- }
212
- return this[atomHolderKey]
213
- }
214
- Object.defineProperty(target, propName, {
253
+ function createObservablePropDescriptor(key: "props" | "state" | "context") {
254
+ const atomKey = `${key}Atom`
255
+ return {
215
256
  configurable: true,
216
257
  enumerable: true,
217
- get: function () {
218
- let prevReadState = false
258
+ get() {
259
+ const admin = getAdministration(this)
219
260
 
220
- // Why this check? BC?
221
- // @ts-expect-error
222
- if (_allowStateReadsStart && _allowStateReadsEnd) {
223
- prevReadState = _allowStateReadsStart(true)
224
- }
225
- getAtom.call(this).reportObserved()
261
+ let prevReadState = _allowStateReadsStart(true)
226
262
 
227
- // Why this check? BC?
228
- // @ts-expect-error
229
- if (_allowStateReadsStart && _allowStateReadsEnd) {
230
- _allowStateReadsEnd(prevReadState)
231
- }
263
+ admin[atomKey].reportObserved()
264
+
265
+ _allowStateReadsEnd(prevReadState)
232
266
 
233
- return this[valueHolderKey]
267
+ return admin[key]
234
268
  },
235
- set: function set(v) {
236
- if (!this[isForcingUpdateKey] && !shallowEqual(this[valueHolderKey], v)) {
237
- setHiddenProp(this, valueHolderKey, v)
238
- setHiddenProp(this, skipRenderKey, true)
239
- getAtom.call(this).reportChanged()
240
- setHiddenProp(this, skipRenderKey, false)
269
+ set(value) {
270
+ const admin = getAdministration(this)
271
+ // forceUpdate issued by reaction sets new props.
272
+ // It sets isUpdating to true to prevent loop.
273
+ if (!admin.isUpdating && !shallowEqual(admin[key], value)) {
274
+ admin[key] = value
275
+ // This notifies all observers including our component,
276
+ // but we don't want to cause `forceUpdate`, because component is already updating,
277
+ // therefore supress component reaction.
278
+ admin.isUpdating = true
279
+ admin[atomKey].reportChanged()
280
+ admin.isUpdating = false
241
281
  } else {
242
- setHiddenProp(this, valueHolderKey, v)
282
+ admin[key] = value
243
283
  }
244
284
  }
245
- })
285
+ }
246
286
  }
287
+
288
+ const observablePropsDescriptor = createObservablePropDescriptor("props")
289
+ const observableStateDescriptor = createObservablePropDescriptor("state")
290
+ const observableContextDescriptor = createObservablePropDescriptor("context")
@@ -1,21 +1,3 @@
1
- let symbolId = 0
2
- function createSymbol(name: string): symbol | string {
3
- if (typeof Symbol === "function") {
4
- return Symbol(name)
5
- }
6
- const symbol = `__$mobx-react ${name} (${symbolId})`
7
- symbolId++
8
- return symbol
9
- }
10
-
11
- const createdSymbols = {}
12
- export function newSymbol(name: string): symbol | string {
13
- if (!createdSymbols[name]) {
14
- createdSymbols[name] = createSymbol(name)
15
- }
16
- return createdSymbols[name]
17
- }
18
-
19
1
  export function shallowEqual(objA: any, objB: any): boolean {
20
2
  //From: https://github.com/facebook/fbjs/blob/c69904a511b900266935168223063dd8772dfc40/packages/fbjs/src/core/shallowEqual.js
21
3
  if (is(objA, objB)) {
@@ -96,8 +78,8 @@ export function setHiddenProp(target: object, prop: any, value: any): void {
96
78
  * Utilities for patching componentWillUnmount, to make sure @disposeOnUnmount works correctly icm with user defined hooks
97
79
  * and the handler provided by mobx-react
98
80
  */
99
- const mobxMixins = newSymbol("patchMixins")
100
- const mobxPatchedDefinition = newSymbol("patchedDefinition")
81
+ const mobxMixins = Symbol("patchMixins")
82
+ const mobxPatchedDefinition = Symbol("patchedDefinition")
101
83
 
102
84
  export interface Mixins extends Record<string, any> {
103
85
  locks: number
@@ -175,6 +157,7 @@ function createDefinition(
175
157
  let wrappedFunc = wrapFunction(originalMethod, mixins)
176
158
 
177
159
  return {
160
+ // @ts-ignore
178
161
  [mobxPatchedDefinition]: true,
179
162
  get: function () {
180
163
  return wrappedFunc