Skip to content

Commit

Permalink
feat(performance): Make non-strict mode faster for classes. Addresses #…
Browse files Browse the repository at this point in the history
…1071

Immer 10.x solved slow iteration for plain JS objects. This update applies the same handling to class instances. In cases this makes class instance handling 3 times faster. Note that this slightly modifies the behavior of Immer with classes in obscure corner cases, in ways that match current documentation, but do not match previous behavior. If you run into issues with this release icmw. class instances, use `setUseStrictShallowCopy("class_only")` to revert to the old behavior. For more details see https://immerjs.github.io/immer/complex-objects#semantics-in-detail
  • Loading branch information
mweststrate authored Apr 27, 2024
2 parents 9713677 + 511ccee commit 53e3203
Show file tree
Hide file tree
Showing 5 changed files with 120 additions and 70 deletions.
104 changes: 71 additions & 33 deletions __tests__/not-strict-copy.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,75 @@
import {produce, setUseStrictShallowCopy} from "../src/immer"
import {
immerable,
produce,
setUseStrictShallowCopy,
setAutoFreeze,
StrictMode
} from "../src/immer"

describe("setUseStrictShallowCopy(true)", () => {
test("keep descriptors", () => {
setUseStrictShallowCopy(true)
describe.each([true, false, "class_only" as const])(
"setUseStrictShallowCopy(true)",
(strictMode: StrictMode) => {
test("keep descriptors, mode: " + strictMode, () => {
setUseStrictShallowCopy(strictMode)

const base: Record<string, unknown> = {}
Object.defineProperty(base, "foo", {
value: "foo",
writable: false,
configurable: false
const base: Record<string, unknown> = {}
Object.defineProperty(base, "foo", {
value: "foo",
writable: false,
configurable: false
})
const copy = produce(base, (draft: any) => {
draft.bar = "bar"
})
if (strictMode === true) {
expect(Object.getOwnPropertyDescriptor(copy, "foo")).toStrictEqual(
Object.getOwnPropertyDescriptor(base, "foo")
)
} else {
expect(Object.getOwnPropertyDescriptor(copy, "foo")).toBeUndefined()
}
})
const copy = produce(base, (draft: any) => {
draft.bar = "bar"
})
expect(Object.getOwnPropertyDescriptor(copy, "foo")).toStrictEqual(
Object.getOwnPropertyDescriptor(base, "foo")
)
})
})

describe("setUseStrictShallowCopy(false)", () => {
test("ignore descriptors", () => {
setUseStrictShallowCopy(false)

const base: Record<string, unknown> = {}
Object.defineProperty(base, "foo", {
value: "foo",
writable: false,
configurable: false
})
const copy = produce(base, (draft: any) => {
draft.bar = "bar"

test("keep non-enumerable class descriptors, mode: " + strictMode, () => {
setUseStrictShallowCopy(strictMode)
setAutoFreeze(false)

class X {
[immerable] = true
foo = "foo"
bar!: string
constructor() {
Object.defineProperty(this, "bar", {
get() {
return this.foo + "bar"
},
configurable: false,
enumerable: false
})
}

get baz() {
return this.foo + "baz"
}
}

const copy = produce(new X(), (draft: any) => {
draft.foo = "FOO"
})

const strict = strictMode === true || strictMode === "class_only"

// descriptors on the prototype are unaffected, so this is still a getter
expect(copy.baz).toBe("FOObaz")
// descriptors on the instance are found, even when non-enumerable, and read during copy
// so new values won't be reflected
expect(copy.bar).toBe(strict ? "foobar" : undefined)

copy.foo = "fluff"
// not updated, the own prop became a value
expect(copy.bar).toBe(strict ? "foobar" : undefined)
// updated, it is still a getter
expect(copy.baz).toBe("fluffbaz")
})
expect(Object.getOwnPropertyDescriptor(copy, "foo")).toBeUndefined()
})
})
}
)
11 changes: 8 additions & 3 deletions src/core/immerClass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,16 @@ interface ProducersFns {
produceWithPatches: IProduceWithPatches
}

export type StrictMode = boolean | "class_only";

export class Immer implements ProducersFns {
autoFreeze_: boolean = true
useStrictShallowCopy_: boolean = false
useStrictShallowCopy_: StrictMode = false

constructor(config?: {autoFreeze?: boolean; useStrictShallowCopy?: boolean}) {
constructor(config?: {
autoFreeze?: boolean
useStrictShallowCopy?: StrictMode
}) {
if (typeof config?.autoFreeze === "boolean")
this.setAutoFreeze(config!.autoFreeze)
if (typeof config?.useStrictShallowCopy === "boolean")
Expand Down Expand Up @@ -163,7 +168,7 @@ export class Immer implements ProducersFns {
*
* By default, immer does not copy the object descriptors such as getter, setter and non-enumrable properties.
*/
setUseStrictShallowCopy(value: boolean) {
setUseStrictShallowCopy(value: StrictMode) {
this.useStrictShallowCopy_ = value
}

Expand Down
3 changes: 2 additions & 1 deletion src/immer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ export {
NOTHING as nothing,
DRAFTABLE as immerable,
freeze,
Objectish
Objectish,
StrictMode
} from "./internal"

const immer = new Immer()
Expand Down
64 changes: 35 additions & 29 deletions src/utils/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import {
AnySet,
ImmerState,
ArchType,
die
die,
StrictMode
} from "../internal"

export const getPrototypeOf = Object.getPrototypeOf
Expand Down Expand Up @@ -140,7 +141,7 @@ export function latest(state: ImmerState): any {
}

/*#__PURE__*/
export function shallowCopy(base: any, strict: boolean) {
export function shallowCopy(base: any, strict: StrictMode) {
if (isMap(base)) {
return new Map(base)
}
Expand All @@ -149,36 +150,41 @@ export function shallowCopy(base: any, strict: boolean) {
}
if (Array.isArray(base)) return Array.prototype.slice.call(base)

if (!strict && isPlainObject(base)) {
if (!getPrototypeOf(base)) {
const obj = Object.create(null)
return Object.assign(obj, base)
const isPlain = isPlainObject(base)

if (strict === true || (strict === "class_only" && !isPlain)) {
// Perform a strict copy
const descriptors = Object.getOwnPropertyDescriptors(base)
delete descriptors[DRAFT_STATE as any]
let keys = Reflect.ownKeys(descriptors)
for (let i = 0; i < keys.length; i++) {
const key: any = keys[i]
const desc = descriptors[key]
if (desc.writable === false) {
desc.writable = true
desc.configurable = true
}
// like object.assign, we will read any _own_, get/set accessors. This helps in dealing
// with libraries that trap values, like mobx or vue
// unlike object.assign, non-enumerables will be copied as well
if (desc.get || desc.set)
descriptors[key] = {
configurable: true,
writable: true, // could live with !!desc.set as well here...
enumerable: desc.enumerable,
value: base[key]
}
}
return {...base}
}

const descriptors = Object.getOwnPropertyDescriptors(base)
delete descriptors[DRAFT_STATE as any]
let keys = Reflect.ownKeys(descriptors)
for (let i = 0; i < keys.length; i++) {
const key: any = keys[i]
const desc = descriptors[key]
if (desc.writable === false) {
desc.writable = true
desc.configurable = true
return Object.create(getPrototypeOf(base), descriptors)
} else {
// perform a sloppy copy
const proto = getPrototypeOf(base)
if (proto !== null && isPlain) {
return {...base} // assumption: better inner class optimization than the assign below
}
// like object.assign, we will read any _own_, get/set accessors. This helps in dealing
// with libraries that trap values, like mobx or vue
// unlike object.assign, non-enumerables will be copied as well
if (desc.get || desc.set)
descriptors[key] = {
configurable: true,
writable: true, // could live with !!desc.set as well here...
enumerable: desc.enumerable,
value: base[key]
}
const obj = Object.create(proto)
return Object.assign(obj, base)
}
return Object.create(getPrototypeOf(base), descriptors)
}

/**
Expand Down
8 changes: 4 additions & 4 deletions website/docs/complex-objects.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ title: Classes
<div data-ea-publisher="immerjs" data-ea-type="image" className="horizontal bordered"></div>
</center>

By default, Immer does not strictly handle Plain object's non-eumerable properties such as getters/setters for performance reason. If you want this behavior to be strict, you can opt-in with `useStrictShallowCopy(true)`.

Plain objects (objects without a prototype), arrays, `Map`s and `Set`s are always drafted by Immer. Every other object must use the `immerable` symbol to mark itself as compatible with Immer. When one of these objects is mutated within a producer, its prototype is preserved between copies.

```js
Expand Down Expand Up @@ -61,15 +59,17 @@ console.log(clock2 instanceof Clock) // true
The semantics on how classes are drafted are as follows:

1. A draft of a class is a fresh object but with the same prototype as the original object.
1. When creating a draft, Immer will copy all _own_ properties from the base to the draft.This includes non-enumerable and symbolic properties.
1. When creating a draft, Immer will copy all _own_ properties from the base to the draft.This includes (in strict mode) non-enumerable and symbolic properties.
1. _Own_ getters will be invoked during the copy process, just like `Object.assign` would.
1. Inherited getters and methods will remain as is and be inherited by the draft.
1. Inherited getters and methods will remain as is and be inherited by the draft, as they are stored on the prototype which is untouched.
1. Immer will not invoke constructor functions.
1. The final instance will be constructed with the same mechanism as the draft was created.
1. Only getters that have a setter as well will be writable in the draft, as otherwise the value can't be copied back.

Because Immer will dereference own getters of objects into normal properties, it is possible to use objects that use getter/setter traps on their fields, like MobX and Vue do.

Note that, by default, Immer does not strictly handle object's non-enumerable properties such as getters/setters for performance reason. If you want this behavior to be strict, you can opt-in with `useStrictShallowCopy(config)`. Use `true` to always copy strict, or `"class_only"` to only copy class instances strictly but use the faster loose copying for plain objects. The default is `false`. (Remember, regardless of strict mode, own getters / setters are always copied _by value_. There is currently no config to copy descriptors as-is. Feature request / PR welcome).

Immer does not support exotic / engine native objects such as DOM Nodes or Buffers, nor is subclassing Map, Set or arrays supported and the `immerable` symbol can't be used on them.

So when working for example with `Date` objects, you should always create a new `Date` instance instead of mutating an existing `Date` object.

0 comments on commit 53e3203

Please sign in to comment.