Skip to content

Commit 9877d64

Browse files
committed
feat: limited class support
When a class instance has the new `immerable` symbol in its own properties, its prototype, or on its constructor, the class instance can be drafted. When drafted, class instances are basically plain objects, just with a different prototype. No magic here. OTHER CHANGES: - Allow symbols as property names in drafts - Add non-enumerable property support to drafts - Improved error messages - Fixed issue where ES5 drafts were not being marked as `finalizing` before being shallow cloned, which resulted in unnecessary draft creation
1 parent d5d07e8 commit 9877d64

File tree

7 files changed

+217
-59
lines changed

7 files changed

+217
-59
lines changed

__tests__/base.js

Lines changed: 98 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use strict"
2-
import {Immer, nothing, original, isDraft} from "../src/index"
3-
import {shallowCopy} from "../src/common"
2+
import {Immer, nothing, original, isDraft, immerable} from "../src/index"
3+
import {each, shallowCopy, isEnumerable} from "../src/common"
44
import deepFreeze from "deep-freeze"
55
import cloneDeep from "lodash.clonedeep"
66
import * as lodash from "lodash"
@@ -259,7 +259,9 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) {
259259
produce([], d => {
260260
d.x = 3
261261
})
262-
}).toThrow(/does not support/)
262+
}).toThrow(
263+
"Immer only supports setting array indices and the 'length' property"
264+
)
263265
})
264266

265267
it("throws when a non-numeric property is deleted", () => {
@@ -269,11 +271,86 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) {
269271
produce(baseState, d => {
270272
delete d.x
271273
})
272-
}).toThrow(/does not support/)
274+
}).toThrow("Immer only supports deleting array indices")
273275
})
274276
}
275277
})
276278

279+
it("supports `immerable` symbol on constructor", () => {
280+
class One {}
281+
One[immerable] = true
282+
const baseState = new One()
283+
const nextState = produce(baseState, draft => {
284+
expect(draft).not.toBe(baseState)
285+
draft.foo = true
286+
})
287+
expect(nextState).not.toBe(baseState)
288+
expect(nextState.foo).toBeTruthy()
289+
})
290+
291+
it("preserves symbol properties", () => {
292+
const test = Symbol("test")
293+
const baseState = {[test]: true}
294+
const nextState = produce(baseState, s => {
295+
expect(s[test]).toBeTruthy()
296+
s.foo = true
297+
})
298+
expect(nextState).toEqual({
299+
[test]: true,
300+
foo: true
301+
})
302+
})
303+
304+
it("preserves non-enumerable properties", () => {
305+
const baseState = {}
306+
Object.defineProperty(baseState, "foo", {
307+
value: true,
308+
enumerable: false
309+
})
310+
const nextState = produce(baseState, s => {
311+
expect(s.foo).toBeTruthy()
312+
expect(isEnumerable(s, "foo")).toBeFalsy()
313+
s.bar = true
314+
})
315+
expect(nextState.foo).toBeTruthy()
316+
expect(isEnumerable(nextState, "foo")).toBeFalsy()
317+
})
318+
319+
it("throws on computed properties", () => {
320+
const baseState = {}
321+
Object.defineProperty(baseState, "foo", {
322+
get: () => {},
323+
enumerable: true
324+
})
325+
expect(() => {
326+
produce(baseState, s => {
327+
// Proxies only throw once a change is made.
328+
if (useProxies) {
329+
s.modified = true
330+
}
331+
})
332+
}).toThrowError("Immer drafts cannot have computed properties")
333+
})
334+
335+
it("allows inherited computed properties", () => {
336+
const proto = {}
337+
Object.defineProperty(proto, "foo", {
338+
get() {
339+
return this.bar
340+
},
341+
set(val) {
342+
this.bar = val
343+
}
344+
})
345+
const baseState = Object.create(proto)
346+
produce(baseState, s => {
347+
expect(s.bar).toBeUndefined()
348+
s.foo = {}
349+
expect(s.bar).toBeDefined()
350+
expect(s.foo).toBe(s.bar)
351+
})
352+
})
353+
277354
it("can rename nested objects (no changes)", () => {
278355
const nextState = produce({obj: {}}, s => {
279356
s.foo = s.obj
@@ -429,7 +506,9 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) {
429506
value: 2
430507
})
431508
})
432-
}).toThrow(/does not support/)
509+
}).toThrow(
510+
"Object.defineProperty() cannot be used on an Immer draft"
511+
)
433512
})
434513

435514
it("should handle constructor correctly", () => {
@@ -604,7 +683,7 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) {
604683
it("throws when Object.setPrototypeOf() is used on a draft", () => {
605684
produce({}, draft => {
606685
expect(() => Object.setPrototypeOf(draft, Array)).toThrow(
607-
/does not support/
686+
"Object.setPrototypeOf() cannot be used on an Immer draft"
608687
)
609688
})
610689
})
@@ -905,12 +984,19 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) {
905984
}
906985

907986
function testObjectTypes(produce) {
987+
class Foo {
988+
constructor(foo) {
989+
this.foo = foo
990+
this[immerable] = true
991+
}
992+
}
908993
const values = {
909994
"empty object": {},
910995
"plain object": {a: 1, b: 2},
911996
"object (no prototype)": Object.create(null),
912997
"empty array": [],
913-
"plain array": [1, 2]
998+
"plain array": [1, 2],
999+
"class instance (draftable)": new Foo(1)
9141000
}
9151001
for (const name in values) {
9161002
const value = values[name]
@@ -976,7 +1062,7 @@ function testLiteralTypes(produce) {
9761062
"boxed string": new String(""),
9771063
"boxed boolean": new Boolean(),
9781064
"date object": new Date(),
979-
"class instance": new Foo()
1065+
"class instance (not draftable)": new Foo()
9801066
}
9811067
for (const name in values) {
9821068
describe(name, () => {
@@ -1006,12 +1092,11 @@ function testLiteralTypes(produce) {
10061092
}
10071093

10081094
function enumerableOnly(x) {
1009-
const copy = shallowCopy(x)
1010-
for (const key in copy) {
1011-
const value = copy[key]
1095+
const copy = Array.isArray(x) ? x.slice() : Object.assign({}, x)
1096+
each(copy, (prop, value) => {
10121097
if (value && typeof value === "object") {
1013-
copy[key] = enumerableOnly(value)
1098+
copy[prop] = enumerableOnly(value)
10141099
}
1015-
}
1100+
})
10161101
return copy
10171102
}

src/common.js

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ export const NOTHING =
33
? Symbol("immer-nothing")
44
: {["immer-nothing"]: true}
55

6+
export const DRAFTABLE =
7+
typeof Symbol !== "undefined"
8+
? Symbol("immer-draftable")
9+
: "__$immer_draftable"
10+
611
export const DRAFT_STATE =
712
typeof Symbol !== "undefined" ? Symbol("immer-state") : "__$immer_state"
813

@@ -11,11 +16,11 @@ export function isDraft(value) {
1116
}
1217

1318
export function isDraftable(value) {
14-
if (!value) return false
15-
if (typeof value !== "object") return false
19+
if (!value || typeof value !== "object") return false
1620
if (Array.isArray(value)) return true
1721
const proto = Object.getPrototypeOf(value)
18-
return proto === null || proto === Object.prototype
22+
if (!proto || proto === Object.prototype) return true
23+
return !!value[DRAFTABLE] || !!value.constructor[DRAFTABLE]
1924
}
2025

2126
export function original(value) {
@@ -36,10 +41,39 @@ export const assign =
3641
return target
3742
}
3843

39-
export function shallowCopy(value) {
40-
if (Array.isArray(value)) return value.slice()
41-
const target = value.__proto__ === undefined ? Object.create(null) : {}
42-
return assign(target, value)
44+
export const ownKeys =
45+
typeof Reflect !== "undefined"
46+
? Reflect.ownKeys
47+
: obj =>
48+
Object.getOwnPropertyNames(obj).concat(
49+
Object.getOwnPropertySymbols(obj)
50+
)
51+
52+
export function shallowCopy(base, invokeGetters = false) {
53+
if (Array.isArray(base)) return base.slice()
54+
const clone = Object.create(Object.getPrototypeOf(base))
55+
ownKeys(base).forEach(key => {
56+
if (key === DRAFT_STATE) {
57+
return // Never copy over draft state.
58+
}
59+
const desc = Object.getOwnPropertyDescriptor(base, key)
60+
if (desc.get) {
61+
if (!invokeGetters) {
62+
throw new Error("Immer drafts cannot have computed properties")
63+
}
64+
desc.value = desc.get.call(base)
65+
}
66+
if (desc.enumerable) {
67+
clone[key] = desc.value
68+
} else {
69+
Object.defineProperty(clone, key, {
70+
value: desc.value,
71+
writable: true,
72+
configurable: true
73+
})
74+
}
75+
})
76+
return clone
4377
}
4478

4579
export function each(value, cb) {
@@ -50,6 +84,18 @@ export function each(value, cb) {
5084
}
5185
}
5286

87+
export function eachOwn(value, cb) {
88+
if (Array.isArray(value)) {
89+
for (let i = 0; i < value.length; i++) cb(i, value[i], value)
90+
} else {
91+
ownKeys(value).forEach(key => cb(key, value[key], value))
92+
}
93+
}
94+
95+
export function isEnumerable(base, prop) {
96+
return Object.getOwnPropertyDescriptor(base, prop).enumerable
97+
}
98+
5399
export function has(thing, prop) {
54100
return Object.prototype.hasOwnProperty.call(thing, prop)
55101
}

src/es5.js

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import {
88
isDraft,
99
isDraftable,
1010
shallowCopy,
11-
DRAFT_STATE
11+
DRAFT_STATE,
12+
eachOwn,
13+
isEnumerable
1214
} from "./common"
1315

1416
const descriptors = {}
@@ -33,13 +35,16 @@ export function createDraft(base, parent) {
3335
const state = base[DRAFT_STATE]
3436
// Avoid creating new drafts when copying.
3537
state.finalizing = true
36-
draft = shallowCopy(state.draft)
38+
draft = shallowCopy(state.draft, true)
3739
state.finalizing = false
3840
} else {
3941
draft = shallowCopy(base)
4042
}
41-
each(base, prop => {
42-
Object.defineProperty(draft, "" + prop, createPropertyProxy("" + prop))
43+
44+
const isArray = Array.isArray(base)
45+
eachOwn(draft, prop => {
46+
const enumerable = isArray || isEnumerable(base, prop)
47+
proxyProperty(draft, prop, enumerable)
4348
})
4449

4550
// See "proxy.js" for property documentation.
@@ -103,20 +108,23 @@ function prepareCopy(state) {
103108
if (!state.copy) state.copy = shallowCopy(state.base)
104109
}
105110

106-
function createPropertyProxy(prop) {
107-
return (
108-
descriptors[prop] ||
109-
(descriptors[prop] = {
111+
function proxyProperty(draft, prop, enumerable) {
112+
let desc = descriptors[prop]
113+
if (desc) {
114+
desc.enumerable = enumerable
115+
} else {
116+
descriptors[prop] = desc = {
110117
configurable: true,
111-
enumerable: true,
118+
enumerable,
112119
get() {
113120
return get(this[DRAFT_STATE], prop)
114121
},
115122
set(value) {
116123
set(this[DRAFT_STATE], prop, value)
117124
}
118-
})
119-
)
125+
}
126+
}
127+
Object.defineProperty(draft, prop, desc)
120128
}
121129

122130
function assertUnrevoked(state) {

src/immer.d.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,16 @@ declare class Nothing {
122122
*/
123123
export const nothing: Nothing
124124

125+
/**
126+
* To let Immer treat your class instances as plain immutable objects
127+
* (albeit with a custom prototype), you must define either an instance property
128+
* or a static property on each of your custom classes.
129+
*
130+
* Otherwise, your class instance will never be drafted, which means it won't be
131+
* safe to mutate in a produce callback.
132+
*/
133+
export const immerable: unique symbol
134+
125135
/**
126136
* Pass true to automatically freeze all copies created by Immer.
127137
*

0 commit comments

Comments
 (0)