Skip to content
6 changes: 6 additions & 0 deletions .changeset/fluffy-hairs-study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"mobx-react": patch
"mobx-react-lite": patch
---

fix #3648: `observableRequiresReaction`/`computedRequiresReaction` shouldn't warn with `enableStaticRendering(true)`
5 changes: 5 additions & 0 deletions .changeset/shaggy-carpets-move.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"mobx": patch
---

`computedRequiresReaction` respects `globalState.allowStateReads`
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ exports[`changing state in render should fail 2`] = `
</div>
`;

exports[`#3648 enableStaticRendering doesn't warn with observableRequiresReaction/computedRequiresReaction 1`] = `[MockFunction]`;

exports[`issue 12 init state is correct 1`] = `
<div>
<div>
Expand Down
21 changes: 21 additions & 0 deletions packages/mobx-react-lite/__tests__/observer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1084,3 +1084,24 @@ test("Anonymous component displayName #3192", () => {
expect(observerError.message).toEqual(memoError.message)
consoleErrorSpy.mockRestore()
})

test("#3648 enableStaticRendering doesn't warn with observableRequiresReaction/computedRequiresReaction", () => {
consoleWarnMock = jest.spyOn(console, "warn").mockImplementation(() => {})
try {
enableStaticRendering(true)
mobx.configure({ observableRequiresReaction: true, computedRequiresReaction: true })
const o = mobx.observable.box(0, { name: "o" })
const c = mobx.computed(() => o.get(), { name: "c" })
const TestCmp = observer(() => <div>{o.get() + c.get()}</div>)

const { unmount, container } = render(<TestCmp />)
expect(container).toHaveTextContent("0")
unmount()

expect(consoleWarnMock).toMatchSnapshot()
} finally {
enableStaticRendering(false)
mobx._resetGlobalState()
consoleWarnMock.mockRestore()
}
})
6 changes: 0 additions & 6 deletions packages/mobx-react-lite/src/observer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { forwardRef, memo } from "react"

import { isUsingStaticRendering } from "./staticRendering"
import { useObserver } from "./useObserver"

let warnObserverOptionsDeprecated = true
Expand Down Expand Up @@ -79,10 +77,6 @@ export function observer<P extends object, TRef = {}>(
}

// The working of observer is explained step by step in this talk: https://www.youtube.com/watch?v=cPF4iBedoF0&feature=youtu.be&t=1307
if (isUsingStaticRendering()) {
return baseComponent
}

let useForwardRef = options?.forwardRef ?? false
let render = baseComponent

Expand Down
9 changes: 7 additions & 2 deletions packages/mobx-react-lite/src/useObserver.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Reaction } from "mobx"
import { Reaction, _allowStateReadsEnd, _allowStateReadsStart } from "mobx"
import React from "react"
import { printDebugValue } from "./utils/printDebugValue"
import { observerFinalizationRegistry } from "./utils/observerFinalizationRegistry"
Expand Down Expand Up @@ -36,7 +36,12 @@ function objectToBeRetainedByReactFactory() {

export function useObserver<T>(fn: () => T, baseComponentName: string = "observed"): T {
if (isUsingStaticRendering()) {
return fn()
let allowStateReads = _allowStateReadsStart?.(true)
try {
return fn()
} finally {
_allowStateReadsEnd?.(allowStateReads)
}
}

const [objectRetainedByReact] = React.useState(objectToBeRetainedByReactFactory)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ exports[`#797 - replacing this.render should trigger a warning 1`] = `
}
`;

exports[`#3648 enableStaticRendering doesn't warn with observableRequiresReaction/computedRequiresReaction 1`] = `[MockFunction]`;

exports[`Redeclaring an existing observer component as an observer should log a warning 1`] = `
[MockFunction] {
"calls": Array [
Expand Down
30 changes: 29 additions & 1 deletion packages/mobx-react/__tests__/observer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import {
computed,
observable,
transaction,
makeObservable
makeObservable,
configure
} from "mobx"
import { withConsole } from "./utils/withConsole"
/**
Expand Down Expand Up @@ -1037,3 +1038,30 @@ test("SSR works #3448", () => {

expect(consoleWarnMock).toMatchSnapshot()
})

test("#3648 enableStaticRendering doesn't warn with observableRequiresReaction/computedRequiresReaction", () => {
consoleWarnMock = jest.spyOn(console, "warn").mockImplementation(() => {})
try {
enableStaticRendering(true)
configure({ observableRequiresReaction: true, computedRequiresReaction: true })
const o = observable.box(0, { name: "o" })
const c = computed(() => o.get(), { name: "c" })

@observer
class TestCmp extends React.Component<any> {
render() {
return o.get() + c.get()
}
}

const { unmount, container } = render(<TestCmp />)
expect(container).toHaveTextContent("0")
unmount()

expect(consoleWarnMock).toMatchSnapshot()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See previous comment

} finally {
enableStaticRendering(false)
_resetGlobalState()
consoleWarnMock.mockRestore()
}
})
9 changes: 8 additions & 1 deletion packages/mobx-react/src/observerClass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,14 @@ export function makeClassComponentObserver(
}
target.render = function () {
this.render = isUsingStaticRendering()
? originalRender
? function () {
let allowStateReads = _allowStateReadsStart?.(true)
try {
return originalRender()
} finally {
_allowStateReadsEnd?.(allowStateReads)
}
}
: createReactiveRender.call(this, originalRender)
return this.render()
}
Expand Down
1 change: 0 additions & 1 deletion packages/mobx/src/api/configure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ export function configure(options: {
globalState[key] = !!options[key]
}
})
globalState.allowStateReads = !globalState.observableRequiresReaction
if (__DEV__ && globalState.disableErrorBoundaries === true) {
console.warn(
"WARNING: Debug feature only. MobX will NOT recover from errors when `disableErrorBoundaries` is enabled."
Expand Down
5 changes: 3 additions & 2 deletions packages/mobx/src/core/computedvalue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,9 +309,10 @@ export class ComputedValue<T> implements IObservable, IComputedValue<T>, IDeriva
)
}
if (
typeof this.requiresReaction_ === "boolean"
!globalState.allowStateReads &&
(typeof this.requiresReaction_ === "boolean"
? this.requiresReaction_
: globalState.computedRequiresReaction
: globalState.computedRequiresReaction)
) {
console.warn(
`[mobx] Computed value '${this.name_}' is being read outside a reactive context. Doing a full recompute.`
Expand Down
4 changes: 2 additions & 2 deletions packages/mobx/src/core/globalstate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,9 @@ export class MobXGlobals {

/**
* Is it allowed to read observables at this point?
* Used to hold the state needed for `observableRequiresReaction`
* Used to hold the state needed for `observableRequiresReaction`/`computedRequiresReaction`
*/
allowStateReads = true
allowStateReads = false
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be a breaking change? By default we allow state reads outside reactive contexts?

Copy link
Collaborator Author

@urugator urugator Mar 19, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. These flags, allowStateReads and allowStateChanges, says whether you're in a context, where reads/changes are allowed - or perhaps better - where they are supposed to take place. They're basically inDerivation/inAction flags - they're switched on/off when entering/leaving these, regardless of whether it should warn or not. We use these flags instead of directly checking for action/derivation, because the mapping isn't always exactly 1:1. But really the point is to demark these places, not to directly control the warning - the warning is always only raised in combination with requiresReaction/enforceAction. They should always start as false, because there is no running action/derivation by default.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check, that adds up. I haven't been working on this for too long 😅.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Turns out it's more complicated. I think it was originally designed as described, but it was maybe misunderstood and changed with the introduction of autoAction.
The thing is that autoAction is supposed to warn when you modify state inside derivation, regardless of enforceAction configuration. So autoAction assumes that if allowStateChanges is false, there will be a warning when setting state, no other conditions needed.
In order for this to work the configure/resetGlobalState was updated to synchronize allowStateChanges with enforeAction configuration.
But the actual check for the warning doesn't assume this behavior:

if (
!globalState.allowStateChanges &&
(hasObservers || globalState.enforceActions === "always")
) {

Notice there is a bug - the check doesn't respect enforceActions: "never" (enforceActions === false).
This bug is actually a reason why the test for autoAction warning is passing - there is a test above that calls mobx.configure({ enforceActions: "never" }).
If you fix this bug, the autoAction warning (Side effects like changing state are not allowed) can never occur.


/**
* If strict mode is enabled, state changes are by default not allowed
Expand Down