From 94017adf5992d86dcd3016f04f08445b536ddb45 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sat, 6 Sep 2025 00:45:43 -0400 Subject: [PATCH 01/11] Use WeakMap caching implementation of isPlainObject --- src/utils/common.ts | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/src/utils/common.ts b/src/utils/common.ts index 0ae33ba1..7fec336e 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -36,7 +36,7 @@ export function isDraftable(value: any): boolean { const objectCtorString = Object.prototype.constructor.toString() /*#__PURE__*/ -export function isPlainObject(value: any): boolean { +export function isPlainObjectOriginal(value: any): boolean { if (!value || typeof value !== "object") return false const proto = getPrototypeOf(value) if (proto === null) { @@ -53,6 +53,29 @@ export function isPlainObject(value: any): boolean { ) } +const cachedCtorStrings = new WeakMap() +function isPlainObjectCached(value: any) { + if (!value || typeof value !== "object") return false + const proto = Object.getPrototypeOf(value) + if (proto === null || proto === Object.prototype) return true + + const Ctor = + Object.hasOwnProperty.call(proto, "constructor") && proto.constructor + if (Ctor === Object) return true + + if (typeof Ctor !== "function") return false + + let ctorString = cachedCtorStrings.get(Ctor) + if (ctorString === undefined) { + ctorString = Function.toString.call(Ctor) + cachedCtorStrings.set(Ctor, ctorString) + } + + return ctorString === objectCtorString +} + +export const isPlainObject = isPlainObjectCached + /** Get the underlying object that is represented by the given draft */ /*#__PURE__*/ export function original(value: T): T | undefined @@ -198,12 +221,12 @@ export function freeze(obj: T, deep?: boolean): T export function freeze(obj: any, deep: boolean = false): T { if (isFrozen(obj) || isDraft(obj) || !isDraftable(obj)) return obj if (getArchtype(obj) > 1 /* Map or Set */) { - Object.defineProperties(obj, { - set: {value: dontMutateFrozenCollections as any}, - add: {value: dontMutateFrozenCollections as any}, - clear: {value: dontMutateFrozenCollections as any}, - delete: {value: dontMutateFrozenCollections as any} - }) + Object.defineProperties(obj, { + set: {value: dontMutateFrozenCollections as any}, + add: {value: dontMutateFrozenCollections as any}, + clear: {value: dontMutateFrozenCollections as any}, + delete: {value: dontMutateFrozenCollections as any} + }) } Object.freeze(obj) if (deep) From 7c3ae1274bde53110553e1a9b71c85f2c409c352 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sat, 6 Sep 2025 23:41:31 -0400 Subject: [PATCH 02/11] Add some early returns to `finalizeProperty` --- src/core/finalize.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/core/finalize.ts b/src/core/finalize.ts index bf1ac742..1de7b075 100644 --- a/src/core/finalize.ts +++ b/src/core/finalize.ts @@ -114,6 +114,18 @@ function finalizeProperty( rootPath?: PatchPath, targetIsSet?: boolean ) { + if (childValue == null) { + return + } + + if (typeof childValue !== "object" && !targetIsSet) { + return + } + const childIsFrozen = isFrozen(childValue) + if (childIsFrozen && !targetIsSet) { + return + } + if (process.env.NODE_ENV !== "production" && childValue === targetObject) die(5) if (isDraft(childValue)) { @@ -136,7 +148,7 @@ function finalizeProperty( targetObject.add(childValue) } // Search new objects for unfinalized drafts. Frozen objects should never contain drafts. - if (isDraftable(childValue) && !isFrozen(childValue)) { + if (isDraftable(childValue) && !childIsFrozen) { if (!rootScope.immer_.autoFreeze_ && rootScope.unfinalizedDrafts_ < 1) { // optimization: if an object is not a draft, and we don't have to // deepfreeze everything, and we are sure that no drafts are left in the remaining object @@ -145,6 +157,15 @@ function finalizeProperty( // See add-data.js perf test return } + if ( + parentState && + parentState.base_ && + parentState.base_[prop] === childValue && + childIsFrozen + ) { + // Object is unchanged from base - no need to process further + return + } finalize(rootScope, childValue) // Immer deep freezes plain objects, so if there is no parent state, we freeze as well // Per #590, we never freeze symbolic properties. Just to make sure don't accidentally interfere From 1a8c36ba7f53f3ad6a17570bd3329426c93e203e Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 7 Sep 2025 01:41:55 -0400 Subject: [PATCH 03/11] Add `strictIteration` option --- src/core/immerClass.ts | 20 +++++++++++++++++++- src/immer.ts | 10 ++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/core/immerClass.ts b/src/core/immerClass.ts index f827361c..b4b69d5a 100644 --- a/src/core/immerClass.ts +++ b/src/core/immerClass.ts @@ -31,20 +31,24 @@ interface ProducersFns { produceWithPatches: IProduceWithPatches } -export type StrictMode = boolean | "class_only"; +export type StrictMode = boolean | "class_only" export class Immer implements ProducersFns { autoFreeze_: boolean = true useStrictShallowCopy_: StrictMode = false + useStrictIteration_: boolean = false constructor(config?: { autoFreeze?: boolean useStrictShallowCopy?: StrictMode + useStrictIteration?: boolean }) { if (typeof config?.autoFreeze === "boolean") this.setAutoFreeze(config!.autoFreeze) if (typeof config?.useStrictShallowCopy === "boolean") this.setUseStrictShallowCopy(config!.useStrictShallowCopy) + if (typeof config?.useStrictIteration === "boolean") + this.setUseStrictIteration(config!.useStrictIteration) } /** @@ -172,6 +176,20 @@ export class Immer implements ProducersFns { this.useStrictShallowCopy_ = value } + /** + * Pass false to use faster iteration that skips non-enumerable properties + * but still handles symbols for compatibility. + * + * By default, strict iteration is enabled (includes all own properties). + */ + setUseStrictIteration(value: boolean) { + this.useStrictIteration_ = value + } + + shouldUseStrictIteration(obj: any): boolean { + return this.useStrictIteration_ + } + applyPatches(base: T, patches: readonly Patch[]): T { // If a patch replaces the entire state, take that replacement as base // before applying patches diff --git a/src/immer.ts b/src/immer.ts index f213b716..de79f9cd 100644 --- a/src/immer.ts +++ b/src/immer.ts @@ -71,6 +71,16 @@ export const setUseStrictShallowCopy = /* @__PURE__ */ immer.setUseStrictShallow immer ) +/** + * Pass false to use ultra-fast iteration that only processes enumerable string properties. + * This skips symbols and non-enumerable properties for maximum performance. + * + * By default, strict iteration is enabled (includes all own properties). + */ +export const setUseStrictIteration = /* @__PURE__ */ immer.setUseStrictIteration.bind( + immer +) + /** * Apply an array of Immer patches to the first argument. * From 1b56279e7461d3d91f9bf7d1e48c392d8b9cdf91 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 7 Sep 2025 01:44:45 -0400 Subject: [PATCH 04/11] Add non-strict iteration handling --- src/utils/common.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/utils/common.ts b/src/utils/common.ts index 7fec336e..5ed0db79 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -87,15 +87,23 @@ export function original(value: Drafted): any { /** * Each iterates a map, set or array. * Or, if any other kind of object, all of its own properties. - * Regardless whether they are enumerable or symbols + * + * @param obj The object to iterate over + * @param iter The iterator function + * @param strict When true (default), includes symbols and non-enumerable properties. + * When false, uses ultra-fast iteration over only enumerable string properties. */ export function each( obj: T, - iter: (key: string | number, value: any, source: T) => void + iter: (key: string | number, value: any, source: T) => void, + strict?: boolean ): void -export function each(obj: any, iter: any) { +export function each(obj: any, iter: any, strict: boolean = true) { if (getArchtype(obj) === ArchType.Object) { - Reflect.ownKeys(obj).forEach(key => { + // If strict, we do a full iteration including symbols and non-enumerable properties + // Otherwise, we only iterate enumerable string properties for performance + const keys = strict ? Reflect.ownKeys(obj) : Object.keys(obj) + keys.forEach(key => { iter(key, obj[key], obj) }) } else { From 2f00eecd18b8f4e0e430329e31b4f884b0654986 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 7 Sep 2025 01:47:11 -0400 Subject: [PATCH 05/11] Use strict iteration option --- src/core/current.ts | 12 +++++++++--- src/core/finalize.ts | 22 ++++++++++++++++++---- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/core/current.ts b/src/core/current.ts index 6346e14f..f3856d9f 100644 --- a/src/core/current.ts +++ b/src/core/current.ts @@ -21,18 +21,24 @@ function currentImpl(value: any): any { if (!isDraftable(value) || isFrozen(value)) return value const state: ImmerState | undefined = value[DRAFT_STATE] let copy: any + let strict = true // Default to strict for compatibility if (state) { if (!state.modified_) return state.base_ // Optimization: avoid generating new drafts during copying state.finalized_ = true copy = shallowCopy(value, state.scope_.immer_.useStrictShallowCopy_) + strict = state.scope_.immer_.shouldUseStrictIteration(value) } else { copy = shallowCopy(value, true) } // recurse - each(copy, (key, childValue) => { - set(copy, key, currentImpl(childValue)) - }) + each( + copy, + (key, childValue) => { + set(copy, key, currentImpl(childValue)) + }, + strict + ) if (state) { state.finalized_ = false } diff --git a/src/core/finalize.ts b/src/core/finalize.ts index 1de7b075..4541d2c1 100644 --- a/src/core/finalize.ts +++ b/src/core/finalize.ts @@ -59,8 +59,11 @@ function finalize(rootScope: ImmerScope, value: any, path?: PatchPath) { const state: ImmerState = value[DRAFT_STATE] // A plain object, might need freezing, might contain drafts if (!state) { - each(value, (key, childValue) => - finalizeProperty(rootScope, state, value, key, childValue, path) + each( + value, + (key, childValue) => + finalizeProperty(rootScope, state, value, key, childValue, path), + rootScope.immer_.shouldUseStrictIteration(value) ) return value } @@ -87,8 +90,19 @@ function finalize(rootScope: ImmerScope, value: any, path?: PatchPath) { result.clear() isSet = true } - each(resultEach, (key, childValue) => - finalizeProperty(rootScope, state, result, key, childValue, path, isSet) + each( + resultEach, + (key, childValue) => + finalizeProperty( + rootScope, + state, + result, + key, + childValue, + path, + isSet + ), + rootScope.immer_.shouldUseStrictIteration(resultEach) ) // everything inside is frozen, we can freeze here maybeFreeze(rootScope, result, false) From eadd0f120e1fe8341b0ae39c3c1f2f5f9ef7b045 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 7 Sep 2025 22:07:40 -0400 Subject: [PATCH 06/11] Switch back to default strict iteration --- src/core/immerClass.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/immerClass.ts b/src/core/immerClass.ts index b4b69d5a..6e288298 100644 --- a/src/core/immerClass.ts +++ b/src/core/immerClass.ts @@ -36,7 +36,7 @@ export type StrictMode = boolean | "class_only" export class Immer implements ProducersFns { autoFreeze_: boolean = true useStrictShallowCopy_: StrictMode = false - useStrictIteration_: boolean = false + useStrictIteration_: boolean = true constructor(config?: { autoFreeze?: boolean From 8bf31585777f01de24ff6d3c983f48dd0accdcf1 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Mon, 6 Oct 2025 19:03:35 +0200 Subject: [PATCH 07/11] Fix strict iteration checks --- src/core/current.ts | 2 +- src/core/finalize.ts | 6 ++++-- src/core/immerClass.ts | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/core/current.ts b/src/core/current.ts index f3856d9f..c573ee12 100644 --- a/src/core/current.ts +++ b/src/core/current.ts @@ -27,7 +27,7 @@ function currentImpl(value: any): any { // Optimization: avoid generating new drafts during copying state.finalized_ = true copy = shallowCopy(value, state.scope_.immer_.useStrictShallowCopy_) - strict = state.scope_.immer_.shouldUseStrictIteration(value) + strict = state.scope_.immer_.shouldUseStrictIteration() } else { copy = shallowCopy(value, true) } diff --git a/src/core/finalize.ts b/src/core/finalize.ts index 4541d2c1..895e139f 100644 --- a/src/core/finalize.ts +++ b/src/core/finalize.ts @@ -56,6 +56,8 @@ function finalize(rootScope: ImmerScope, value: any, path?: PatchPath) { // Don't recurse in tho recursive data structures if (isFrozen(value)) return value + const useStrictIteration = rootScope.immer_.shouldUseStrictIteration() + const state: ImmerState = value[DRAFT_STATE] // A plain object, might need freezing, might contain drafts if (!state) { @@ -63,7 +65,7 @@ function finalize(rootScope: ImmerScope, value: any, path?: PatchPath) { value, (key, childValue) => finalizeProperty(rootScope, state, value, key, childValue, path), - rootScope.immer_.shouldUseStrictIteration(value) + useStrictIteration ) return value } @@ -102,7 +104,7 @@ function finalize(rootScope: ImmerScope, value: any, path?: PatchPath) { path, isSet ), - rootScope.immer_.shouldUseStrictIteration(resultEach) + useStrictIteration ) // everything inside is frozen, we can freeze here maybeFreeze(rootScope, result, false) diff --git a/src/core/immerClass.ts b/src/core/immerClass.ts index 6e288298..64c221f1 100644 --- a/src/core/immerClass.ts +++ b/src/core/immerClass.ts @@ -186,7 +186,7 @@ export class Immer implements ProducersFns { this.useStrictIteration_ = value } - shouldUseStrictIteration(obj: any): boolean { + shouldUseStrictIteration(): boolean { return this.useStrictIteration_ } From 8c68f4f5bc05826f665cf2e172544a5d481c4e8a Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Mon, 6 Oct 2025 17:10:47 -0400 Subject: [PATCH 08/11] Shorten benchmark array sizes for faster results --- perf-testing/immutability-benchmarks.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/perf-testing/immutability-benchmarks.mjs b/perf-testing/immutability-benchmarks.mjs index 1d3e63e3..9266f418 100644 --- a/perf-testing/immutability-benchmarks.mjs +++ b/perf-testing/immutability-benchmarks.mjs @@ -59,8 +59,8 @@ const MAX = 1 const BENCHMARK_CONFIG = { iterations: 1, - arraySize: 10000, - nestedArraySize: 100, + arraySize: 100, + nestedArraySize: 10, multiUpdateCount: 5, reuseStateIterations: 10 } From a2162e8838c8056e9908ade0dc9f3f8304a3d368 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Mon, 6 Oct 2025 17:23:38 -0400 Subject: [PATCH 09/11] Dedupe Map/Set method overrides --- src/utils/common.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/utils/common.ts b/src/utils/common.ts index 5ed0db79..3a28fcd9 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -230,10 +230,10 @@ export function freeze(obj: any, deep: boolean = false): T { if (isFrozen(obj) || isDraft(obj) || !isDraftable(obj)) return obj if (getArchtype(obj) > 1 /* Map or Set */) { Object.defineProperties(obj, { - set: {value: dontMutateFrozenCollections as any}, - add: {value: dontMutateFrozenCollections as any}, - clear: {value: dontMutateFrozenCollections as any}, - delete: {value: dontMutateFrozenCollections as any} + set: dontMutateMethodOverride, + add: dontMutateMethodOverride, + clear: dontMutateMethodOverride, + delete: dontMutateMethodOverride }) } Object.freeze(obj) @@ -248,6 +248,10 @@ function dontMutateFrozenCollections() { die(2) } +const dontMutateMethodOverride = { + value: dontMutateFrozenCollections +} + export function isFrozen(obj: any): boolean { return Object.isFrozen(obj) } From a08e62c0448a2414d157da89aca4838f38ba3feb Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Mon, 6 Oct 2025 17:41:20 -0400 Subject: [PATCH 10/11] Removed old isPlainObject impl --- src/immer.ts | 2 +- src/utils/common.ts | 25 +++---------------------- 2 files changed, 4 insertions(+), 23 deletions(-) diff --git a/src/immer.ts b/src/immer.ts index de79f9cd..2d7b1877 100644 --- a/src/immer.ts +++ b/src/immer.ts @@ -72,7 +72,7 @@ export const setUseStrictShallowCopy = /* @__PURE__ */ immer.setUseStrictShallow ) /** - * Pass false to use ultra-fast iteration that only processes enumerable string properties. + * Pass false to use loose iteration that only processes enumerable string properties. * This skips symbols and non-enumerable properties for maximum performance. * * By default, strict iteration is enabled (includes all own properties). diff --git a/src/utils/common.ts b/src/utils/common.ts index 3a28fcd9..87ee713c 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -35,26 +35,9 @@ export function isDraftable(value: any): boolean { } const objectCtorString = Object.prototype.constructor.toString() -/*#__PURE__*/ -export function isPlainObjectOriginal(value: any): boolean { - if (!value || typeof value !== "object") return false - const proto = getPrototypeOf(value) - if (proto === null) { - return true - } - const Ctor = - Object.hasOwnProperty.call(proto, "constructor") && proto.constructor - - if (Ctor === Object) return true - - return ( - typeof Ctor == "function" && - Function.toString.call(Ctor) === objectCtorString - ) -} - const cachedCtorStrings = new WeakMap() -function isPlainObjectCached(value: any) { +/*#__PURE__*/ +export function isPlainObject(value: any): boolean { if (!value || typeof value !== "object") return false const proto = Object.getPrototypeOf(value) if (proto === null || proto === Object.prototype) return true @@ -74,8 +57,6 @@ function isPlainObjectCached(value: any) { return ctorString === objectCtorString } -export const isPlainObject = isPlainObjectCached - /** Get the underlying object that is represented by the given draft */ /*#__PURE__*/ export function original(value: T): T | undefined @@ -91,7 +72,7 @@ export function original(value: Drafted): any { * @param obj The object to iterate over * @param iter The iterator function * @param strict When true (default), includes symbols and non-enumerable properties. - * When false, uses ultra-fast iteration over only enumerable string properties. + * When false, uses looseiteration over only enumerable string properties. */ export function each( obj: T, From a45068545ccf1e1601d80bca413e01805d8c8817 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Mon, 6 Oct 2025 18:00:12 -0400 Subject: [PATCH 11/11] Add early bailout to `isFrozen` --- src/utils/common.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/utils/common.ts b/src/utils/common.ts index 87ee713c..7bb5f93d 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -234,5 +234,7 @@ const dontMutateMethodOverride = { } export function isFrozen(obj: any): boolean { + // Fast path: primitives and null/undefined are always "frozen" + if (obj === null || typeof obj !== "object") return true return Object.isFrozen(obj) }