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/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/.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..11fdde76f 100644 --- a/packages/mobx-react-lite/CHANGELOG.md +++ b/packages/mobx-react-lite/CHANGELOG.md @@ -1,5 +1,17 @@ # 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 + +- [`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..7db055669 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.2", "description": "Lightweight React bindings for MobX based on function components and Hooks", "source": "src/index.ts", "type": "commonjs", @@ -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" @@ -58,7 +62,7 @@ "react": "^18 || ^19" }, "devDependencies": { - "mobx": "^7.0.0" + "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..a9eeee82c 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", @@ -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" @@ -55,15 +59,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 b22b79769..7a33afde1 100644 --- a/packages/mobx/CHANGELOG.md +++ b/packages/mobx/CHANGELOG.md @@ -1,5 +1,29 @@ # 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 + +- [`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/__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/__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/package.json b/packages/mobx/package.json index d7a1ed869..b2df69a43 100644 --- a/packages/mobx/package.json +++ b/packages/mobx/package.json @@ -1,6 +1,6 @@ { "name": "mobx", - "version": "7.0.0", + "version": "7.0.2", "description": "Simple, scalable state management.", "source": "src/mobx.ts", "type": "commonjs", @@ -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" 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) 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 } 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,