From 2267786cfb90f3e84acfe85645ab49cfe082932c Mon Sep 17 00:00:00 2001 From: Izhak Lehmann Date: Wed, 18 Feb 2026 09:58:49 +0100 Subject: [PATCH 01/10] keepAlive_ is now true by default; update warn trigger and warn message --- packages/mobx/src/core/computedvalue.ts | 29 +++++++++++++------------ 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/mobx/src/core/computedvalue.ts b/packages/mobx/src/core/computedvalue.ts index cec036d6c..96db346ce 100644 --- a/packages/mobx/src/core/computedvalue.ts +++ b/packages/mobx/src/core/computedvalue.ts @@ -136,7 +136,7 @@ export class ComputedValue implements IObservable, IComputedValue, IDeriva : comparer.default) this.scope_ = options.context this.requiresReaction_ = options.requiresReaction - this.keepAlive_ = !!options.keepAlive + this.keepAlive_ = options.keepAlive !== false } onBecomeStale_() { @@ -206,14 +206,14 @@ export class ComputedValue implements IObservable, IComputedValue, IDeriva if (this.isComputing) { die(32, this.name_, this.derivation) } - if ( - globalState.inBatch === 0 && - // !globalState.trackingDerivatpion && - this.observers_.size === 0 && - !this.keepAlive_ - ) { + const isUntrackedRead = globalState.inBatch === 0 && this.observers_.size === 0 + + if (isUntrackedRead && !globalState.trackingDerivation) { + this.warnAboutUntrackedRead_() + } + + if (isUntrackedRead && !this.keepAlive_) { if (shouldCompute(this)) { - this.warnAboutUntrackedRead_() startBatch() // See perf test 'computed memoization' this.value_ = this.computeValue_(false) endBatch() @@ -348,19 +348,20 @@ export class ComputedValue implements IObservable, IComputedValue, IDeriva if (!__DEV__) { return } + let untrackedReadWarning = `Computed value '${this.name_}' is being read outside a reactive context.` + if (!this.keepAlive_) { + untrackedReadWarning += " Doing a full recompute." + } + if (this.isTracing_ !== TraceMode.NONE) { - console.log( - `[mobx.trace] Computed value '${this.name_}' is being read outside a reactive context. Doing a full recompute.` - ) + console.log(`[mobx.trace] ${untrackedReadWarning}`) } if ( typeof this.requiresReaction_ === "boolean" ? this.requiresReaction_ : globalState.computedRequiresReaction ) { - console.warn( - `[mobx] Computed value '${this.name_}' is being read outside a reactive context. Doing a full recompute.` - ) + console.warn(`[mobx] ${untrackedReadWarning}`) } } From 9d96096c1de1cf144bf2526fdbf3526092d0e907 Mon Sep 17 00:00:00 2001 From: Izhak Lehmann Date: Wed, 18 Feb 2026 09:58:59 +0100 Subject: [PATCH 02/10] update docs --- docs/computeds.md | 22 +++++++++------------- docs/configuration.md | 6 +++--- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/docs/computeds.md b/docs/computeds.md index 9e4f78a93..698c35235 100644 --- a/docs/computeds.md +++ b/docs/computeds.md @@ -98,30 +98,24 @@ When using computed values there are a couple of best practices to follow: ## Tips -
**Tip:** computed values will be suspended if they are _not_ observed +
**Tip:** computed values will stay alive (cached) even when they're _not_ observed -It sometimes confuses people new to MobX, perhaps used to a library like [Reselect](https://github.com/reduxjs/reselect), that if you create a computed property but don't use it anywhere in a reaction, it is not memoized and appears to be recomputed more often than necessary. -For example, if we extended the above example with calling `console.log(order.total)` twice, after we called `stop()`, the value would be recomputed twice. +Computed values are **memoized by default**, even outside reactions. -This allows MobX to automatically suspend computations that are not actively in use -to avoid unnecessary updates to computed values that are not being accessed. But if a computed property is _not_ in use by some reaction, then computed expressions are evaluated each time their value is requested, so they behave just like a normal property. - -If you only fiddle around computed properties might not seem efficient, but when applied in a project that uses `observer`, `autorun`, etc., they become very efficient. - -The following code demonstrates the issue: +The following code demonstrates it: ```javascript // OrderLine has a computed property `total`. const line = new OrderLine(2.0) -// If you access `line.total` outside of a reaction, it is recomputed every time. +// If you access `line.total` outside of a reaction, it is still cached. setInterval(() => { console.log(line.total) }, 60) ``` -It can be overridden by setting the annotation with the `keepAlive` option ([try it out yourself](https://codesandbox.io/s/computed-3cjo9?file=/src/index.tsx)) or by creating a no-op `autorun(() => { someObject.someComputed })`, which can be nicely cleaned up later if needed. -Note that both solutions have the risk of creating memory leaks. Changing the default behavior here is an anti-pattern. +If you need the old suspension behavior (no caching when not observed), set `keepAlive: false`. +It can reduce retained memory, but may trigger extra recomputation on untracked reads. MobX can also be configured with the [`computedRequiresReaction`](configuration.md#computedrequiresreaction-boolean) option, to report an error when computeds are accessed outside of a reactive context. @@ -237,4 +231,6 @@ It is recommended to set this one to `true` on very expensive computed values. I ### `keepAlive` -This avoids suspending computed values when they are not being observed by anything (see the above explanation). Can potentially create memory leaks, similar to the ones discussed for [reactions](reactions.md#always-dispose-of-reactions). +`keepAlive` controls whether a computed gets suspended when it has no observers. +By default, computeds are kept alive (`true`). +Set `keepAlive: false` to opt into suspension behavior and lower retention at the cost of potential recomputation. diff --git a/docs/configuration.md b/docs/configuration.md index 0b3486357..279e75513 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -96,9 +96,9 @@ In the rare case where you create observables lazily, for example in a computed #### `computedRequiresReaction: boolean` Forbids the direct access of any unobserved computed value from outside an action or reaction. -This guarantees you aren't using computed values in a way where MobX won't cache them. **Default: `false`**. +Use this to enforce that computeds are only read in a reactive context. **Default: `false`**. -In the following example, MobX won't cache the computed value in the first code block, but will cache the result in the second and third block: +In the following example, the first block will trigger a warning when this option is enabled: ```javascript class Clock { @@ -116,7 +116,7 @@ class Clock { const clock = new Clock() { - // This would compute twice, but is warned against by this flag. + // This is warned against by this flag. console.log(clock.milliseconds) console.log(clock.milliseconds) } From 544db6171f6ce7fe94f43c130191591262b3b022 Mon Sep 17 00:00:00 2001 From: Izhak Lehmann Date: Wed, 18 Feb 2026 10:06:17 +0100 Subject: [PATCH 03/10] tests: update warnings --- .../__tests__/decorators_20223/stage3-decorators.ts | 8 ++------ packages/mobx/__tests__/v5/base/trace.ts | 12 ++---------- .../mobx/__tests__/v5/base/typescript-decorators.ts | 4 ++-- packages/mobx/__tests__/v5/base/typescript-tests.ts | 4 ++-- 4 files changed, 8 insertions(+), 20 deletions(-) diff --git a/packages/mobx/__tests__/decorators_20223/stage3-decorators.ts b/packages/mobx/__tests__/decorators_20223/stage3-decorators.ts index 9f2420d6e..caf192d0b 100644 --- a/packages/mobx/__tests__/decorators_20223/stage3-decorators.ts +++ b/packages/mobx/__tests__/decorators_20223/stage3-decorators.ts @@ -980,12 +980,8 @@ test("unobserved computed reads should warn with requiresReaction enabled", () = a.y expect(warnings.length).toEqual(2) - expect(warnings[0]).toContain( - "is being read outside a reactive context. Doing a full recompute." - ) - expect(warnings[1]).toContain( - "is being read outside a reactive context. Doing a full recompute." - ) + expect(warnings[0]).toContain("is being read outside a reactive context.") + expect(warnings[1]).toContain("is being read outside a reactive context.") } finally { console.warn = consoleWarn } diff --git a/packages/mobx/__tests__/v5/base/trace.ts b/packages/mobx/__tests__/v5/base/trace.ts index 5055fe8a5..76a8c8010 100644 --- a/packages/mobx/__tests__/v5/base/trace.ts +++ b/packages/mobx/__tests__/v5/base/trace.ts @@ -35,7 +35,7 @@ describe("trace", () => { x.fullname expectedLogCalls.push([ - "[mobx.trace] Computed value 'x.fullname' is being read outside a reactive context. Doing a full recompute." + "[mobx.trace] Computed value 'x.fullname' is being read outside a reactive context." ]) const dispose = mobx.autorun( @@ -61,10 +61,6 @@ describe("trace", () => { dispose() - expectedLogCalls.push([ - "[mobx.trace] Computed value 'x.fullname' was suspended and it will recompute on the next access." - ]) - expect(expectedLogCalls).toEqual(consoleLogSpy.mock.calls) }) @@ -85,7 +81,7 @@ describe("trace", () => { x.fooIsGreaterThan5 expectedLogCalls.push([ - "[mobx.trace] Computed value 'x.fooIsGreaterThan5' is being read outside a reactive context. Doing a full recompute." + "[mobx.trace] Computed value 'x.fooIsGreaterThan5' is being read outside a reactive context." ]) const dispose = mobx.autorun( @@ -117,10 +113,6 @@ describe("trace", () => { dispose() - expectedLogCalls.push([ - "[mobx.trace] Computed value 'x.fooIsGreaterThan5' was suspended and it will recompute on the next access." - ]) - expect(expectedLogCalls).toEqual(consoleLogSpy.mock.calls) }) diff --git a/packages/mobx/__tests__/v5/base/typescript-decorators.ts b/packages/mobx/__tests__/v5/base/typescript-decorators.ts index dafbcb360..3ebe8758d 100644 --- a/packages/mobx/__tests__/v5/base/typescript-decorators.ts +++ b/packages/mobx/__tests__/v5/base/typescript-decorators.ts @@ -1123,7 +1123,7 @@ test("unobserved computed reads should warn with requiresReaction enabled", () = a.y expectedWarnings.push( - `[mobx] Computed value 'a.y' is being read outside a reactive context. Doing a full recompute.` + `[mobx] Computed value 'a.y' is being read outside a reactive context.` ) const d = mobx.reaction( @@ -1137,7 +1137,7 @@ test("unobserved computed reads should warn with requiresReaction enabled", () = a.y expectedWarnings.push( - `[mobx] Computed value 'a.y' is being read outside a reactive context. Doing a full recompute.` + `[mobx] Computed value 'a.y' is being read outside a reactive context.` ) expect(warnings).toEqual(expectedWarnings) diff --git a/packages/mobx/__tests__/v5/base/typescript-tests.ts b/packages/mobx/__tests__/v5/base/typescript-tests.ts index 8c17f046d..fefc82cef 100644 --- a/packages/mobx/__tests__/v5/base/typescript-tests.ts +++ b/packages/mobx/__tests__/v5/base/typescript-tests.ts @@ -1683,7 +1683,7 @@ test("unobserved computed reads should warn with requiresReaction enabled", () = a.y expectedWarnings.push( - `[mobx] Computed value 'a.y' is being read outside a reactive context. Doing a full recompute.` + `[mobx] Computed value 'a.y' is being read outside a reactive context.` ) const d = mobx.reaction( @@ -1697,7 +1697,7 @@ test("unobserved computed reads should warn with requiresReaction enabled", () = a.y expectedWarnings.push( - `[mobx] Computed value 'a.y' is being read outside a reactive context. Doing a full recompute.` + `[mobx] Computed value 'a.y' is being read outside a reactive context.` ) expect(warnings).toEqual(expectedWarnings) From 94c96888b72946570717e833c29b08afddf59a29 Mon Sep 17 00:00:00 2001 From: Izhak Lehmann Date: Wed, 18 Feb 2026 10:06:54 +0100 Subject: [PATCH 04/10] update tests v4 & v5 --- .../mobx/__tests__/v4/base/observables.js | 67 +++++++++----- .../mobx/__tests__/v5/base/become-observed.ts | 12 ++- .../mobx/__tests__/v5/base/observables.js | 89 +++++++++++-------- 3 files changed, 102 insertions(+), 66 deletions(-) diff --git a/packages/mobx/__tests__/v4/base/observables.js b/packages/mobx/__tests__/v4/base/observables.js index c1e8d8088..f7eb557cb 100644 --- a/packages/mobx/__tests__/v4/base/observables.js +++ b/packages/mobx/__tests__/v4/base/observables.js @@ -267,16 +267,16 @@ test("transaction with inspection", function () { expect(calcs).toBe(1) }) expect(b.get()).toBe(6) - expect(calcs).toBe(2) + expect(calcs).toBe(1) // if inspected, evaluate eagerly mobx.transaction(function () { a.set(4) expect(b.get()).toBe(8) - expect(calcs).toBe(3) + expect(calcs).toBe(2) }) expect(b.get()).toBe(8) - expect(calcs).toBe(4) + expect(calcs).toBe(2) }) test("transaction with inspection 2", function () { @@ -648,16 +648,16 @@ test("lazy evaluation", function () { expect(cCalcs).toBe(1) expect(c.get()).toBe(3) - expect(bCalcs).toBe(2) - expect(cCalcs).toBe(2) + expect(bCalcs).toBe(1) + expect(cCalcs).toBe(1) a.set(2) - expect(bCalcs).toBe(2) - expect(cCalcs).toBe(2) + expect(bCalcs).toBe(1) + expect(cCalcs).toBe(1) expect(c.get()).toBe(4) - expect(bCalcs).toBe(3) - expect(cCalcs).toBe(3) + expect(bCalcs).toBe(2) + expect(cCalcs).toBe(2) const d = computed(function () { dCalcs += 1 @@ -671,31 +671,31 @@ test("lazy evaluation", function () { }, false ) - expect(bCalcs).toBe(4) - expect(cCalcs).toBe(3) + expect(bCalcs).toBe(2) + expect(cCalcs).toBe(2) expect(dCalcs).toBe(1) // d is evaluated, so that its dependencies are known a.set(3) expect(d.get()).toBe(8) - expect(bCalcs).toBe(5) - expect(cCalcs).toBe(3) + expect(bCalcs).toBe(3) + expect(cCalcs).toBe(2) expect(dCalcs).toBe(2) expect(c.get()).toBe(5) - expect(bCalcs).toBe(5) - expect(cCalcs).toBe(4) + expect(bCalcs).toBe(3) + expect(cCalcs).toBe(3) expect(dCalcs).toBe(2) expect(b.get()).toBe(4) - expect(bCalcs).toBe(5) - expect(cCalcs).toBe(4) + expect(bCalcs).toBe(3) + expect(cCalcs).toBe(3) expect(dCalcs).toBe(2) handle() // unlisten expect(d.get()).toBe(8) - expect(bCalcs).toBe(6) // gone to sleep - expect(cCalcs).toBe(4) - expect(dCalcs).toBe(3) + expect(bCalcs).toBe(3) + expect(cCalcs).toBe(3) + expect(dCalcs).toBe(2) expect(observerChanges).toBe(1) @@ -1402,7 +1402,6 @@ test("verify calculation count", () => { "e", "f", // would have expected b c e d f, but alas "transaction", - "f", "change", "b", "try c", @@ -1831,10 +1830,10 @@ test("computed comparer works with decorate (plain) - 3", () => { }) test("can create computed with setter", () => { - let y = 1 - let x = mobx.computed(() => y, { + const y = observable.box(1) + let x = mobx.computed(() => y.get(), { set: v => { - y = v * 2 + y.set(v * 2) } }) expect(x.get()).toBe(1) @@ -1851,6 +1850,26 @@ test("can make non-extenible objects observable", () => { expect(mobx.isObservableProp(o, "x")).toBeTruthy() }) +test("computed values are kept alive by default", () => { + let calcs = 0 + const x = observable({ + x: 1, + get y() { + calcs++ + return this.x * 2 + } + }) + + expect(x.y).toBe(2) + expect(x.y).toBe(2) + expect(calcs).toBe(1) + + x.x = 3 + expect(calcs).toBe(1) + expect(x.y).toBe(6) + expect(calcs).toBe(2) +}) + test("keeping computed properties alive does not run before access", () => { let calcs = 0 observable( diff --git a/packages/mobx/__tests__/v5/base/become-observed.ts b/packages/mobx/__tests__/v5/base/become-observed.ts index 32d42d346..7187ecb9b 100644 --- a/packages/mobx/__tests__/v5/base/become-observed.ts +++ b/packages/mobx/__tests__/v5/base/become-observed.ts @@ -60,7 +60,7 @@ test("#2309 don't trigger oBO for computeds that aren't subscribed to", () => { asd.actionProp() events.push("--") asd.actionComputed() - expect(events).toEqual(["--"]) + expect(events).toEqual(["--", "onBecomeObserved"]) }) describe("#2309 onBecomeObserved inconsistencies", () => { @@ -338,15 +338,14 @@ describe("nested computes don't trigger hooks #2686", () => { expect(lowerForComputed.isObserved).toBe(true) d() - expect(lowerForComputed.isObserved).toBe(false) + expect(lowerForComputed.isObserved).toBe(true) expect(events).toEqual([ "upperValue$", "value read through computed: -Infinity", "upperValue$", "onBecomeObserved", - "value read through computed: -1", - "onBecomeUnobserved" + "value read through computed: -1" ]) }) @@ -375,7 +374,7 @@ test("#2686 - 2", () => { selection.color = "blue" }) d() - expect(events).toEqual(["unselected", "observing", "selected", "unobserving"]) + expect(events).toEqual(["unselected", "observing", "selected"]) }) test("#2686 - 3", () => { @@ -505,8 +504,7 @@ test("#2667", () => { "onBecomeObservednew", 1, "new", - "onBecomeUnobservedinitial", - "onBecomeUnobservednew" + "onBecomeUnobservedinitial" ]) }) diff --git a/packages/mobx/__tests__/v5/base/observables.js b/packages/mobx/__tests__/v5/base/observables.js index 63bf54825..5475a2223 100644 --- a/packages/mobx/__tests__/v5/base/observables.js +++ b/packages/mobx/__tests__/v5/base/observables.js @@ -279,16 +279,16 @@ test("transaction with inspection", function () { expect(calcs).toBe(1) }) expect(b.get()).toBe(6) - expect(calcs).toBe(2) + expect(calcs).toBe(1) // if inspected, evaluate eagerly mobx.transaction(function () { a.set(4) expect(b.get()).toBe(8) - expect(calcs).toBe(3) + expect(calcs).toBe(2) }) expect(b.get()).toBe(8) - expect(calcs).toBe(4) + expect(calcs).toBe(2) }) test("transaction with inspection 2", function () { @@ -660,16 +660,16 @@ test("lazy evaluation", function () { expect(cCalcs).toBe(1) expect(c.get()).toBe(3) - expect(bCalcs).toBe(2) - expect(cCalcs).toBe(2) + expect(bCalcs).toBe(1) + expect(cCalcs).toBe(1) a.set(2) - expect(bCalcs).toBe(2) - expect(cCalcs).toBe(2) + expect(bCalcs).toBe(1) + expect(cCalcs).toBe(1) expect(c.get()).toBe(4) - expect(bCalcs).toBe(3) - expect(cCalcs).toBe(3) + expect(bCalcs).toBe(2) + expect(cCalcs).toBe(2) const d = computed(function () { dCalcs += 1 @@ -683,31 +683,31 @@ test("lazy evaluation", function () { }, false ) - expect(bCalcs).toBe(4) - expect(cCalcs).toBe(3) + expect(bCalcs).toBe(2) + expect(cCalcs).toBe(2) expect(dCalcs).toBe(1) // d is evaluated, so that its dependencies are known a.set(3) expect(d.get()).toBe(8) - expect(bCalcs).toBe(5) - expect(cCalcs).toBe(3) + expect(bCalcs).toBe(3) + expect(cCalcs).toBe(2) expect(dCalcs).toBe(2) expect(c.get()).toBe(5) - expect(bCalcs).toBe(5) - expect(cCalcs).toBe(4) + expect(bCalcs).toBe(3) + expect(cCalcs).toBe(3) expect(dCalcs).toBe(2) expect(b.get()).toBe(4) - expect(bCalcs).toBe(5) - expect(cCalcs).toBe(4) + expect(bCalcs).toBe(3) + expect(cCalcs).toBe(3) expect(dCalcs).toBe(2) handle() // unlisten expect(d.get()).toBe(8) - expect(bCalcs).toBe(6) // gone to sleep - expect(cCalcs).toBe(4) - expect(dCalcs).toBe(3) + expect(bCalcs).toBe(3) + expect(cCalcs).toBe(3) + expect(dCalcs).toBe(2) expect(observerChanges).toBe(1) @@ -1416,31 +1416,31 @@ test("#3563 reportObserved in batch", () => { observed += a.reportObserved() ? 1 : 0 }) c.get() - expect(start).toBe(0) + expect(start).toBe(1) expect(stop).toBe(0) expect(computed).toBe(1) - expect(observed).toBe(0) + expect(observed).toBe(1) mobx.runInAction(() => { c.get() - expect(start).toBe(0) + expect(start).toBe(1) expect(stop).toBe(0) - expect(computed).toBe(2) - expect(observed).toBe(0) + expect(computed).toBe(1) + expect(observed).toBe(1) c.get() - expect(computed).toBe(2) - expect(observed).toBe(0) + expect(computed).toBe(1) + expect(observed).toBe(1) }) const c2 = mobx.computed(() => { c.get() }) c2.get() - expect(start).toBe(0) + expect(start).toBe(1) expect(stop).toBe(0) - expect(computed).toBe(3) - expect(observed).toBe(0) + expect(computed).toBe(1) + expect(observed).toBe(1) }) test("verify calculation count", () => { @@ -1506,7 +1506,6 @@ test("verify calculation count", () => { "e", "f", // would have expected b c e d f, but alas "transaction", - "f", "change", "b", "try c", @@ -1937,10 +1936,10 @@ test("computed comparer works with decorate (plain) - 3", () => { }) test("can create computed with setter", () => { - let y = 1 - let x = mobx.computed(() => y, { + const y = observable.box(1) + let x = mobx.computed(() => y.get(), { set: v => { - y = v * 2 + y.set(v * 2) } }) expect(x.get()).toBe(1) @@ -1957,6 +1956,26 @@ test("can make non-extenible objects observable", () => { expect(mobx.isObservableProp(o, "x")).toBeTruthy() }) +test("computed values are kept alive by default", () => { + let calcs = 0 + const x = observable({ + x: 1, + get y() { + calcs++ + return this.x * 2 + } + }) + + expect(x.y).toBe(2) + expect(x.y).toBe(2) + expect(calcs).toBe(1) + + x.x = 3 + expect(calcs).toBe(1) + expect(x.y).toBe(6) + expect(calcs).toBe(2) +}) + test("keeping computed properties alive does not run before access", () => { let calcs = 0 observable( @@ -2295,7 +2314,7 @@ test("ObservableArray.splice", () => { describe("`requiresReaction` takes precedence over global `computedRequiresReaction`", () => { const name = "TestComputed" - let warnMsg = `[mobx] Computed value '${name}' is being read outside a reactive context. Doing a full recompute.` + let warnMsg = `[mobx] Computed value '${name}' is being read outside a reactive context.` let consoleWarnSpy beforeEach(() => { consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation() From 9d741f4b94fe44442a632877ef3f9cc85c2e3dea Mon Sep 17 00:00:00 2001 From: Izhak Lehmann Date: Wed, 18 Feb 2026 12:40:12 +0100 Subject: [PATCH 05/10] Added changeset --- .changeset/five-islands-look.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/five-islands-look.md diff --git a/.changeset/five-islands-look.md b/.changeset/five-islands-look.md new file mode 100644 index 000000000..707c67cfa --- /dev/null +++ b/.changeset/five-islands-look.md @@ -0,0 +1,7 @@ +--- +"mobx": major +--- + +`computed`s are now kept alive by default (`keepAlive: true`) +This changes unobserved computed behaviour - values stay cached instead being suspended or recomputed for each untracked read +If you rely on previous behaviour, set "keepAlive: false" explicitly From 69ab399a43f03e37d34e83e9e8e4321b8c5a59f7 Mon Sep 17 00:00:00 2001 From: Izhak Lehmann Date: Thu, 26 Feb 2026 13:06:14 +0100 Subject: [PATCH 06/10] Added global flag for keepAlive state warn on implicit computed (for future migration) --- .changeset/five-islands-look.md | 7 ------- packages/mobx/src/api/configure.ts | 4 ++++ packages/mobx/src/core/computedvalue.ts | 12 +++++++++++- packages/mobx/src/core/globalstate.ts | 7 +++++++ 4 files changed, 22 insertions(+), 8 deletions(-) delete mode 100644 .changeset/five-islands-look.md diff --git a/.changeset/five-islands-look.md b/.changeset/five-islands-look.md deleted file mode 100644 index 707c67cfa..000000000 --- a/.changeset/five-islands-look.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"mobx": major ---- - -`computed`s are now kept alive by default (`keepAlive: true`) -This changes unobserved computed behaviour - values stay cached instead being suspended or recomputed for each untracked read -If you rely on previous behaviour, set "keepAlive: false" explicitly diff --git a/packages/mobx/src/api/configure.ts b/packages/mobx/src/api/configure.ts index cfabf9f2d..c82c3c3dd 100644 --- a/packages/mobx/src/api/configure.ts +++ b/packages/mobx/src/api/configure.ts @@ -16,6 +16,7 @@ export function configure(options: { * Warn if observables are accessed outside a reactive context */ observableRequiresReaction?: boolean + globalKeepAliveState?: boolean isolateGlobalState?: boolean disableErrorBoundaries?: boolean safeDescriptors?: boolean @@ -53,6 +54,9 @@ export function configure(options: { globalState[key] = !!options[key] } }) + if (options.globalKeepAliveState !== undefined) { + globalState.globalKeepAliveState = options.globalKeepAliveState + } globalState.allowStateReads = !globalState.observableRequiresReaction if (__DEV__ && globalState.disableErrorBoundaries === true) { console.warn( diff --git a/packages/mobx/src/core/computedvalue.ts b/packages/mobx/src/core/computedvalue.ts index 96db346ce..39395f1ac 100644 --- a/packages/mobx/src/core/computedvalue.ts +++ b/packages/mobx/src/core/computedvalue.ts @@ -16,6 +16,7 @@ import { globalState, isCaughtException, isSpyEnabled, + once, propagateChangeConfirmed, propagateMaybeChanged, reportObserved, @@ -34,6 +35,12 @@ import { import { getFlag, setFlag } from "../utils/utils" +const implicitKeepAliveDefaultWarning = once(() => + console.warn( + "[mobx] Default behavior for computed values without explicit `keepAlive` will be `true` in the next major version. Set `keepAlive: false` explicitly, or set `globalKeepAliveState` to `false` to preserve the current behavior." + ) +) + export interface IComputedValue { get(): T set(value: T): void @@ -136,7 +143,10 @@ export class ComputedValue implements IObservable, IComputedValue, IDeriva : comparer.default) this.scope_ = options.context this.requiresReaction_ = options.requiresReaction - this.keepAlive_ = options.keepAlive !== false + if (__DEV__ && options.keepAlive === undefined) { + implicitKeepAliveDefaultWarning() + } + this.keepAlive_ = options.keepAlive ?? globalState.globalKeepAliveState } onBecomeStale_() { diff --git a/packages/mobx/src/core/globalstate.ts b/packages/mobx/src/core/globalstate.ts index 35de9dfea..f47d3d0c2 100644 --- a/packages/mobx/src/core/globalstate.ts +++ b/packages/mobx/src/core/globalstate.ts @@ -9,6 +9,7 @@ const persistentKeys: (keyof MobXGlobals)[] = [ "spyListeners", "enforceActions", "computedRequiresReaction", + "globalKeepAliveState", "reactionRequiresObservable", "observableRequiresReaction", "allowStateReads", @@ -114,6 +115,12 @@ export class MobXGlobals { */ computedRequiresReaction = false + /** + * Global flag for computed keepAlive behavior. + * Defaults to `false` to preserve current behavior. + */ + globalKeepAliveState = false + /** * (Experimental) * Warn if you try to create to derivation / reactive context without accessing any observable. From e0c2648915290ac5d917b2457d2a8d95fd251d12 Mon Sep 17 00:00:00 2001 From: Izhak Lehmann Date: Thu, 26 Feb 2026 13:07:11 +0100 Subject: [PATCH 07/10] revert tests (as behaviour should remain the same) --- .../decorators_20223/stage3-decorators.ts | 10 ++- .../mobx/__tests__/v4/base/observables.js | 67 +++++++------------ .../mobx/__tests__/v5/base/become-observed.ts | 12 ++-- packages/mobx/__tests__/v5/base/trace.ts | 12 +++- .../v5/base/typescript-decorators.ts | 6 +- .../__tests__/v5/base/typescript-tests.ts | 6 +- 6 files changed, 54 insertions(+), 59 deletions(-) diff --git a/packages/mobx/__tests__/decorators_20223/stage3-decorators.ts b/packages/mobx/__tests__/decorators_20223/stage3-decorators.ts index caf192d0b..91850347b 100644 --- a/packages/mobx/__tests__/decorators_20223/stage3-decorators.ts +++ b/packages/mobx/__tests__/decorators_20223/stage3-decorators.ts @@ -962,7 +962,7 @@ test("unobserved computed reads should warn with requiresReaction enabled", () = class A { @observable accessor x = 0 - @computed({ requiresReaction: true }) + @computed({ requiresReaction: true, keepAlive: false }) get y() { return this.x * 2 } @@ -980,8 +980,12 @@ test("unobserved computed reads should warn with requiresReaction enabled", () = a.y expect(warnings.length).toEqual(2) - expect(warnings[0]).toContain("is being read outside a reactive context.") - expect(warnings[1]).toContain("is being read outside a reactive context.") + expect(warnings[0]).toContain( + "is being read outside a reactive context. Doing a full recompute." + ) + expect(warnings[1]).toContain( + "is being read outside a reactive context. Doing a full recompute." + ) } finally { console.warn = consoleWarn } diff --git a/packages/mobx/__tests__/v4/base/observables.js b/packages/mobx/__tests__/v4/base/observables.js index f7eb557cb..c1e8d8088 100644 --- a/packages/mobx/__tests__/v4/base/observables.js +++ b/packages/mobx/__tests__/v4/base/observables.js @@ -267,16 +267,16 @@ test("transaction with inspection", function () { expect(calcs).toBe(1) }) expect(b.get()).toBe(6) - expect(calcs).toBe(1) + expect(calcs).toBe(2) // if inspected, evaluate eagerly mobx.transaction(function () { a.set(4) expect(b.get()).toBe(8) - expect(calcs).toBe(2) + expect(calcs).toBe(3) }) expect(b.get()).toBe(8) - expect(calcs).toBe(2) + expect(calcs).toBe(4) }) test("transaction with inspection 2", function () { @@ -648,17 +648,17 @@ test("lazy evaluation", function () { expect(cCalcs).toBe(1) expect(c.get()).toBe(3) - expect(bCalcs).toBe(1) - expect(cCalcs).toBe(1) + expect(bCalcs).toBe(2) + expect(cCalcs).toBe(2) a.set(2) - expect(bCalcs).toBe(1) - expect(cCalcs).toBe(1) - - expect(c.get()).toBe(4) expect(bCalcs).toBe(2) expect(cCalcs).toBe(2) + expect(c.get()).toBe(4) + expect(bCalcs).toBe(3) + expect(cCalcs).toBe(3) + const d = computed(function () { dCalcs += 1 return b.get() * 2 @@ -671,31 +671,31 @@ test("lazy evaluation", function () { }, false ) - expect(bCalcs).toBe(2) - expect(cCalcs).toBe(2) + expect(bCalcs).toBe(4) + expect(cCalcs).toBe(3) expect(dCalcs).toBe(1) // d is evaluated, so that its dependencies are known a.set(3) expect(d.get()).toBe(8) - expect(bCalcs).toBe(3) - expect(cCalcs).toBe(2) + expect(bCalcs).toBe(5) + expect(cCalcs).toBe(3) expect(dCalcs).toBe(2) expect(c.get()).toBe(5) - expect(bCalcs).toBe(3) - expect(cCalcs).toBe(3) + expect(bCalcs).toBe(5) + expect(cCalcs).toBe(4) expect(dCalcs).toBe(2) expect(b.get()).toBe(4) - expect(bCalcs).toBe(3) - expect(cCalcs).toBe(3) + expect(bCalcs).toBe(5) + expect(cCalcs).toBe(4) expect(dCalcs).toBe(2) handle() // unlisten expect(d.get()).toBe(8) - expect(bCalcs).toBe(3) - expect(cCalcs).toBe(3) - expect(dCalcs).toBe(2) + expect(bCalcs).toBe(6) // gone to sleep + expect(cCalcs).toBe(4) + expect(dCalcs).toBe(3) expect(observerChanges).toBe(1) @@ -1402,6 +1402,7 @@ test("verify calculation count", () => { "e", "f", // would have expected b c e d f, but alas "transaction", + "f", "change", "b", "try c", @@ -1830,10 +1831,10 @@ test("computed comparer works with decorate (plain) - 3", () => { }) test("can create computed with setter", () => { - const y = observable.box(1) - let x = mobx.computed(() => y.get(), { + let y = 1 + let x = mobx.computed(() => y, { set: v => { - y.set(v * 2) + y = v * 2 } }) expect(x.get()).toBe(1) @@ -1850,26 +1851,6 @@ test("can make non-extenible objects observable", () => { expect(mobx.isObservableProp(o, "x")).toBeTruthy() }) -test("computed values are kept alive by default", () => { - let calcs = 0 - const x = observable({ - x: 1, - get y() { - calcs++ - return this.x * 2 - } - }) - - expect(x.y).toBe(2) - expect(x.y).toBe(2) - expect(calcs).toBe(1) - - x.x = 3 - expect(calcs).toBe(1) - expect(x.y).toBe(6) - expect(calcs).toBe(2) -}) - test("keeping computed properties alive does not run before access", () => { let calcs = 0 observable( diff --git a/packages/mobx/__tests__/v5/base/become-observed.ts b/packages/mobx/__tests__/v5/base/become-observed.ts index 7187ecb9b..32d42d346 100644 --- a/packages/mobx/__tests__/v5/base/become-observed.ts +++ b/packages/mobx/__tests__/v5/base/become-observed.ts @@ -60,7 +60,7 @@ test("#2309 don't trigger oBO for computeds that aren't subscribed to", () => { asd.actionProp() events.push("--") asd.actionComputed() - expect(events).toEqual(["--", "onBecomeObserved"]) + expect(events).toEqual(["--"]) }) describe("#2309 onBecomeObserved inconsistencies", () => { @@ -338,14 +338,15 @@ describe("nested computes don't trigger hooks #2686", () => { expect(lowerForComputed.isObserved).toBe(true) d() - expect(lowerForComputed.isObserved).toBe(true) + expect(lowerForComputed.isObserved).toBe(false) expect(events).toEqual([ "upperValue$", "value read through computed: -Infinity", "upperValue$", "onBecomeObserved", - "value read through computed: -1" + "value read through computed: -1", + "onBecomeUnobserved" ]) }) @@ -374,7 +375,7 @@ test("#2686 - 2", () => { selection.color = "blue" }) d() - expect(events).toEqual(["unselected", "observing", "selected"]) + expect(events).toEqual(["unselected", "observing", "selected", "unobserving"]) }) test("#2686 - 3", () => { @@ -504,7 +505,8 @@ test("#2667", () => { "onBecomeObservednew", 1, "new", - "onBecomeUnobservedinitial" + "onBecomeUnobservedinitial", + "onBecomeUnobservednew" ]) }) diff --git a/packages/mobx/__tests__/v5/base/trace.ts b/packages/mobx/__tests__/v5/base/trace.ts index 76a8c8010..5055fe8a5 100644 --- a/packages/mobx/__tests__/v5/base/trace.ts +++ b/packages/mobx/__tests__/v5/base/trace.ts @@ -35,7 +35,7 @@ describe("trace", () => { x.fullname expectedLogCalls.push([ - "[mobx.trace] Computed value 'x.fullname' is being read outside a reactive context." + "[mobx.trace] Computed value 'x.fullname' is being read outside a reactive context. Doing a full recompute." ]) const dispose = mobx.autorun( @@ -61,6 +61,10 @@ describe("trace", () => { dispose() + expectedLogCalls.push([ + "[mobx.trace] Computed value 'x.fullname' was suspended and it will recompute on the next access." + ]) + expect(expectedLogCalls).toEqual(consoleLogSpy.mock.calls) }) @@ -81,7 +85,7 @@ describe("trace", () => { x.fooIsGreaterThan5 expectedLogCalls.push([ - "[mobx.trace] Computed value 'x.fooIsGreaterThan5' is being read outside a reactive context." + "[mobx.trace] Computed value 'x.fooIsGreaterThan5' is being read outside a reactive context. Doing a full recompute." ]) const dispose = mobx.autorun( @@ -113,6 +117,10 @@ describe("trace", () => { dispose() + expectedLogCalls.push([ + "[mobx.trace] Computed value 'x.fooIsGreaterThan5' was suspended and it will recompute on the next access." + ]) + expect(expectedLogCalls).toEqual(consoleLogSpy.mock.calls) }) diff --git a/packages/mobx/__tests__/v5/base/typescript-decorators.ts b/packages/mobx/__tests__/v5/base/typescript-decorators.ts index 3ebe8758d..3cfcd957a 100644 --- a/packages/mobx/__tests__/v5/base/typescript-decorators.ts +++ b/packages/mobx/__tests__/v5/base/typescript-decorators.ts @@ -1110,7 +1110,7 @@ test("unobserved computed reads should warn with requiresReaction enabled", () = class A { @observable x = 0 - @computed({ requiresReaction: true }) + @computed({ requiresReaction: true, keepAlive: false }) get y() { return this.x * 2 } @@ -1123,7 +1123,7 @@ test("unobserved computed reads should warn with requiresReaction enabled", () = a.y expectedWarnings.push( - `[mobx] Computed value 'a.y' is being read outside a reactive context.` + `[mobx] Computed value 'a.y' is being read outside a reactive context. Doing a full recompute.` ) const d = mobx.reaction( @@ -1137,7 +1137,7 @@ test("unobserved computed reads should warn with requiresReaction enabled", () = a.y expectedWarnings.push( - `[mobx] Computed value 'a.y' is being read outside a reactive context.` + `[mobx] Computed value 'a.y' is being read outside a reactive context. Doing a full recompute.` ) expect(warnings).toEqual(expectedWarnings) diff --git a/packages/mobx/__tests__/v5/base/typescript-tests.ts b/packages/mobx/__tests__/v5/base/typescript-tests.ts index fefc82cef..a12aadf05 100644 --- a/packages/mobx/__tests__/v5/base/typescript-tests.ts +++ b/packages/mobx/__tests__/v5/base/typescript-tests.ts @@ -1672,7 +1672,7 @@ test("unobserved computed reads should warn with requiresReaction enabled", () = this, { x: observable, - y: computed({ requiresReaction: true }) + y: computed({ requiresReaction: true, keepAlive: false }) }, { name: "a" } ) @@ -1683,7 +1683,7 @@ test("unobserved computed reads should warn with requiresReaction enabled", () = a.y expectedWarnings.push( - `[mobx] Computed value 'a.y' is being read outside a reactive context.` + `[mobx] Computed value 'a.y' is being read outside a reactive context. Doing a full recompute.` ) const d = mobx.reaction( @@ -1697,7 +1697,7 @@ test("unobserved computed reads should warn with requiresReaction enabled", () = a.y expectedWarnings.push( - `[mobx] Computed value 'a.y' is being read outside a reactive context.` + `[mobx] Computed value 'a.y' is being read outside a reactive context. Doing a full recompute.` ) expect(warnings).toEqual(expectedWarnings) From cd5a92e27cc192c0d8cdd17dea575480ec04228d Mon Sep 17 00:00:00 2001 From: Izhak Lehmann Date: Thu, 26 Feb 2026 13:07:45 +0100 Subject: [PATCH 08/10] add global flag behaviour tests --- .../mobx/__tests__/v5/base/observables.js | 174 ++++++++++++------ 1 file changed, 115 insertions(+), 59 deletions(-) diff --git a/packages/mobx/__tests__/v5/base/observables.js b/packages/mobx/__tests__/v5/base/observables.js index 5475a2223..11cf64b59 100644 --- a/packages/mobx/__tests__/v5/base/observables.js +++ b/packages/mobx/__tests__/v5/base/observables.js @@ -55,6 +55,81 @@ test("basic", function () { expect(mobx._isComputingDerivation()).toBe(false) }) +describe("computed keepAlive: migration flag", () => { + beforeEach(() => { + mobx.configure({ globalKeepAliveState: false }) + }) + + afterEach(() => { + mobx.configure({ globalKeepAliveState: false }) + mobx._resetGlobalState() + }) + + test("warns once when implicit `keepAlive`", () => { + const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}) + try { + const box = observable.box(1) + mobx.computed(() => box.get() * 2) + mobx.computed(() => box.get() * 3) + + const migrationWarnings = consoleWarnSpy.mock.calls.filter(([msg]) => + msg.includes("Default behavior for computed values without explicit `keepAlive`") + ) + expect(migrationWarnings).toHaveLength(1) + } finally { + consoleWarnSpy.mockRestore() + } + }) + + test("implicit `keepAlive` defaults to global flag", () => { + const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}) + try { + let computations = 0 + const box = observable.box(1) + const computed = mobx.computed(() => { + computations++ + return box.get() * 2 + }) + + expect(computed.get()).toBe(2) + expect(computed.get()).toBe(2) + expect(computations).toBe(2) + } finally { + consoleWarnSpy.mockRestore() + } + }) + + test("explicit `keepAlive` overrides global flag", () => { + mobx.configure({ globalKeepAliveState: false }) + let computationsWhenTrue = 0 + const a = observable.box(1) + const keepAliveTrue = mobx.computed( + () => { + computationsWhenTrue++ + return a.get() * 2 + }, + { keepAlive: true } + ) + expect(keepAliveTrue.get()).toBe(2) + expect(keepAliveTrue.get()).toBe(2) + expect(computationsWhenTrue).toBe(1) + + mobx.configure({ globalKeepAliveState: true }) + let computationsWhenFalse = 0 + const b = observable.box(1) + const keepAliveFalse = mobx.computed( + () => { + computationsWhenFalse++ + return b.get() * 2 + }, + { keepAlive: false } + ) + expect(keepAliveFalse.get()).toBe(2) + expect(keepAliveFalse.get()).toBe(2) + expect(computationsWhenFalse).toBe(2) + }) +}) + test("basic2", function () { const x = observable.box(3) const z = computed(function () { @@ -279,16 +354,16 @@ test("transaction with inspection", function () { expect(calcs).toBe(1) }) expect(b.get()).toBe(6) - expect(calcs).toBe(1) + expect(calcs).toBe(2) // if inspected, evaluate eagerly mobx.transaction(function () { a.set(4) expect(b.get()).toBe(8) - expect(calcs).toBe(2) + expect(calcs).toBe(3) }) expect(b.get()).toBe(8) - expect(calcs).toBe(2) + expect(calcs).toBe(4) }) test("transaction with inspection 2", function () { @@ -660,17 +735,17 @@ test("lazy evaluation", function () { expect(cCalcs).toBe(1) expect(c.get()).toBe(3) - expect(bCalcs).toBe(1) - expect(cCalcs).toBe(1) + expect(bCalcs).toBe(2) + expect(cCalcs).toBe(2) a.set(2) - expect(bCalcs).toBe(1) - expect(cCalcs).toBe(1) - - expect(c.get()).toBe(4) expect(bCalcs).toBe(2) expect(cCalcs).toBe(2) + expect(c.get()).toBe(4) + expect(bCalcs).toBe(3) + expect(cCalcs).toBe(3) + const d = computed(function () { dCalcs += 1 return b.get() * 2 @@ -683,31 +758,31 @@ test("lazy evaluation", function () { }, false ) - expect(bCalcs).toBe(2) - expect(cCalcs).toBe(2) + expect(bCalcs).toBe(4) + expect(cCalcs).toBe(3) expect(dCalcs).toBe(1) // d is evaluated, so that its dependencies are known a.set(3) expect(d.get()).toBe(8) - expect(bCalcs).toBe(3) - expect(cCalcs).toBe(2) + expect(bCalcs).toBe(5) + expect(cCalcs).toBe(3) expect(dCalcs).toBe(2) expect(c.get()).toBe(5) - expect(bCalcs).toBe(3) - expect(cCalcs).toBe(3) + expect(bCalcs).toBe(5) + expect(cCalcs).toBe(4) expect(dCalcs).toBe(2) expect(b.get()).toBe(4) - expect(bCalcs).toBe(3) - expect(cCalcs).toBe(3) + expect(bCalcs).toBe(5) + expect(cCalcs).toBe(4) expect(dCalcs).toBe(2) handle() // unlisten expect(d.get()).toBe(8) - expect(bCalcs).toBe(3) - expect(cCalcs).toBe(3) - expect(dCalcs).toBe(2) + expect(bCalcs).toBe(6) // gone to sleep + expect(cCalcs).toBe(4) + expect(dCalcs).toBe(3) expect(observerChanges).toBe(1) @@ -1416,31 +1491,31 @@ test("#3563 reportObserved in batch", () => { observed += a.reportObserved() ? 1 : 0 }) c.get() - expect(start).toBe(1) + expect(start).toBe(0) expect(stop).toBe(0) expect(computed).toBe(1) - expect(observed).toBe(1) + expect(observed).toBe(0) mobx.runInAction(() => { c.get() - expect(start).toBe(1) + expect(start).toBe(0) expect(stop).toBe(0) - expect(computed).toBe(1) - expect(observed).toBe(1) + expect(computed).toBe(2) + expect(observed).toBe(0) c.get() - expect(computed).toBe(1) - expect(observed).toBe(1) + expect(computed).toBe(2) + expect(observed).toBe(0) }) const c2 = mobx.computed(() => { c.get() }) c2.get() - expect(start).toBe(1) + expect(start).toBe(0) expect(stop).toBe(0) - expect(computed).toBe(1) - expect(observed).toBe(1) + expect(computed).toBe(3) + expect(observed).toBe(0) }) test("verify calculation count", () => { @@ -1506,6 +1581,7 @@ test("verify calculation count", () => { "e", "f", // would have expected b c e d f, but alas "transaction", + "f", "change", "b", "try c", @@ -1936,10 +2012,10 @@ test("computed comparer works with decorate (plain) - 3", () => { }) test("can create computed with setter", () => { - const y = observable.box(1) - let x = mobx.computed(() => y.get(), { + let y = 1 + let x = mobx.computed(() => y, { set: v => { - y.set(v * 2) + y = v * 2 } }) expect(x.get()).toBe(1) @@ -1956,26 +2032,6 @@ test("can make non-extenible objects observable", () => { expect(mobx.isObservableProp(o, "x")).toBeTruthy() }) -test("computed values are kept alive by default", () => { - let calcs = 0 - const x = observable({ - x: 1, - get y() { - calcs++ - return this.x * 2 - } - }) - - expect(x.y).toBe(2) - expect(x.y).toBe(2) - expect(calcs).toBe(1) - - x.x = 3 - expect(calcs).toBe(1) - expect(x.y).toBe(6) - expect(calcs).toBe(2) -}) - test("keeping computed properties alive does not run before access", () => { let calcs = 0 observable( @@ -2314,7 +2370,7 @@ test("ObservableArray.splice", () => { describe("`requiresReaction` takes precedence over global `computedRequiresReaction`", () => { const name = "TestComputed" - let warnMsg = `[mobx] Computed value '${name}' is being read outside a reactive context.` + let warnMsg = `[mobx] Computed value '${name}' is being read outside a reactive context. Doing a full recompute.` let consoleWarnSpy beforeEach(() => { consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation() @@ -2326,21 +2382,21 @@ describe("`requiresReaction` takes precedence over global `computedRequiresReact test("`undefined`", () => { mobx.configure({ computedRequiresReaction: true }) - const c = mobx.computed(() => {}, { name }) + const c = mobx.computed(() => {}, { name, keepAlive: false }) c.get() expect(consoleWarnSpy).toHaveBeenLastCalledWith(warnMsg) }) test("`true` over `false`", () => { mobx.configure({ computedRequiresReaction: false }) - const c = mobx.computed(() => {}, { name, requiresReaction: true }) + const c = mobx.computed(() => {}, { name, requiresReaction: true, keepAlive: false }) c.get() expect(consoleWarnSpy).toHaveBeenLastCalledWith(warnMsg) }) test("`false` over `true`", () => { mobx.configure({ computedRequiresReaction: true }) - const c = mobx.computed(() => {}, { name, requiresReaction: false }) + const c = mobx.computed(() => {}, { name, requiresReaction: false, keepAlive: false }) c.get() expect(consoleWarnSpy).not.toHaveBeenCalled() }) @@ -2415,7 +2471,7 @@ test('Observables initialization does not violate `enforceActions: "always"`', ( check(() => mobx.observable({ x: 0 }, { proxy: true })) check(() => mobx.observable([0], { proxy: false })) check(() => mobx.observable([0], { proxy: true })) - check(() => mobx.computed(() => 0)) + check(() => mobx.computed(() => 0, { keepAlive: false })) } finally { consoleWarnSpy.mockRestore() mobx._resetGlobalState() @@ -2486,7 +2542,7 @@ test("state version does not update on observable creation", () => { check(() => mobx.observable({ x: 0 }, { proxy: true })) check(() => mobx.observable([0], { proxy: false })) check(() => mobx.observable([0], { proxy: true })) - check(() => mobx.computed(() => 0)) + check(() => mobx.computed(() => 0, { keepAlive: false })) }) test("#3747", () => { From 38c8923f4d4c18ab74cece28314bc74eaba0eca9 Mon Sep 17 00:00:00 2001 From: Izhak Lehmann Date: Thu, 26 Feb 2026 14:06:49 +0100 Subject: [PATCH 09/10] improved warning text, added changeset --- .changeset/big-dots-run.md | 11 +++++++++++ packages/mobx/__tests__/v5/base/observables.js | 2 +- packages/mobx/src/core/computedvalue.ts | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 .changeset/big-dots-run.md diff --git a/.changeset/big-dots-run.md b/.changeset/big-dots-run.md new file mode 100644 index 000000000..dea9beea0 --- /dev/null +++ b/.changeset/big-dots-run.md @@ -0,0 +1,11 @@ +--- +"mobx": minor +--- + +Adds a temporary global migration flag for computed `keepAlive` default value: +`configure({ globalKeepAliveState })` + +Current behavior is preserved by default (`globalKeepAliveState: false`), +explicit `keepAlive` still takes precedence + +MobX now warns once when a computed is created without an explicit `keepAlive`, to help prepare for the next major version where the default will change. diff --git a/packages/mobx/__tests__/v5/base/observables.js b/packages/mobx/__tests__/v5/base/observables.js index 11cf64b59..3e74c509f 100644 --- a/packages/mobx/__tests__/v5/base/observables.js +++ b/packages/mobx/__tests__/v5/base/observables.js @@ -73,7 +73,7 @@ describe("computed keepAlive: migration flag", () => { mobx.computed(() => box.get() * 3) const migrationWarnings = consoleWarnSpy.mock.calls.filter(([msg]) => - msg.includes("Default behavior for computed values without explicit `keepAlive`") + msg.includes("Computed created without explicit `keepAlive`") ) expect(migrationWarnings).toHaveLength(1) } finally { diff --git a/packages/mobx/src/core/computedvalue.ts b/packages/mobx/src/core/computedvalue.ts index 39395f1ac..73dd3fe45 100644 --- a/packages/mobx/src/core/computedvalue.ts +++ b/packages/mobx/src/core/computedvalue.ts @@ -37,7 +37,7 @@ import { getFlag, setFlag } from "../utils/utils" const implicitKeepAliveDefaultWarning = once(() => console.warn( - "[mobx] Default behavior for computed values without explicit `keepAlive` will be `true` in the next major version. Set `keepAlive: false` explicitly, or set `globalKeepAliveState` to `false` to preserve the current behavior." + "[mobx] Computed created without explicit `keepAlive`. The default is currently controlled by `configure({ globalKeepAliveState })` and will change from `false` to `true` in the next major version. Set `keepAlive` explicitly (`true`/`false`) to avoid relying on defaults." ) ) From fea6d5b1cf88c427ada10e0134c58ed5256a4cbc Mon Sep 17 00:00:00 2001 From: Izhak Lehmann Date: Thu, 26 Feb 2026 15:43:32 +0100 Subject: [PATCH 10/10] refined warning message fixed global flag when set by "false"/"true" revert docs and added proper explanation about future plans --- .changeset/big-dots-run.md | 8 +++++++- docs/computeds.md | 15 ++++++++------- docs/configuration.md | 9 +++++++++ packages/mobx/__tests__/v5/base/observables.js | 5 +---- packages/mobx/src/api/configure.ts | 2 +- packages/mobx/src/core/computedvalue.ts | 2 +- 6 files changed, 27 insertions(+), 14 deletions(-) diff --git a/.changeset/big-dots-run.md b/.changeset/big-dots-run.md index dea9beea0..8cd4ed13b 100644 --- a/.changeset/big-dots-run.md +++ b/.changeset/big-dots-run.md @@ -2,10 +2,16 @@ "mobx": minor --- -Adds a temporary global migration flag for computed `keepAlive` default value: +Adds a global flag for computed `keepAlive` default value: `configure({ globalKeepAliveState })` Current behavior is preserved by default (`globalKeepAliveState: false`), explicit `keepAlive` still takes precedence +Computed keepAlive now resolves in this order: + +1. explicit `keepAlive` +2. `configure({ globalKeepAliveState })` +3. default `true` + MobX now warns once when a computed is created without an explicit `keepAlive`, to help prepare for the next major version where the default will change. diff --git a/docs/computeds.md b/docs/computeds.md index 698c35235..54ee0ef16 100644 --- a/docs/computeds.md +++ b/docs/computeds.md @@ -98,9 +98,9 @@ When using computed values there are a couple of best practices to follow: ## Tips -
**Tip:** computed values will stay alive (cached) even when they're _not_ observed +
**Tip:** computed values will be suspended if they are _not_ observed (by default) -Computed values are **memoized by default**, even outside reactions. +By default, computeds that are read outside a reaction are not kept alive, so they can recompute on each untracked access. _This will be changed in next major release_ The following code demonstrates it: @@ -108,14 +108,14 @@ The following code demonstrates it: // OrderLine has a computed property `total`. const line = new OrderLine(2.0) -// If you access `line.total` outside of a reaction, it is still cached. +// If you access `line.total` outside of a reaction, it is recomputed every time. setInterval(() => { console.log(line.total) }, 60) ``` -If you need the old suspension behavior (no caching when not observed), set `keepAlive: false`. -It can reduce retained memory, but may trigger extra recomputation on untracked reads. +You can override this per computed with `keepAlive: true`, or globally with `configure({ globalKeepAliveState: true })`. +Keeping computeds alive can increase memory retention, but avoids extra recomputation on untracked reads. MobX can also be configured with the [`computedRequiresReaction`](configuration.md#computedrequiresreaction-boolean) option, to report an error when computeds are accessed outside of a reactive context. @@ -232,5 +232,6 @@ It is recommended to set this one to `true` on very expensive computed values. I ### `keepAlive` `keepAlive` controls whether a computed gets suspended when it has no observers. -By default, computeds are kept alive (`true`). -Set `keepAlive: false` to opt into suspension behavior and lower retention at the cost of potential recomputation. +Explicit `keepAlive` takes precedence. +If omitted, MobX uses `configure({ globalKeepAliveState })`, which currently defaults to `false` to preserve existing behavior. +In the next major version, `globalKeepAliveState` will default to `true` diff --git a/docs/configuration.md b/docs/configuration.md index 279e75513..f8132c47d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -136,6 +136,15 @@ const clock = new Clock() } ``` +#### `globalKeepAliveState: boolean` + +Overrides the default `keepAlive` behavior for computed's that do not set `keepAlive` explicitly. +Explicit `keepAlive` takes precedence. `globalKeepAliveState` currently defaults to `false` to preserve existing behavior. In next major, the flag will default to `true` + +```javascript +configure({ globalKeepAliveState: false }) +``` + #### `observableRequiresReaction: boolean` Warns about any unobserved observable access. diff --git a/packages/mobx/__tests__/v5/base/observables.js b/packages/mobx/__tests__/v5/base/observables.js index 3e74c509f..3bef8cb30 100644 --- a/packages/mobx/__tests__/v5/base/observables.js +++ b/packages/mobx/__tests__/v5/base/observables.js @@ -56,10 +56,6 @@ test("basic", function () { }) describe("computed keepAlive: migration flag", () => { - beforeEach(() => { - mobx.configure({ globalKeepAliveState: false }) - }) - afterEach(() => { mobx.configure({ globalKeepAliveState: false }) mobx._resetGlobalState() @@ -84,6 +80,7 @@ describe("computed keepAlive: migration flag", () => { test("implicit `keepAlive` defaults to global flag", () => { const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}) try { + mobx.configure({ globalKeepAliveState: false }) let computations = 0 const box = observable.box(1) const computed = mobx.computed(() => { diff --git a/packages/mobx/src/api/configure.ts b/packages/mobx/src/api/configure.ts index c82c3c3dd..85cd91db4 100644 --- a/packages/mobx/src/api/configure.ts +++ b/packages/mobx/src/api/configure.ts @@ -55,7 +55,7 @@ export function configure(options: { } }) if (options.globalKeepAliveState !== undefined) { - globalState.globalKeepAliveState = options.globalKeepAliveState + globalState.globalKeepAliveState = !!options.globalKeepAliveState } globalState.allowStateReads = !globalState.observableRequiresReaction if (__DEV__ && globalState.disableErrorBoundaries === true) { diff --git a/packages/mobx/src/core/computedvalue.ts b/packages/mobx/src/core/computedvalue.ts index 73dd3fe45..4b6d16762 100644 --- a/packages/mobx/src/core/computedvalue.ts +++ b/packages/mobx/src/core/computedvalue.ts @@ -37,7 +37,7 @@ import { getFlag, setFlag } from "../utils/utils" const implicitKeepAliveDefaultWarning = once(() => console.warn( - "[mobx] Computed created without explicit `keepAlive`. The default is currently controlled by `configure({ globalKeepAliveState })` and will change from `false` to `true` in the next major version. Set `keepAlive` explicitly (`true`/`false`) to avoid relying on defaults." + "[mobx] Computed created without explicit `keepAlive`. It will use `configure({ globalKeepAliveState })` if set, otherwise the current default (`false`, changing to `true` in the next major version). Set `keepAlive` explicitly to avoid relying on global / default behavior." ) )