From 9444c624b957b489875e1c6b45deb290034ce4e9 Mon Sep 17 00:00:00 2001 From: "MD. MOHIBUR RAHMAN" <35300157+mrpmohiburrahman@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:19:59 +0600 Subject: [PATCH 1/6] fix: Fix onBecomeObserved not firing when a computed becomes observed while serving a cached value (#4694) Observation did not cascade, while unobservation always has. `isBeingObserved` was set only on the observable handed to `reportObserved`, so a computed that gained an observer without recomputing never re-reported its own dependencies and their `onBO()` never fired. Add `markObserved()`, which performs the transition and recurses through `observing_`, and call it from `ComputedValue.get()` on the cache-hit branch only, where the getter will not run and so will not re-report the dependencies itself. --- .changeset/cascade-onbecomeobserved.md | 5 +++ .../mobx/__tests__/base/become-observed.ts | 45 +++++++++++++++++++ packages/mobx/src/core/computedvalue.ts | 6 +++ packages/mobx/src/core/observable.ts | 18 ++++++++ 4 files changed, 74 insertions(+) create mode 100644 .changeset/cascade-onbecomeobserved.md diff --git a/.changeset/cascade-onbecomeobserved.md b/.changeset/cascade-onbecomeobserved.md new file mode 100644 index 000000000..4c2d500bd --- /dev/null +++ b/.changeset/cascade-onbecomeobserved.md @@ -0,0 +1,5 @@ +--- +"mobx": patch +--- + +fix: `onBecomeObserved` is now called for the dependencies of a computed that becomes observed while serving a cached value. Previously, observation only cascaded when the newly observed computed also happened to recompute, so an observable with a live observer chain up to a running reaction could still report itself as unobserved and never fire its hook. diff --git a/packages/mobx/__tests__/base/become-observed.ts b/packages/mobx/__tests__/base/become-observed.ts index ed73345fd..0a4d975eb 100644 --- a/packages/mobx/__tests__/base/become-observed.ts +++ b/packages/mobx/__tests__/base/become-observed.ts @@ -570,3 +570,48 @@ test("works with ObservableSet #3595", () => { expect(onSetObserved).toHaveBeenCalledTimes(1) expect(onSetUnobserved).toHaveBeenCalledTimes(1) }) + +test("onBecomeObserved fires when a computed becomes observed while serving a cached value #4547", () => { + const events: string[] = [] + + const o = observable.box(1) + onBecomeObserved(o, () => events.push("BO")) + onBecomeUnobserved(o, () => events.push("BUO")) + const c = computed(() => o.get()) + + let disposeAutorun: () => void + runInAction(() => { + // non-reactive read inside the batch leaves `c` up-to-date but unobserved + void c.get() + // `c` becomes observed during endBatch() without recomputing, so it never + // re-reports `o` — the hook has to cascade instead + disposeAutorun = autorun(() => void c.get()) + }) + + expect(events).toEqual(["BO"]) + + disposeAutorun!() + expect(events).toEqual(["BO", "BUO"]) +}) + +test("onBecomeObserved cascades through a chain of cached computeds #4547", () => { + const events: string[] = [] + + const o = observable.box(1) + onBecomeObserved(o, () => events.push("BO")) + onBecomeUnobserved(o, () => events.push("BUO")) + // two levels, as in the reported issue: the cascade has to recurse + const inner = computed(() => o.get()) + const outer = computed(() => inner.get()) + + let disposeAutorun: () => void + runInAction(() => { + void outer.get() + disposeAutorun = autorun(() => void outer.get()) + }) + + expect(events).toEqual(["BO"]) + + disposeAutorun!() + expect(events).toEqual(["BO", "BUO"]) +}) diff --git a/packages/mobx/src/core/computedvalue.ts b/packages/mobx/src/core/computedvalue.ts index 3e7433211..aa573ac42 100644 --- a/packages/mobx/src/core/computedvalue.ts +++ b/packages/mobx/src/core/computedvalue.ts @@ -14,6 +14,7 @@ import { globalState, isCaughtException, isSpyEnabled, + markObserved, propagateChangeConfirmed, propagateMaybeChanged, reportObserved, @@ -211,6 +212,7 @@ export class ComputedValue implements IObservable, IComputedValue, IDeriva endBatch() } } else { + const wasBeingObserved = this.isBeingObserved reportObserved(this) if (shouldCompute(this)) { let prevTrackingContext = globalState.trackingContext @@ -221,6 +223,10 @@ export class ComputedValue implements IObservable, IComputedValue, IDeriva propagateChangeConfirmed(this) } globalState.trackingContext = prevTrackingContext + } else if (!wasBeingObserved && this.isBeingObserved) { + // We just became observed while serving a cached value, so the getter + // won't run and won't re-report our dependencies. Cascade to them. #4547 + this.observing_.forEach(markObserved) } } const result = this.value_! diff --git a/packages/mobx/src/core/observable.ts b/packages/mobx/src/core/observable.ts index 3d5351adc..6b6172f11 100644 --- a/packages/mobx/src/core/observable.ts +++ b/packages/mobx/src/core/observable.ts @@ -148,6 +148,24 @@ export function endBatch() { } } +/** + * Marks an observable as observed, cascading into the dependencies of a ComputedValue. + * Unobservation already cascades (`suspend_` -> `clearObserving`), observation normally + * only does so by accident: a newly observed computed usually recomputes and re-reports + * its dependencies. When it serves a cached value instead nothing re-reports them, so the + * transition has to be propagated by hand. See #4547. + */ +export function markObserved(observable: IObservable) { + if (observable.isBeingObserved) { + return + } + observable.isBeingObserved = true + observable.onBO() + // No queueForUnobservation here: the observer links already exist, so the regular + // suspend_ -> clearObserving -> removeObserver teardown still delivers the onBUO. + observable.observing_?.forEach(markObserved) +} + export function reportObserved(observable: IObservable): boolean { checkIfStateReadsAreAllowed(observable) From f2db7593573341902116e5b4f2e1a0231fd6d58f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:05:44 +0200 Subject: [PATCH 2/6] Version Packages (#4688) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/cascade-onbecomeobserved.md | 5 ----- .changeset/deep-enhancer-primitive-fast-path.md | 5 ----- .../fix-endbatch-unobservation-reentrancy.md | 5 ----- .changeset/fix-observableset-receiver-order.md | 5 ----- .changeset/lazy-observers-allocation.md | 5 ----- .changeset/use-observer-first-render-retention.md | 5 ----- packages/mobx-react-lite/CHANGELOG.md | 6 ++++++ packages/mobx-react-lite/package.json | 4 ++-- packages/mobx/CHANGELOG.md | 14 ++++++++++++++ packages/mobx/package.json | 2 +- 10 files changed, 23 insertions(+), 33 deletions(-) delete mode 100644 .changeset/cascade-onbecomeobserved.md delete mode 100644 .changeset/deep-enhancer-primitive-fast-path.md delete mode 100644 .changeset/fix-endbatch-unobservation-reentrancy.md delete mode 100644 .changeset/fix-observableset-receiver-order.md delete mode 100644 .changeset/lazy-observers-allocation.md delete mode 100644 .changeset/use-observer-first-render-retention.md diff --git a/.changeset/cascade-onbecomeobserved.md b/.changeset/cascade-onbecomeobserved.md deleted file mode 100644 index 4c2d500bd..000000000 --- a/.changeset/cascade-onbecomeobserved.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"mobx": patch ---- - -fix: `onBecomeObserved` is now called for the dependencies of a computed that becomes observed while serving a cached value. Previously, observation only cascaded when the newly observed computed also happened to recompute, so an observable with a live observer chain up to a running reaction could still report itself as unobserved and never fire its hook. diff --git a/.changeset/deep-enhancer-primitive-fast-path.md b/.changeset/deep-enhancer-primitive-fast-path.md deleted file mode 100644 index 2a7f3b8a0..000000000 --- a/.changeset/deep-enhancer-primitive-fast-path.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"mobx": patch ---- - -perf: fast-path primitives in `deepEnhancer`. Writing a primitive into a deep observable no longer runs the observable/array/plain-object/Map/Set/function type checks; primitives can never be made observable, so they are returned immediately. Creating an observable array of primitives is ~4x faster, and observable Set/Map writes are ~20-25% faster in the perf suite. diff --git a/.changeset/fix-endbatch-unobservation-reentrancy.md b/.changeset/fix-endbatch-unobservation-reentrancy.md deleted file mode 100644 index 9ebd53c29..000000000 --- a/.changeset/fix-endbatch-unobservation-reentrancy.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"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. diff --git a/.changeset/fix-observableset-receiver-order.md b/.changeset/fix-observableset-receiver-order.md deleted file mode 100644 index 12a1cc135..000000000 --- a/.changeset/fix-observableset-receiver-order.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"mobx": patch ---- - -Fix ObservableSet union, intersection and symmetricDifference to return results in receiver order, matching native Set, when the argument is a plain Set. diff --git a/.changeset/lazy-observers-allocation.md b/.changeset/lazy-observers-allocation.md deleted file mode 100644 index d1d1f48e4..000000000 --- a/.changeset/lazy-observers-allocation.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"mobx": patch ---- - -perf: lazily allocate the internal `observers_` Set. Atoms and computed values no longer allocate an empty `Set` upfront; it is created on first observer instead. Most atoms in large stores are never observed, so this saves roughly 160 bytes per unobserved atom (e.g. ~35% lower heap usage when hydrating 50k instances with 10 observable fields each). diff --git a/.changeset/use-observer-first-render-retention.md b/.changeset/use-observer-first-render-retention.md deleted file mode 100644 index b274e9a4f..000000000 --- a/.changeset/use-observer-first-render-retention.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"mobx-react-lite": patch ---- - -fix: `useObserver` no longer retains the element tree returned by a component's first render for the component's whole mounted life. `subscribe`/`getSnapshot` were created inside `useObserver`'s first invocation and therefore shared that invocation's closure context with the `reaction.track` callback's captures (`render`, `renderResult`); since React's `useSyncExternalStore` holds `subscribe` while the component is mounted, the first render result (and every fiber and DOM node reachable from it) could never be garbage collected. The administration object is now created by a module-level factory whose scope contains nothing render-related. diff --git a/packages/mobx-react-lite/CHANGELOG.md b/packages/mobx-react-lite/CHANGELOG.md index 67bf3e54b..4ac39062d 100644 --- a/packages/mobx-react-lite/CHANGELOG.md +++ b/packages/mobx-react-lite/CHANGELOG.md @@ -1,5 +1,11 @@ # mobx-react-lite +## 5.0.1 + +### Patch Changes + +- [`043850ed96266f8bea42bf643a65e62245a98b3c`](https://github.com/mobxjs/mobx/commit/043850ed96266f8bea42bf643a65e62245a98b3c) [#4689](https://github.com/mobxjs/mobx/pull/4689) Thanks [@gesposito](https://github.com/gesposito)! - fix: `useObserver` no longer retains the element tree returned by a component's first render for the component's whole mounted life. `subscribe`/`getSnapshot` were created inside `useObserver`'s first invocation and therefore shared that invocation's closure context with the `reaction.track` callback's captures (`render`, `renderResult`); since React's `useSyncExternalStore` holds `subscribe` while the component is mounted, the first render result (and every fiber and DOM node reachable from it) could never be garbage collected. The administration object is now created by a module-level factory whose scope contains nothing render-related. + ## 5.0.0 ### Major Changes diff --git a/packages/mobx-react-lite/package.json b/packages/mobx-react-lite/package.json index a2a95b413..9ac0029c1 100644 --- a/packages/mobx-react-lite/package.json +++ b/packages/mobx-react-lite/package.json @@ -1,6 +1,6 @@ { "name": "mobx-react-lite", - "version": "5.0.0", + "version": "5.0.1", "description": "Lightweight React bindings for MobX based on function components and Hooks", "source": "src/index.ts", "type": "commonjs", @@ -58,7 +58,7 @@ "react": "^18 || ^19" }, "devDependencies": { - "mobx": "^7.0.0" + "mobx": "^7.0.1" }, "keywords": [ "mobx", diff --git a/packages/mobx/CHANGELOG.md b/packages/mobx/CHANGELOG.md index b22b79769..9c84cbbce 100644 --- a/packages/mobx/CHANGELOG.md +++ b/packages/mobx/CHANGELOG.md @@ -1,5 +1,19 @@ # mobx +## 7.0.1 + +### Patch Changes + +- [`9444c624b957b489875e1c6b45deb290034ce4e9`](https://github.com/mobxjs/mobx/commit/9444c624b957b489875e1c6b45deb290034ce4e9) [#4694](https://github.com/mobxjs/mobx/pull/4694) Thanks [@mrpmohiburrahman](https://github.com/mrpmohiburrahman)! - fix: `onBecomeObserved` is now called for the dependencies of a computed that becomes observed while serving a cached value. Previously, observation only cascaded when the newly observed computed also happened to recompute, so an observable with a live observer chain up to a running reaction could still report itself as unobserved and never fire its hook. + +- [`53bb83fdf455a4e606bb721ea10040ded7c57796`](https://github.com/mobxjs/mobx/commit/53bb83fdf455a4e606bb721ea10040ded7c57796) [#4683](https://github.com/mobxjs/mobx/pull/4683) Thanks [@gesposito](https://github.com/gesposito)! - perf: fast-path primitives in `deepEnhancer`. Writing a primitive into a deep observable no longer runs the observable/array/plain-object/Map/Set/function type checks; primitives can never be made observable, so they are returned immediately. Creating an observable array of primitives is ~4x faster, and observable Set/Map writes are ~20-25% faster in the perf suite. + +- [`030498d6b2bfe4cc27340fed8c706177cb3b28e1`](https://github.com/mobxjs/mobx/commit/030498d6b2bfe4cc27340fed8c706177cb3b28e1) [#4684](https://github.com/mobxjs/mobx/pull/4684) Thanks [@a-y-ibrahim](https://github.com/a-y-ibrahim)! - 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. + +- [`a9086076b9ead1a9c933215bffaa9b57d44f6829`](https://github.com/mobxjs/mobx/commit/a9086076b9ead1a9c933215bffaa9b57d44f6829) [#4681](https://github.com/mobxjs/mobx/pull/4681) Thanks [@spokodev](https://github.com/spokodev)! - Fix ObservableSet union, intersection and symmetricDifference to return results in receiver order, matching native Set, when the argument is a plain Set. + +- [`c65a4e14cf48b42cb792dc4c64edbcf56234b32d`](https://github.com/mobxjs/mobx/commit/c65a4e14cf48b42cb792dc4c64edbcf56234b32d) [#4682](https://github.com/mobxjs/mobx/pull/4682) Thanks [@gesposito](https://github.com/gesposito)! - perf: lazily allocate the internal `observers_` Set. Atoms and computed values no longer allocate an empty `Set` upfront; it is created on first observer instead. Most atoms in large stores are never observed, so this saves roughly 160 bytes per unobserved atom (e.g. ~35% lower heap usage when hydrating 50k instances with 10 observable fields each). + ## 7.0.0 ### Major Changes diff --git a/packages/mobx/package.json b/packages/mobx/package.json index d7a1ed869..447973a6c 100644 --- a/packages/mobx/package.json +++ b/packages/mobx/package.json @@ -1,6 +1,6 @@ { "name": "mobx", - "version": "7.0.0", + "version": "7.0.1", "description": "Simple, scalable state management.", "source": "src/mobx.ts", "type": "commonjs", From 6d8b5fa6c7596d7e5b16e55e3121d81c0c5bb21a Mon Sep 17 00:00:00 2001 From: Alexander Kireyev Date: Wed, 19 Aug 2026 21:19:40 +0700 Subject: [PATCH 3/6] fix: ObservableSet.replace only emits events for actual changes (#4672) * fix: ObservableSet.replace only emits events for actual changes ObservableSet.replace previously cleared the set and re-added every value, emitting a delete event for every existing element followed by an add event for every replacement element - even when the contents were unchanged. This also retriggered reactions unnecessarily. It now deletes only the values that are not part of the replacement and adds only the new ones (add/delete are already no-ops for unchanged values), mirroring the behavior of ObservableMap.replace. Closes #3761 * fix(set): address review feedback on ObservableSet.replace Per review on #3761: - Reuse `other` directly when it is already a Set instead of allocating a second one (observable sets are already snapshotted earlier and the Set is only read, never mutated). - Short-circuit the trivial cases: an empty replacement is a plain `clear()`, and replacing into an empty set only needs the adds. - Document the (observable) iteration-order change in the changeset: surviving values now keep their original position and new values are appended rather than the set being reordered to match the argument. - Reorder the replacement arrays in the tests so they assert the resulting iteration order and cover the documented behavior change. --------- Co-authored-by: Michel Weststrate --- .changeset/observable-set-replace-events.md | 7 ++ packages/mobx/__tests__/base/set.js | 106 ++++++++++++++++++++ packages/mobx/src/types/observableset.ts | 47 +++++++-- 3 files changed, 149 insertions(+), 11 deletions(-) create mode 100644 .changeset/observable-set-replace-events.md diff --git a/.changeset/observable-set-replace-events.md b/.changeset/observable-set-replace-events.md new file mode 100644 index 000000000..159df9c6d --- /dev/null +++ b/.changeset/observable-set-replace-events.md @@ -0,0 +1,7 @@ +--- +"mobx": patch +--- + +Fix `ObservableSet.replace` emitting spurious `delete`/`add` events (and triggering reactions) for values that are unchanged. It now only fires `delete` for removed values and `add` for newly added ones, mirroring `ObservableMap.replace`. + +Note: because `replace` no longer clears and re-adds every value, the iteration order after `replace` changes in a (subtle but observable) way. Surviving values now keep their original relative position and newly added values are appended, instead of the whole set being reordered to match the argument. For example, `set(["a", "b", "c"]).replace(["d", "b", "a"])` previously iterated as `d, b, a`, and now iterates as `a, b, d`. This is arguably the more correct behavior (unchanged values are genuinely unchanged), but if you relied on `replace` reordering the set to match its argument, you may need to adjust. diff --git a/packages/mobx/__tests__/base/set.js b/packages/mobx/__tests__/base/set.js index 6d211c241..b679d1410 100644 --- a/packages/mobx/__tests__/base/set.js +++ b/packages/mobx/__tests__/base/set.js @@ -523,3 +523,109 @@ describe("Observable Set interceptors", () => { expect([...s]).toStrictEqual([1, 10]) }) }) + +describe("#3761 replace only fires events for actual changes", () => { + test("replace only emits delete/add for removed/added values", () => { + const s = set(["a", "b", "c"]) + const events = [] + mobx.observe(s, change => { + delete change.observableKind + delete change.debugObjectName + events.push(change) + }) + + // The replacement is intentionally ordered differently from the original + // ("c", "a", "d" vs "a", "b", "c"): "b" is removed, "d" is added, "a"/"c" survive. + s.replace(["c", "a", "d"]) + + expect(events).toEqual([ + { object: s, oldValue: "b", type: "delete" }, + { object: s, newValue: "d", type: "add" } + ]) + // Surviving values keep their original relative order ("a" before "c") and the + // added value is appended, so the result iterates as ["a", "c", "d"]. See the + // iteration-order note in the changeset / #3761 discussion. + expect(mobx.values(s)).toEqual(["a", "c", "d"]) + }) + + test("replace with identical content emits no events", () => { + const s = set(["x", "y"]) + const events = [] + mobx.observe(s, change => events.push(change)) + + s.replace(["x", "y"]) + + expect(events).toEqual([]) + expect(mobx.values(s)).toEqual(["x", "y"]) + }) + + test("replace with an ES6 Set only emits events for actual changes", () => { + const s = set([1, 2, 3]) + const events = [] + mobx.observe(s, change => { + delete change.observableKind + delete change.debugObjectName + events.push(change) + }) + + // Reordered replacement (3, 1, 4 vs 1, 2, 3): 2 is removed, 4 is added, 1/3 survive. + s.replace(new Set([3, 1, 4])) + + expect(events).toEqual([ + { object: s, oldValue: 2, type: "delete" }, + { object: s, newValue: 4, type: "add" } + ]) + // Survivors keep their original relative order (1 before 3), 4 is appended. + expect(mobx.values(s)).toEqual([1, 3, 4]) + }) + + test("replace with an observable Set only emits events for actual changes", () => { + const s = set([1, 2, 3]) + const other = set([2, 3, 4]) + const events = [] + mobx.observe(s, change => { + delete change.observableKind + delete change.debugObjectName + events.push(change) + }) + + s.replace(other) + + expect(events).toEqual([ + { object: s, oldValue: 1, type: "delete" }, + { object: s, newValue: 4, type: "add" } + ]) + expect(mobx.values(s)).toEqual([2, 3, 4]) + }) + + test("replace with identical content does not report a change", () => { + const s = set([1, 2, 3]) + let runCount = 0 + const dispose = mobx.autorun(() => { + mobx.values(s) + runCount++ + }) + expect(runCount).toBe(1) + + // Nothing actually changes, so observers must not be notified. + s.replace([1, 2, 3]) + + expect(runCount).toBe(1) + dispose() + }) + + test("replace still honors interceptors", () => { + const s = set([1, 2]) + mobx.intercept(s, change => { + // Prevent adding 4. + if (change.type === "add" && change.newValue === 4) { + return undefined + } + return change + }) + + s.replace([2, 3, 4]) + + expect(mobx.values(s)).toEqual([2, 3]) + }) +}) diff --git a/packages/mobx/src/types/observableset.ts b/packages/mobx/src/types/observableset.ts index 7be89f061..e9a7c11f4 100644 --- a/packages/mobx/src/types/observableset.ts +++ b/packages/mobx/src/types/observableset.ts @@ -265,17 +265,42 @@ export class ObservableSet implements Set, IInterceptable { - if (Array.isArray(other)) { - this.clear() - other.forEach(value => this.add(value)) - } else if (isES6Set(other)) { - this.clear() - other.forEach(value => this.add(value)) - } else if (other !== null && other !== undefined) { - die(41, other) - } - }) + if (Array.isArray(other) || isES6Set(other)) { + // Only emit `delete`/`add` events (and `reportChanged`) for values that + // actually change, instead of clearing and re-adding everything. `add` and + // `delete` are already no-ops for values that are respectively already + // present or already absent, so we just need to avoid deleting values that + // are part of the replacement. See #3761. + transaction(() => { + // Collect the desired values for quick lookup. `other` is already a Set + // here when it was passed (or snapshotted from an observable set) as one, + // so reuse it rather than allocating another; arrays are wrapped (which + // also dedupes them). + const replacementValues: Set = isES6Set(other) + ? other + : new Set(other as Iterable) + // Short-circuit the trivial cases: an empty replacement is just a clear, + // and replacing into an empty set only needs the adds. + if (replacementValues.size === 0) { + this.clear() + return + } + if (this.data_.size === 0) { + replacementValues.forEach(value => this.add(value)) + return + } + // Delete values that are not part of the replacement. + for (const value of this.data_.values()) { + if (!replacementValues.has(this.dehanceValue_(value))) { + this.delete(value) + } + } + // Add new values; values that are already present are a no-op. + replacementValues.forEach(value => this.add(value)) + }) + } else if (other !== null && other !== undefined) { + die(41, other) + } return this } From ec1b708026c1578e1c1f6c7bd90be18b26f7ce85 Mon Sep 17 00:00:00 2001 From: Gianluca Esposito Date: Wed, 19 Aug 2026 16:25:52 +0200 Subject: [PATCH 4/6] perf: evaluate NODE_ENV once at module scope in env-agnostic esm builds (#4695) The generic esm outputs (dist/.esm.js and dist/.mjs) shipped every __DEV__ check as a live process.env.NODE_ENV read (147 in mobx). process.env is an exotic object in Node, so each read performs a real environment lookup, which unbundled ESM consumers (Node SSR, vitest, RN dev) pay on hot paths. For env-agnostic builds only: skip babel-plugin-dev-expression, rename the free __DEV__ identifier to __MOBX_DEV__, and declare it once via output.intro. All env-set artifacts are byte-identical; 2M observed writes drop from 3324ms to 319ms on Node 26. --- .changeset/hoist-node-env-check-esm-builds.md | 7 ++++ scripts/create-rollup-config.mjs | 32 ++++++++++++++++--- 2 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 .changeset/hoist-node-env-check-esm-builds.md diff --git a/.changeset/hoist-node-env-check-esm-builds.md b/.changeset/hoist-node-env-check-esm-builds.md new file mode 100644 index 000000000..97a9c2187 --- /dev/null +++ b/.changeset/hoist-node-env-check-esm-builds.md @@ -0,0 +1,7 @@ +--- +"mobx": patch +"mobx-react": patch +"mobx-react-lite": patch +--- + +perf: evaluate `NODE_ENV` once at module scope in the env-agnostic esm bundles (`dist/.esm.js` and `dist/.mjs`) instead of at every `__DEV__` call site. `process.env` is an exotic object in Node, so each check performed a real environment lookup on hot paths; consumers that execute these files as-is (Node ESM, vitest, SSR) see roughly 10x faster observable writes in dev mode. All env-set artifacts and bundler output are unchanged. diff --git a/scripts/create-rollup-config.mjs b/scripts/create-rollup-config.mjs index 5e3137641..78f1990d1 100644 --- a/scripts/create-rollup-config.mjs +++ b/scripts/create-rollup-config.mjs @@ -81,7 +81,16 @@ const stripShebang = () => ({ } }) -const babelPlugin = () => +// The env-agnostic esm outputs (`dist/.esm.js` and `dist/.mjs`) keep a +// runtime `NODE_ENV` check, but evaluate it once at module scope instead of at +// every `__DEV__` call site: `process.env` is an exotic object in Node, so every +// read performs a real environment lookup, and unbundled consumers pay that on hot paths. +// The identifier is scoped (`__MOBX_DEV__`) so consumer-side replacers that blindly define +// `__DEV__` (e.g. `DefinePlugin({ __DEV__: ... })`) cannot collide with the declaration. +const DEV_IDENTIFIER = "__MOBX_DEV__" +const DEV_DECLARATION = `const ${DEV_IDENTIFIER} = process.env.NODE_ENV !== "production";` + +const babelPlugin = ({ devExpression = true } = {}) => babel({ babelHelpers: "bundled", exclude: "node_modules/**", @@ -105,7 +114,10 @@ const babelPlugin = () => ], plugins: [ "babel-plugin-annotate-pure-calls", - "babel-plugin-dev-expression", + // For env-agnostic builds `__DEV__` stays a free identifier; it is + // renamed to `__MOBX_DEV__` and defined once by the `DEV_DECLARATION` + // intro instead of being inlined at every call site. + devExpression && "babel-plugin-dev-expression", ["@babel/plugin-proposal-class-properties", { loose: true }] ].filter(Boolean) }) @@ -121,6 +133,7 @@ const createConfig = ({ globals }) => { const shouldMinify = env === "production" + const isEnvAgnostic = env === undefined const outputName = [`${dist}/${packageBase}`, format, env, shouldMinify ? "min" : "", "js"] .filter(Boolean) .join(".") @@ -136,6 +149,10 @@ const createConfig = ({ exports: "named" } + const outputs = [output, ...extraOutputs].map(o => + isEnvAgnostic ? { ...o, intro: DEV_DECLARATION } : o + ) + return { input, external(id) { @@ -147,7 +164,7 @@ const createConfig = ({ treeshake: { propertyReadSideEffects: false }, - output: [output, ...extraOutputs], + output: outputs, plugins: [ nodeResolve({ mainFields: ["module", "main", "browser"], @@ -171,7 +188,14 @@ const createConfig = ({ check: declarations, useTsconfigDeclarationDir: false }), - babelPlugin(), + babelPlugin({ devExpression: !isEnvAgnostic }), + isEnvAgnostic && + replace({ + preventAssignment: true, + values: { + __DEV__: DEV_IDENTIFIER + } + }), env !== undefined && replace({ preventAssignment: true, From f719c63d7e836fc430db37af512aff1c4083ed04 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:35:45 +0200 Subject: [PATCH 5/6] Version Packages (#4697) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/hoist-node-env-check-esm-builds.md | 7 ------- .changeset/observable-set-replace-events.md | 7 ------- packages/mobx-react-lite/CHANGELOG.md | 6 ++++++ packages/mobx-react-lite/package.json | 4 ++-- packages/mobx-react/CHANGELOG.md | 9 +++++++++ packages/mobx-react/package.json | 8 ++++---- packages/mobx/CHANGELOG.md | 10 ++++++++++ packages/mobx/package.json | 2 +- 8 files changed, 32 insertions(+), 21 deletions(-) delete mode 100644 .changeset/hoist-node-env-check-esm-builds.md delete mode 100644 .changeset/observable-set-replace-events.md diff --git a/.changeset/hoist-node-env-check-esm-builds.md b/.changeset/hoist-node-env-check-esm-builds.md deleted file mode 100644 index 97a9c2187..000000000 --- a/.changeset/hoist-node-env-check-esm-builds.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"mobx": patch -"mobx-react": patch -"mobx-react-lite": patch ---- - -perf: evaluate `NODE_ENV` once at module scope in the env-agnostic esm bundles (`dist/.esm.js` and `dist/.mjs`) instead of at every `__DEV__` call site. `process.env` is an exotic object in Node, so each check performed a real environment lookup on hot paths; consumers that execute these files as-is (Node ESM, vitest, SSR) see roughly 10x faster observable writes in dev mode. All env-set artifacts and bundler output are unchanged. diff --git a/.changeset/observable-set-replace-events.md b/.changeset/observable-set-replace-events.md deleted file mode 100644 index 159df9c6d..000000000 --- a/.changeset/observable-set-replace-events.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"mobx": patch ---- - -Fix `ObservableSet.replace` emitting spurious `delete`/`add` events (and triggering reactions) for values that are unchanged. It now only fires `delete` for removed values and `add` for newly added ones, mirroring `ObservableMap.replace`. - -Note: because `replace` no longer clears and re-adds every value, the iteration order after `replace` changes in a (subtle but observable) way. Surviving values now keep their original relative position and newly added values are appended, instead of the whole set being reordered to match the argument. For example, `set(["a", "b", "c"]).replace(["d", "b", "a"])` previously iterated as `d, b, a`, and now iterates as `a, b, d`. This is arguably the more correct behavior (unchanged values are genuinely unchanged), but if you relied on `replace` reordering the set to match its argument, you may need to adjust. diff --git a/packages/mobx-react-lite/CHANGELOG.md b/packages/mobx-react-lite/CHANGELOG.md index 4ac39062d..11fdde76f 100644 --- a/packages/mobx-react-lite/CHANGELOG.md +++ b/packages/mobx-react-lite/CHANGELOG.md @@ -1,5 +1,11 @@ # mobx-react-lite +## 5.0.2 + +### Patch Changes + +- [`ec1b708026c1578e1c1f6c7bd90be18b26f7ce85`](https://github.com/mobxjs/mobx/commit/ec1b708026c1578e1c1f6c7bd90be18b26f7ce85) [#4695](https://github.com/mobxjs/mobx/pull/4695) Thanks [@gesposito](https://github.com/gesposito)! - perf: evaluate `NODE_ENV` once at module scope in the env-agnostic esm bundles (`dist/.esm.js` and `dist/.mjs`) instead of at every `__DEV__` call site. `process.env` is an exotic object in Node, so each check performed a real environment lookup on hot paths; consumers that execute these files as-is (Node ESM, vitest, SSR) see roughly 10x faster observable writes in dev mode. All env-set artifacts and bundler output are unchanged. + ## 5.0.1 ### Patch Changes diff --git a/packages/mobx-react-lite/package.json b/packages/mobx-react-lite/package.json index 9ac0029c1..85b6d8e87 100644 --- a/packages/mobx-react-lite/package.json +++ b/packages/mobx-react-lite/package.json @@ -1,6 +1,6 @@ { "name": "mobx-react-lite", - "version": "5.0.1", + "version": "5.0.2", "description": "Lightweight React bindings for MobX based on function components and Hooks", "source": "src/index.ts", "type": "commonjs", @@ -58,7 +58,7 @@ "react": "^18 || ^19" }, "devDependencies": { - "mobx": "^7.0.1" + "mobx": "^7.0.2" }, "keywords": [ "mobx", diff --git a/packages/mobx-react/CHANGELOG.md b/packages/mobx-react/CHANGELOG.md index 68a0a12fc..5b876b98e 100644 --- a/packages/mobx-react/CHANGELOG.md +++ b/packages/mobx-react/CHANGELOG.md @@ -1,5 +1,14 @@ # mobx-react +## 10.0.1 + +### Patch Changes + +- [`ec1b708026c1578e1c1f6c7bd90be18b26f7ce85`](https://github.com/mobxjs/mobx/commit/ec1b708026c1578e1c1f6c7bd90be18b26f7ce85) [#4695](https://github.com/mobxjs/mobx/pull/4695) Thanks [@gesposito](https://github.com/gesposito)! - perf: evaluate `NODE_ENV` once at module scope in the env-agnostic esm bundles (`dist/.esm.js` and `dist/.mjs`) instead of at every `__DEV__` call site. `process.env` is an exotic object in Node, so each check performed a real environment lookup on hot paths; consumers that execute these files as-is (Node ESM, vitest, SSR) see roughly 10x faster observable writes in dev mode. All env-set artifacts and bundler output are unchanged. + +- Updated dependencies [[`ec1b708026c1578e1c1f6c7bd90be18b26f7ce85`](https://github.com/mobxjs/mobx/commit/ec1b708026c1578e1c1f6c7bd90be18b26f7ce85)]: + - mobx-react-lite@5.0.2 + ## 10.0.0 ### Major Changes diff --git a/packages/mobx-react/package.json b/packages/mobx-react/package.json index a07016432..118a24227 100644 --- a/packages/mobx-react/package.json +++ b/packages/mobx-react/package.json @@ -1,6 +1,6 @@ { "name": "mobx-react", - "version": "10.0.0", + "version": "10.0.1", "description": "React bindings for MobX. Create fully reactive components.", "source": "src/index.ts", "type": "commonjs", @@ -55,15 +55,15 @@ }, "homepage": "https://mobx.js.org", "dependencies": { - "mobx-react-lite": "^5.0.0" + "mobx-react-lite": "^5.0.2" }, "peerDependencies": { "mobx": "^7.0.0", "react": "^18 || ^19" }, "devDependencies": { - "mobx": "^7.0.0", - "mobx-react-lite": "^5.0.0" + "mobx": "^7.0.2", + "mobx-react-lite": "^5.0.2" }, "keywords": [ "mobx", diff --git a/packages/mobx/CHANGELOG.md b/packages/mobx/CHANGELOG.md index 9c84cbbce..7a33afde1 100644 --- a/packages/mobx/CHANGELOG.md +++ b/packages/mobx/CHANGELOG.md @@ -1,5 +1,15 @@ # mobx +## 7.0.2 + +### Patch Changes + +- [`ec1b708026c1578e1c1f6c7bd90be18b26f7ce85`](https://github.com/mobxjs/mobx/commit/ec1b708026c1578e1c1f6c7bd90be18b26f7ce85) [#4695](https://github.com/mobxjs/mobx/pull/4695) Thanks [@gesposito](https://github.com/gesposito)! - perf: evaluate `NODE_ENV` once at module scope in the env-agnostic esm bundles (`dist/.esm.js` and `dist/.mjs`) instead of at every `__DEV__` call site. `process.env` is an exotic object in Node, so each check performed a real environment lookup on hot paths; consumers that execute these files as-is (Node ESM, vitest, SSR) see roughly 10x faster observable writes in dev mode. All env-set artifacts and bundler output are unchanged. + +- [`6d8b5fa6c7596d7e5b16e55e3121d81c0c5bb21a`](https://github.com/mobxjs/mobx/commit/6d8b5fa6c7596d7e5b16e55e3121d81c0c5bb21a) [#4672](https://github.com/mobxjs/mobx/pull/4672) Thanks [@chatman-media](https://github.com/chatman-media)! - Fix `ObservableSet.replace` emitting spurious `delete`/`add` events (and triggering reactions) for values that are unchanged. It now only fires `delete` for removed values and `add` for newly added ones, mirroring `ObservableMap.replace`. + + Note: because `replace` no longer clears and re-adds every value, the iteration order after `replace` changes in a (subtle but observable) way. Surviving values now keep their original relative position and newly added values are appended, instead of the whole set being reordered to match the argument. For example, `set(["a", "b", "c"]).replace(["d", "b", "a"])` previously iterated as `d, b, a`, and now iterates as `a, b, d`. This is arguably the more correct behavior (unchanged values are genuinely unchanged), but if you relied on `replace` reordering the set to match its argument, you may need to adjust. + ## 7.0.1 ### Patch Changes diff --git a/packages/mobx/package.json b/packages/mobx/package.json index 447973a6c..438c1467c 100644 --- a/packages/mobx/package.json +++ b/packages/mobx/package.json @@ -1,6 +1,6 @@ { "name": "mobx", - "version": "7.0.1", + "version": "7.0.2", "description": "Simple, scalable state management.", "source": "src/mobx.ts", "type": "commonjs", From 6f002b8e543ca39d3ee23681e28848d7f1724fae Mon Sep 17 00:00:00 2001 From: Gianluca Esposito Date: Wed, 19 Aug 2026 17:03:34 +0200 Subject: [PATCH 6/6] perf: route Node ESM imports to the env-switched CJS entry via a node export condition (#4696) Node ESM consumers previously always loaded dist/.mjs: unminified, dev-and-prod code decided per call by 147 live process.env.NODE_ENV reads, and a second module instance for apps that also require('mobx') (#1082 class). Adding a node condition (after react-native, before import) routes Node and Bun to dist/index.js, the existing runtime switch over the prebaked development/production CJS builds: single instance across import/require, env-correct code, working named and default imports via cjs-module-lexer. Bundler paths are unchanged (web ESM still gets .mjs, react-native still gets .esm.js; verified with an enhanced-resolve@5 condition matrix); the one disclosed change besides Node itself is bundlers targeting node, which now bundle the CJS entry. attw: node16-from-ESM goes from 'masquerading as CJS' to consistent CJS. --- .changeset/node-export-condition.md | 7 +++++++ packages/mobx-react-lite/package.json | 4 ++++ packages/mobx-react/package.json | 4 ++++ packages/mobx/package.json | 4 ++++ 4 files changed, 19 insertions(+) create mode 100644 .changeset/node-export-condition.md diff --git a/.changeset/node-export-condition.md b/.changeset/node-export-condition.md new file mode 100644 index 000000000..35f9a1a5f --- /dev/null +++ b/.changeset/node-export-condition.md @@ -0,0 +1,7 @@ +--- +"mobx": patch +"mobx-react": patch +"mobx-react-lite": patch +--- + +perf: add a `node` export condition that routes Node and Bun to the existing `dist/index.js` entry, which picks the prebaked development or production CJS build once at require time. `import`ing mobx in Node no longer executes the env-agnostic `dist/mobx.mjs` with per-call `NODE_ENV` checks, and mixing `import` and `require` in one Node app now yields a single mobx instance. diff --git a/packages/mobx-react-lite/package.json b/packages/mobx-react-lite/package.json index 85b6d8e87..7db055669 100644 --- a/packages/mobx-react-lite/package.json +++ b/packages/mobx-react-lite/package.json @@ -20,6 +20,10 @@ "types": "./dist/index.d.ts", "default": "./dist/mobxreactlite.esm.js" }, + "node": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, "import": { "types": "./dist/index.d.ts", "default": "./dist/mobxreactlite.mjs" diff --git a/packages/mobx-react/package.json b/packages/mobx-react/package.json index 118a24227..a9eeee82c 100644 --- a/packages/mobx-react/package.json +++ b/packages/mobx-react/package.json @@ -20,6 +20,10 @@ "types": "./dist/index.d.ts", "default": "./dist/mobxreact.esm.js" }, + "node": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, "import": { "types": "./dist/index.d.ts", "default": "./dist/mobxreact.mjs" diff --git a/packages/mobx/package.json b/packages/mobx/package.json index 438c1467c..b2df69a43 100644 --- a/packages/mobx/package.json +++ b/packages/mobx/package.json @@ -20,6 +20,10 @@ "types": "./dist/mobx.d.ts", "default": "./dist/mobx.esm.js" }, + "node": { + "types": "./dist/mobx.d.ts", + "default": "./dist/index.js" + }, "import": { "types": "./dist/mobx.d.ts", "default": "./dist/mobx.mjs"