From b1f703b2633d40e107cb5739d1d5a96be6ec542b Mon Sep 17 00:00:00 2001 From: Andrei Chmelev Date: Tue, 17 Mar 2026 07:18:56 +0300 Subject: [PATCH 1/2] feat(mobx): add configure option to disable descriptor cache Add useDescriptorCache to configure so apps can opt out of descriptor reuse for observable plain-object keys. When disabled, MobX also clears the existing descriptor cache to avoid retaining descriptors for unbounded dynamic keys. --- docs/configuration.md | 12 ++++++ .../mobx/__tests__/v5/base/observables.js | 38 +++++++++++++++++++ packages/mobx/src/api/configure.ts | 14 ++++++- packages/mobx/src/core/globalstate.ts | 8 ++++ packages/mobx/src/types/observableobject.ts | 34 ++++++++++------- 5 files changed, 92 insertions(+), 14 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 0b3486357b..43829b9980 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -196,6 +196,18 @@ Note it doesn't affect existing observables, only the ones created after it's be configure({ safeDescriptors: false }) ``` +#### `useDescriptorCache: boolean` + +MobX caches property descriptors for observable plain-object keys so repeated keys can reuse the same getter and setter functions. This is usually a small performance optimization, but the cache can grow without bound when observables are created with a very large number of unique property names, such as UUIDs used as object keys during SSR. + +`configure({ useDescriptorCache: false })` disables descriptor reuse and clears any descriptors that were cached so far. Existing observable objects keep working; only future observable properties stop using the cache. **Default: `true`** + +```javascript +configure({ useDescriptorCache: false }) +``` + +Whenever possible, prefer `observable.map` for truly dynamic key-based collections. + ## Further configuration options #### `isolateGlobalState: boolean` diff --git a/packages/mobx/__tests__/v5/base/observables.js b/packages/mobx/__tests__/v5/base/observables.js index a02424dbad..6a669313dd 100644 --- a/packages/mobx/__tests__/v5/base/observables.js +++ b/packages/mobx/__tests__/v5/base/observables.js @@ -2193,6 +2193,44 @@ test("configure({ safeDescriptors: false })", () => { expect(globalState.safeDescriptors).toBe(true) }) +test("configure({ useDescriptorCache: false }) disables and evicts cached object descriptors", () => { + const globalState = mobx._getGlobalState() + const getKeyGetter = () => + Object.getOwnPropertyDescriptor( + mobx.observable.object( + { + key: 1 + }, + {}, + { proxy: false } + ), + "key" + ).get + + try { + expect(globalState.useDescriptorCache).toBe(true) + + const cachedGetter = getKeyGetter() + expect(getKeyGetter()).toBe(cachedGetter) + + mobx.configure({ useDescriptorCache: false }) + expect(globalState.useDescriptorCache).toBe(false) + + const uncachedGetter = getKeyGetter() + expect(uncachedGetter).not.toBe(cachedGetter) + expect(getKeyGetter()).not.toBe(uncachedGetter) + + mobx.configure({ useDescriptorCache: true }) + expect(globalState.useDescriptorCache).toBe(true) + + const recachedGetter = getKeyGetter() + expect(recachedGetter).not.toBe(cachedGetter) + expect(getKeyGetter()).toBe(recachedGetter) + } finally { + mobx.configure({ useDescriptorCache: true }) + } +}) + test("function props are observable auto actions", () => { const o = observable({ observable: 0, diff --git a/packages/mobx/src/api/configure.ts b/packages/mobx/src/api/configure.ts index cfabf9f2d4..afe38608fd 100644 --- a/packages/mobx/src/api/configure.ts +++ b/packages/mobx/src/api/configure.ts @@ -1,4 +1,9 @@ -import { globalState, isolateGlobalState, setReactionScheduler } from "../internal" +import { + clearObservablePropDescriptorCache, + globalState, + isolateGlobalState, + setReactionScheduler +} from "../internal" const NEVER = "never" const ALWAYS = "always" @@ -19,6 +24,7 @@ export function configure(options: { isolateGlobalState?: boolean disableErrorBoundaries?: boolean safeDescriptors?: boolean + useDescriptorCache?: boolean reactionScheduler?: (f: () => void) => void useProxies?: "always" | "never" | "ifavailable" }): void { @@ -42,6 +48,12 @@ export function configure(options: { globalState.enforceActions = ea globalState.allowStateChanges = ea === true || ea === ALWAYS ? false : true } + if ("useDescriptorCache" in options) { + globalState.useDescriptorCache = !!options.useDescriptorCache + if (!globalState.useDescriptorCache) { + clearObservablePropDescriptorCache() + } + } ;[ "computedRequiresReaction", "reactionRequiresObservable", diff --git a/packages/mobx/src/core/globalstate.ts b/packages/mobx/src/core/globalstate.ts index 35de9dfea8..a6d12af63b 100644 --- a/packages/mobx/src/core/globalstate.ts +++ b/packages/mobx/src/core/globalstate.ts @@ -144,6 +144,11 @@ export class MobXGlobals { */ verifyProxies = false + /* + * Reuse property descriptors for observable plain-object keys. + */ + useDescriptorCache = true + /** * False forces all object's descriptors to * writable: true @@ -178,6 +183,9 @@ export let globalState: MobXGlobals = (function () { if (!global.__mobxGlobals.UNCHANGED) { global.__mobxGlobals.UNCHANGED = {} } // make merge backward compatible + if (global.__mobxGlobals.useDescriptorCache === undefined) { + global.__mobxGlobals.useDescriptorCache = true + } return global.__mobxGlobals } else { global.__mobxInstanceCount = 1 diff --git a/packages/mobx/src/types/observableobject.ts b/packages/mobx/src/types/observableobject.ts index a24b4a8c62..f87bce2f88 100644 --- a/packages/mobx/src/types/observableobject.ts +++ b/packages/mobx/src/types/observableobject.ts @@ -50,7 +50,11 @@ import { checkIfStateModificationsAreAllowed } from "../internal" -const descriptorCache = Object.create(null) +let descriptorCache = Object.create(null) + +export function clearObservablePropDescriptorCache() { + descriptorCache = Object.create(null) +} export type IObjectDidChange = { observableKind: "object" @@ -698,18 +702,22 @@ const isObservableObjectAdministration = createInstanceofPredicate( ObservableObjectAdministration ) -function getCachedObservablePropDescriptor(key) { - return ( - descriptorCache[key] || - (descriptorCache[key] = { - get() { - return this[$mobx].getObservablePropValue_(key) - }, - set(value) { - return this[$mobx].setObservablePropValue_(key, value) - } - }) - ) +function createObservablePropDescriptor(key: PropertyKey) { + return { + get() { + return this[$mobx].getObservablePropValue_(key) + }, + set(value) { + return this[$mobx].setObservablePropValue_(key, value) + } + } +} + +function getCachedObservablePropDescriptor(key: PropertyKey) { + if (!globalState.useDescriptorCache) { + return createObservablePropDescriptor(key) + } + return descriptorCache[key] || (descriptorCache[key] = createObservablePropDescriptor(key)) } export function isObservableObject(thing: any): boolean { From 4b43010ac168b7cf08f89e811920c31b44c7d362 Mon Sep 17 00:00:00 2001 From: Andrei Chmelev Date: Tue, 17 Mar 2026 07:32:09 +0300 Subject: [PATCH 2/2] chore: add changeset for descriptor cache option --- .changeset/curly-ladybugs-sparkle.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/curly-ladybugs-sparkle.md diff --git a/.changeset/curly-ladybugs-sparkle.md b/.changeset/curly-ladybugs-sparkle.md new file mode 100644 index 0000000000..564e2a892a --- /dev/null +++ b/.changeset/curly-ladybugs-sparkle.md @@ -0,0 +1,5 @@ +--- +"mobx": minor +--- + +Add a configure option to disable descriptor cache reuse for observable object keys.