UNPKG

11.4 kBPlain TextView Raw
1import { PureComponent, Component, ComponentClass, ClassAttributes } from "react"
2import {
3 _allowStateChanges,
4 Reaction,
5 _allowStateReadsStart,
6 _allowStateReadsEnd,
7 _getGlobalState
8} from "mobx"
9import {
10 isUsingStaticRendering,
11 _observerFinalizationRegistry as observerFinalizationRegistry
12} from "mobx-react-lite"
13import { shallowEqual, patch } from "./utils/utils"
14
15const administrationSymbol = Symbol("ObserverAdministration")
16const isMobXReactObserverSymbol = Symbol("isMobXReactObserver")
17
18let observablePropDescriptors: PropertyDescriptorMap
19if (__DEV__) {
20 observablePropDescriptors = {
21 props: createObservablePropDescriptor("props"),
22 state: createObservablePropDescriptor("state"),
23 context: createObservablePropDescriptor("context")
24 }
25}
26
27type ObserverAdministration = {
28 reaction: Reaction | null // also serves as disposed flag
29 forceUpdate: Function | null
30 mounted: boolean // we could use forceUpdate as mounted flag
31 reactionInvalidatedBeforeMount: boolean
32 name: string
33 // Used only on __DEV__
34 props: any
35 state: any
36 context: any
37}
38
39function getAdministration(component: Component): ObserverAdministration {
40 // We create administration lazily, because we can't patch constructor
41 // and the exact moment of initialization partially depends on React internals.
42 // At the time of writing this, the first thing invoked is one of the observable getter/setter (state/props/context).
43 return (component[administrationSymbol] ??= {
44 reaction: null,
45 mounted: false,
46 reactionInvalidatedBeforeMount: false,
47 forceUpdate: null,
48 name: getDisplayName(component.constructor as ComponentClass),
49 state: undefined,
50 props: undefined,
51 context: undefined
52 })
53}
54
55export function makeClassComponentObserver(
56 componentClass: ComponentClass<any, any>
57): ComponentClass<any, any> {
58 const { prototype } = componentClass
59
60 if (componentClass[isMobXReactObserverSymbol]) {
61 const displayName = getDisplayName(componentClass)
62 throw new Error(
63 `The provided component class (${displayName}) has already been declared as an observer component.`
64 )
65 } else {
66 componentClass[isMobXReactObserverSymbol] = true
67 }
68
69 if (prototype.componentWillReact) {
70 throw new Error("The componentWillReact life-cycle event is no longer supported")
71 }
72 if (componentClass["__proto__"] !== PureComponent) {
73 if (!prototype.shouldComponentUpdate) {
74 prototype.shouldComponentUpdate = observerSCU
75 } else if (prototype.shouldComponentUpdate !== observerSCU) {
76 // n.b. unequal check, instead of existence check, as @observer might be on superclass as well
77 throw new Error(
78 "It is not allowed to use shouldComponentUpdate in observer based components."
79 )
80 }
81 }
82
83 if (__DEV__) {
84 Object.defineProperties(prototype, observablePropDescriptors)
85 }
86
87 const originalRender = prototype.render
88 if (typeof originalRender !== "function") {
89 const displayName = getDisplayName(componentClass)
90 throw new Error(
91 `[mobx-react] class component (${displayName}) is missing \`render\` method.` +
92 `\n\`observer\` requires \`render\` being a function defined on prototype.` +
93 `\n\`render = () => {}\` or \`render = function() {}\` is not supported.`
94 )
95 }
96
97 prototype.render = function () {
98 Object.defineProperty(this, "render", {
99 // There is no safe way to replace render, therefore it's forbidden.
100 configurable: false,
101 writable: false,
102 value: isUsingStaticRendering()
103 ? originalRender
104 : createReactiveRender.call(this, originalRender)
105 })
106 return this.render()
107 }
108
109 const originalComponentDidMount = prototype.componentDidMount
110 prototype.componentDidMount = function () {
111 if (__DEV__ && this.componentDidMount !== Object.getPrototypeOf(this).componentDidMount) {
112 const displayName = getDisplayName(componentClass)
113 throw new Error(
114 `[mobx-react] \`observer(${displayName}).componentDidMount\` must be defined on prototype.` +
115 `\n\`componentDidMount = () => {}\` or \`componentDidMount = function() {}\` is not supported.`
116 )
117 }
118
119 // `componentDidMount` may not be called at all. React can abandon the instance after `render`.
120 // That's why we use finalization registry to dispose reaction created during render.
121 // Happens with `<Suspend>` see #3492
122 //
123 // `componentDidMount` can be called immediately after `componentWillUnmount` without calling `render` in between.
124 // Happens with `<StrictMode>`see #3395.
125 //
126 // If `componentDidMount` is called, it's guaranteed to run synchronously with render (similary to `useLayoutEffect`).
127 // Therefore we don't have to worry about external (observable) state being updated before mount (no state version checking).
128 //
129 // Things may change: "In the future, React will provide a feature that lets components preserve state between unmounts"
130
131 const admin = getAdministration(this)
132
133 admin.mounted = true
134
135 // Component instance committed, prevent reaction disposal.
136 observerFinalizationRegistry.unregister(this)
137
138 // We don't set forceUpdate before mount because it requires a reference to `this`,
139 // therefore `this` could NOT be garbage collected before mount,
140 // preventing reaction disposal by FinalizationRegistry and leading to memory leak.
141 // As an alternative we could have `admin.instanceRef = new WeakRef(this)`, but lets avoid it if possible.
142 admin.forceUpdate = () => this.forceUpdate()
143
144 if (!admin.reaction || admin.reactionInvalidatedBeforeMount) {
145 // Missing reaction:
146 // 1. Instance was unmounted (reaction disposed) and immediately remounted without running render #3395.
147 // 2. Reaction was disposed by finalization registry before mount. Shouldn't ever happen for class components:
148 // `componentDidMount` runs synchronously after render, but our registry are deferred (can't run in between).
149 // In any case we lost subscriptions to observables, so we have to create new reaction and re-render to resubscribe.
150 // The reaction will be created lazily by following render.
151
152 // Reaction invalidated before mount:
153 // 1. A descendant's `componenDidMount` invalidated it's parent #3730
154
155 admin.forceUpdate()
156 }
157 return originalComponentDidMount?.apply(this, arguments)
158 }
159
160 // TODO@major Overly complicated "patch" is only needed to support the deprecated @disposeOnUnmount
161 patch(prototype, "componentWillUnmount", function () {
162 if (isUsingStaticRendering()) {
163 return
164 }
165 const admin = getAdministration(this)
166 admin.reaction?.dispose()
167 admin.reaction = null
168 admin.forceUpdate = null
169 admin.mounted = false
170 admin.reactionInvalidatedBeforeMount = false
171 })
172
173 return componentClass
174}
175
176// Generates a friendly name for debugging
177function getDisplayName(componentClass: ComponentClass) {
178 return componentClass.displayName || componentClass.name || "<component>"
179}
180
181function createReactiveRender(originalRender: any) {
182 const boundOriginalRender = originalRender.bind(this)
183
184 const admin = getAdministration(this)
185
186 function reactiveRender() {
187 if (!admin.reaction) {
188 // Create reaction lazily to support re-mounting #3395
189 admin.reaction = createReaction(admin)
190 if (!admin.mounted) {
191 // React can abandon this instance and never call `componentDidMount`/`componentWillUnmount`,
192 // we have to make sure reaction will be disposed.
193 observerFinalizationRegistry.register(this, admin, this)
194 }
195 }
196
197 let error: unknown = undefined
198 let renderResult = undefined
199 admin.reaction.track(() => {
200 try {
201 // TODO@major
202 // Optimization: replace with _allowStateChangesStart/End (not available in mobx@6.0.0)
203 renderResult = _allowStateChanges(false, boundOriginalRender)
204 } catch (e) {
205 error = e
206 }
207 })
208 if (error) {
209 throw error
210 }
211 return renderResult
212 }
213
214 return reactiveRender
215}
216
217function createReaction(admin: ObserverAdministration) {
218 return new Reaction(`${admin.name}.render()`, () => {
219 if (!admin.mounted) {
220 // This is neccessary to avoid react warning about calling forceUpdate on component that isn't mounted yet.
221 // This happens when component is abandoned after render - our reaction is already created and reacts to changes.
222 // `componenDidMount` runs synchronously after `render`, so unlike functional component, there is no delay during which the reaction could be invalidated.
223 // However `componentDidMount` runs AFTER it's descendants' `componentDidMount`, which CAN invalidate the reaction, see #3730. Therefore remember and forceUpdate on mount.
224 admin.reactionInvalidatedBeforeMount = true
225 return
226 }
227
228 try {
229 admin.forceUpdate?.()
230 } catch (error) {
231 admin.reaction?.dispose()
232 admin.reaction = null
233 }
234 })
235}
236
237function observerSCU(nextProps: ClassAttributes<any>, nextState: any): boolean {
238 if (isUsingStaticRendering()) {
239 console.warn(
240 "[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."
241 )
242 }
243 // update on any state changes (as is the default)
244 if (this.state !== nextState) {
245 return true
246 }
247 // update if props are shallowly not equal, inspired by PureRenderMixin
248 // we could return just 'false' here, and avoid the `skipRender` checks etc
249 // however, it is nicer if lifecycle events are triggered like usually,
250 // so we return true here if props are shallowly modified.
251 return !shallowEqual(this.props, nextProps)
252}
253
254function createObservablePropDescriptor(key: "props" | "state" | "context") {
255 return {
256 configurable: true,
257 enumerable: true,
258 get() {
259 const admin = getAdministration(this)
260 const derivation = _getGlobalState().trackingDerivation
261 if (derivation && derivation !== admin.reaction) {
262 throw new Error(
263 `[mobx-react] Cannot read "${admin.name}.${key}" in a reactive context, as it isn't observable.
264 Please use component lifecycle method to copy the value into a local observable first.
265 See https://github.com/mobxjs/mobx/blob/main/packages/mobx-react/README.md#note-on-using-props-and-state-in-derivations`
266 )
267 }
268 return admin[key]
269 },
270 set(value) {
271 getAdministration(this)[key] = value
272 }
273 }
274}