Skip to content

Commit 53e3203

Browse files
authored
feat(performance): Make non-strict mode faster for classes. Addresses #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
2 parents 9713677 + 511ccee commit 53e3203

File tree

5 files changed

+120
-70
lines changed

5 files changed

+120
-70
lines changed

__tests__/not-strict-copy.ts

+71-33
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,75 @@
1-
import {produce, setUseStrictShallowCopy} from "../src/immer"
1+
import {
2+
immerable,
3+
produce,
4+
setUseStrictShallowCopy,
5+
setAutoFreeze,
6+
StrictMode
7+
} from "../src/immer"
28

3-
describe("setUseStrictShallowCopy(true)", () => {
4-
test("keep descriptors", () => {
5-
setUseStrictShallowCopy(true)
9+
describe.each([true, false, "class_only" as const])(
10+
"setUseStrictShallowCopy(true)",
11+
(strictMode: StrictMode) => {
12+
test("keep descriptors, mode: " + strictMode, () => {
13+
setUseStrictShallowCopy(strictMode)
614

7-
const base: Record<string, unknown> = {}
8-
Object.defineProperty(base, "foo", {
9-
value: "foo",
10-
writable: false,
11-
configurable: false
15+
const base: Record<string, unknown> = {}
16+
Object.defineProperty(base, "foo", {
17+
value: "foo",
18+
writable: false,
19+
configurable: false
20+
})
21+
const copy = produce(base, (draft: any) => {
22+
draft.bar = "bar"
23+
})
24+
if (strictMode === true) {
25+
expect(Object.getOwnPropertyDescriptor(copy, "foo")).toStrictEqual(
26+
Object.getOwnPropertyDescriptor(base, "foo")
27+
)
28+
} else {
29+
expect(Object.getOwnPropertyDescriptor(copy, "foo")).toBeUndefined()
30+
}
1231
})
13-
const copy = produce(base, (draft: any) => {
14-
draft.bar = "bar"
15-
})
16-
expect(Object.getOwnPropertyDescriptor(copy, "foo")).toStrictEqual(
17-
Object.getOwnPropertyDescriptor(base, "foo")
18-
)
19-
})
20-
})
21-
22-
describe("setUseStrictShallowCopy(false)", () => {
23-
test("ignore descriptors", () => {
24-
setUseStrictShallowCopy(false)
25-
26-
const base: Record<string, unknown> = {}
27-
Object.defineProperty(base, "foo", {
28-
value: "foo",
29-
writable: false,
30-
configurable: false
31-
})
32-
const copy = produce(base, (draft: any) => {
33-
draft.bar = "bar"
32+
33+
test("keep non-enumerable class descriptors, mode: " + strictMode, () => {
34+
setUseStrictShallowCopy(strictMode)
35+
setAutoFreeze(false)
36+
37+
class X {
38+
[immerable] = true
39+
foo = "foo"
40+
bar!: string
41+
constructor() {
42+
Object.defineProperty(this, "bar", {
43+
get() {
44+
return this.foo + "bar"
45+
},
46+
configurable: false,
47+
enumerable: false
48+
})
49+
}
50+
51+
get baz() {
52+
return this.foo + "baz"
53+
}
54+
}
55+
56+
const copy = produce(new X(), (draft: any) => {
57+
draft.foo = "FOO"
58+
})
59+
60+
const strict = strictMode === true || strictMode === "class_only"
61+
62+
// descriptors on the prototype are unaffected, so this is still a getter
63+
expect(copy.baz).toBe("FOObaz")
64+
// descriptors on the instance are found, even when non-enumerable, and read during copy
65+
// so new values won't be reflected
66+
expect(copy.bar).toBe(strict ? "foobar" : undefined)
67+
68+
copy.foo = "fluff"
69+
// not updated, the own prop became a value
70+
expect(copy.bar).toBe(strict ? "foobar" : undefined)
71+
// updated, it is still a getter
72+
expect(copy.baz).toBe("fluffbaz")
3473
})
35-
expect(Object.getOwnPropertyDescriptor(copy, "foo")).toBeUndefined()
36-
})
37-
})
74+
}
75+
)

src/core/immerClass.ts

+8-3
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,16 @@ interface ProducersFns {
3131
produceWithPatches: IProduceWithPatches
3232
}
3333

34+
export type StrictMode = boolean | "class_only";
35+
3436
export class Immer implements ProducersFns {
3537
autoFreeze_: boolean = true
36-
useStrictShallowCopy_: boolean = false
38+
useStrictShallowCopy_: StrictMode = false
3739

38-
constructor(config?: {autoFreeze?: boolean; useStrictShallowCopy?: boolean}) {
40+
constructor(config?: {
41+
autoFreeze?: boolean
42+
useStrictShallowCopy?: StrictMode
43+
}) {
3944
if (typeof config?.autoFreeze === "boolean")
4045
this.setAutoFreeze(config!.autoFreeze)
4146
if (typeof config?.useStrictShallowCopy === "boolean")
@@ -163,7 +168,7 @@ export class Immer implements ProducersFns {
163168
*
164169
* By default, immer does not copy the object descriptors such as getter, setter and non-enumrable properties.
165170
*/
166-
setUseStrictShallowCopy(value: boolean) {
171+
setUseStrictShallowCopy(value: StrictMode) {
167172
this.useStrictShallowCopy_ = value
168173
}
169174

src/immer.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ export {
1818
NOTHING as nothing,
1919
DRAFTABLE as immerable,
2020
freeze,
21-
Objectish
21+
Objectish,
22+
StrictMode
2223
} from "./internal"
2324

2425
const immer = new Immer()

src/utils/common.ts

+35-29
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import {
88
AnySet,
99
ImmerState,
1010
ArchType,
11-
die
11+
die,
12+
StrictMode
1213
} from "../internal"
1314

1415
export const getPrototypeOf = Object.getPrototypeOf
@@ -140,7 +141,7 @@ export function latest(state: ImmerState): any {
140141
}
141142

142143
/*#__PURE__*/
143-
export function shallowCopy(base: any, strict: boolean) {
144+
export function shallowCopy(base: any, strict: StrictMode) {
144145
if (isMap(base)) {
145146
return new Map(base)
146147
}
@@ -149,36 +150,41 @@ export function shallowCopy(base: any, strict: boolean) {
149150
}
150151
if (Array.isArray(base)) return Array.prototype.slice.call(base)
151152

152-
if (!strict && isPlainObject(base)) {
153-
if (!getPrototypeOf(base)) {
154-
const obj = Object.create(null)
155-
return Object.assign(obj, base)
153+
const isPlain = isPlainObject(base)
154+
155+
if (strict === true || (strict === "class_only" && !isPlain)) {
156+
// Perform a strict copy
157+
const descriptors = Object.getOwnPropertyDescriptors(base)
158+
delete descriptors[DRAFT_STATE as any]
159+
let keys = Reflect.ownKeys(descriptors)
160+
for (let i = 0; i < keys.length; i++) {
161+
const key: any = keys[i]
162+
const desc = descriptors[key]
163+
if (desc.writable === false) {
164+
desc.writable = true
165+
desc.configurable = true
166+
}
167+
// like object.assign, we will read any _own_, get/set accessors. This helps in dealing
168+
// with libraries that trap values, like mobx or vue
169+
// unlike object.assign, non-enumerables will be copied as well
170+
if (desc.get || desc.set)
171+
descriptors[key] = {
172+
configurable: true,
173+
writable: true, // could live with !!desc.set as well here...
174+
enumerable: desc.enumerable,
175+
value: base[key]
176+
}
156177
}
157-
return {...base}
158-
}
159-
160-
const descriptors = Object.getOwnPropertyDescriptors(base)
161-
delete descriptors[DRAFT_STATE as any]
162-
let keys = Reflect.ownKeys(descriptors)
163-
for (let i = 0; i < keys.length; i++) {
164-
const key: any = keys[i]
165-
const desc = descriptors[key]
166-
if (desc.writable === false) {
167-
desc.writable = true
168-
desc.configurable = true
178+
return Object.create(getPrototypeOf(base), descriptors)
179+
} else {
180+
// perform a sloppy copy
181+
const proto = getPrototypeOf(base)
182+
if (proto !== null && isPlain) {
183+
return {...base} // assumption: better inner class optimization than the assign below
169184
}
170-
// like object.assign, we will read any _own_, get/set accessors. This helps in dealing
171-
// with libraries that trap values, like mobx or vue
172-
// unlike object.assign, non-enumerables will be copied as well
173-
if (desc.get || desc.set)
174-
descriptors[key] = {
175-
configurable: true,
176-
writable: true, // could live with !!desc.set as well here...
177-
enumerable: desc.enumerable,
178-
value: base[key]
179-
}
185+
const obj = Object.create(proto)
186+
return Object.assign(obj, base)
180187
}
181-
return Object.create(getPrototypeOf(base), descriptors)
182188
}
183189

184190
/**

website/docs/complex-objects.md

+4-4
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ title: Classes
77
<div data-ea-publisher="immerjs" data-ea-type="image" className="horizontal bordered"></div>
88
</center>
99

10-
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)`.
11-
1210
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.
1311

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

6361
1. A draft of a class is a fresh object but with the same prototype as the original object.
64-
1. When creating a draft, Immer will copy all _own_ properties from the base to the draft.This includes non-enumerable and symbolic properties.
62+
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.
6563
1. _Own_ getters will be invoked during the copy process, just like `Object.assign` would.
66-
1. Inherited getters and methods will remain as is and be inherited by the draft.
64+
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.
6765
1. Immer will not invoke constructor functions.
6866
1. The final instance will be constructed with the same mechanism as the draft was created.
6967
1. Only getters that have a setter as well will be writable in the draft, as otherwise the value can't be copied back.
7068

7169
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.
7270

71+
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).
72+
7373
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.
7474

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

0 commit comments

Comments
 (0)