Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-endbatch-unobservation-reentrancy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"mobx": patch
---

Fix a stack overflow ("Maximum call stack size exceeded") that could occur when an `onBecomeUnobserved` handler disposes a `Reaction`. Disposing a `Reaction` re-enters `endBatch()`, which used to recurse into the same `pendingUnobservations` drain loop instead of letting the already-running outer loop pick up the newly queued items, causing unbounded stack depth for long enough chains.
27 changes: 27 additions & 0 deletions packages/mobx/__tests__/base/become-observed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,33 @@ test("#2667", () => {
])
})

test("#3954 - disposing a chain of reactions from onBecomeUnobserved doesn't overflow the stack", () => {
// Each box's onBecomeUnobserved handler disposes the next reaction in the
// chain. Disposing reaction[0] unobserves box[0], whose handler disposes
// reaction[1], which unobserves box[1], and so on. Each of those disposals
// re-enters endBatch() while the previous one is still draining
// pendingUnobservations, so this used to recurse N deep instead of
// looping, overflowing the stack for a large enough chain.
const N = 10000
const boxes = Array.from({ length: N }, () => observable.box(0))
const disposers = boxes.map(box => autorun(() => box.get()))
let unobservedCount = 0

boxes.forEach((box, i) => {
onBecomeUnobserved(box, () => {
unobservedCount++
if (i + 1 < N) {
disposers[i + 1]()
}
})
})

expect(() => disposers[0]()).not.toThrow()

// the whole chain should have unwound, not just the first link
expect(unobservedCount).toBe(N)
})

test("works with ObservableSet #3595", () => {
const onSetObserved = jest.fn()
const onSetUnobserved = jest.fn()
Expand Down
8 changes: 8 additions & 0 deletions packages/mobx/src/core/globalstate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ export class MobXGlobals {
*/
isRunningReactions = false

/**
* Are we currently draining pendingUnobservations in endBatch?
* An onBecomeUnobserved handler can dispose a Reaction, which calls
* startBatch/endBatch again; this guards against re-entering the same
* drain loop recursively (see endBatch in observable.ts).
*/
isRunningUnobservations = false

/**
* Is it allowed to change observables at this point?
* In general, MobX doesn't allow that when running computations and React.render.
Expand Down
39 changes: 24 additions & 15 deletions packages/mobx/src/core/observable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,24 +111,33 @@ export function endBatch() {
if (--globalState.inBatch === 0) {
runReactions()
// the batch is actually about to finish, all unobserving should happen here.
const list = globalState.pendingUnobservations
for (let i = 0; i < list.length; i++) {
const observable = list[i]
observable.isPendingUnobservation = false
if (observable.observers_.size === 0) {
if (observable.isBeingObserved) {
// if this observable had reactive observers, trigger the hooks
observable.isBeingObserved = false
observable.onBUO()
}
if (observable instanceof ComputedValue) {
// computed values are automatically teared down when the last observer leaves
// this process happens recursively, this computed might be the last observabe of another, etc..
observable.suspend_()
// Guard against re-entering this loop: an onBUO handler can dispose a Reaction,
// which calls startBatch/endBatch again while we're still iterating. Bail out of
// the nested call instead of recursing; the outer loop re-reads list.length on
// every iteration, so it picks up anything the nested dispose() pushes onto the
// same pendingUnobservations array.
if (!globalState.isRunningUnobservations) {
globalState.isRunningUnobservations = true
const list = globalState.pendingUnobservations
for (let i = 0; i < list.length; i++) {
const observable = list[i]
observable.isPendingUnobservation = false
if (observable.observers_.size === 0) {
if (observable.isBeingObserved) {
// if this observable had reactive observers, trigger the hooks
observable.isBeingObserved = false
observable.onBUO()
}
if (observable instanceof ComputedValue) {
// computed values are automatically teared down when the last observer leaves
// this process happens recursively, this computed might be the last observabe of another, etc..
observable.suspend_()
}
}
}
globalState.pendingUnobservations = []
globalState.isRunningUnobservations = false
}
Comment on lines +119 to 146

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch, fixed in af07124: wrapped the drain in try/finally so isRunningUnobservations always releases, added a regression test (handler throws + a following unrelated disposal still fires normally) to lock this in.

globalState.pendingUnobservations = []
}
}

Expand Down