diff --git a/__tests__/__snapshots__/base.js.snap b/__tests__/__snapshots__/base.js.snap index 1128b377..c47baf80 100644 --- a/__tests__/__snapshots__/base.js.snap +++ b/__tests__/__snapshots__/base.js.snap @@ -22,8 +22,6 @@ exports[`base functionality - es5 (autofreeze) set drafts revokes sets 1`] = `"[ exports[`base functionality - es5 (autofreeze) set drafts revokes sets 2`] = `"[Immer] Cannot use a proxy that has been revoked. Did you pass an object from inside an immer function to an async process? {}"`; -exports[`base functionality - es5 (autofreeze) throws on computed properties 1`] = `"[Immer] Immer drafts cannot have computed properties"`; - exports[`base functionality - es5 (autofreeze) throws when the draft is modified and another object is returned 1`] = `"[Immer] An immer producer returned a new value *and* modified its draft. Either return a new value *or* modify the draft."`; exports[`base functionality - es5 (autofreeze)(patch listener) async recipe function works with rejected promises 1`] = `"[Immer] Cannot use a proxy that has been revoked. Did you pass an object from inside an immer function to an async process? {\\"a\\":0,\\"b\\":1}"`; @@ -48,8 +46,6 @@ exports[`base functionality - es5 (autofreeze)(patch listener) set drafts revoke exports[`base functionality - es5 (autofreeze)(patch listener) set drafts revokes sets 2`] = `"[Immer] Cannot use a proxy that has been revoked. Did you pass an object from inside an immer function to an async process? {}"`; -exports[`base functionality - es5 (autofreeze)(patch listener) throws on computed properties 1`] = `"[Immer] Immer drafts cannot have computed properties"`; - exports[`base functionality - es5 (autofreeze)(patch listener) throws when the draft is modified and another object is returned 1`] = `"[Immer] An immer producer returned a new value *and* modified its draft. Either return a new value *or* modify the draft."`; exports[`base functionality - es5 (no freeze) async recipe function works with rejected promises 1`] = `"[Immer] Cannot use a proxy that has been revoked. Did you pass an object from inside an immer function to an async process? {\\"a\\":0,\\"b\\":1}"`; @@ -74,8 +70,6 @@ exports[`base functionality - es5 (no freeze) set drafts revokes sets 1`] = `"[I exports[`base functionality - es5 (no freeze) set drafts revokes sets 2`] = `"[Immer] Cannot use a proxy that has been revoked. Did you pass an object from inside an immer function to an async process? {}"`; -exports[`base functionality - es5 (no freeze) throws on computed properties 1`] = `"[Immer] Immer drafts cannot have computed properties"`; - exports[`base functionality - es5 (no freeze) throws when the draft is modified and another object is returned 1`] = `"[Immer] An immer producer returned a new value *and* modified its draft. Either return a new value *or* modify the draft."`; exports[`base functionality - es5 (patch listener) async recipe function works with rejected promises 1`] = `"[Immer] Cannot use a proxy that has been revoked. Did you pass an object from inside an immer function to an async process? {\\"a\\":0,\\"b\\":1}"`; @@ -100,8 +94,6 @@ exports[`base functionality - es5 (patch listener) set drafts revokes sets 1`] = exports[`base functionality - es5 (patch listener) set drafts revokes sets 2`] = `"[Immer] Cannot use a proxy that has been revoked. Did you pass an object from inside an immer function to an async process? {}"`; -exports[`base functionality - es5 (patch listener) throws on computed properties 1`] = `"[Immer] Immer drafts cannot have computed properties"`; - exports[`base functionality - es5 (patch listener) throws when the draft is modified and another object is returned 1`] = `"[Immer] An immer producer returned a new value *and* modified its draft. Either return a new value *or* modify the draft."`; exports[`base functionality - proxy (autofreeze) array drafts throws when a non-numeric property is added 1`] = `"[Immer] Immer only supports setting array indices and the 'length' property"`; @@ -138,8 +130,6 @@ exports[`base functionality - proxy (autofreeze) set drafts revokes sets 1`] = ` exports[`base functionality - proxy (autofreeze) set drafts revokes sets 2`] = `"[Immer] Cannot use a proxy that has been revoked. Did you pass an object from inside an immer function to an async process? {}"`; -exports[`base functionality - proxy (autofreeze) throws on computed properties 1`] = `"[Immer] Immer drafts cannot have computed properties"`; - exports[`base functionality - proxy (autofreeze) throws when Object.defineProperty() is used on drafts 1`] = `"[Immer] Object.defineProperty() cannot be used on an Immer draft"`; exports[`base functionality - proxy (autofreeze) throws when Object.setPrototypeOf() is used on a draft 1`] = `"[Immer] Object.setPrototypeOf() cannot be used on an Immer draft"`; @@ -180,8 +170,6 @@ exports[`base functionality - proxy (autofreeze)(patch listener) set drafts revo exports[`base functionality - proxy (autofreeze)(patch listener) set drafts revokes sets 2`] = `"[Immer] Cannot use a proxy that has been revoked. Did you pass an object from inside an immer function to an async process? {}"`; -exports[`base functionality - proxy (autofreeze)(patch listener) throws on computed properties 1`] = `"[Immer] Immer drafts cannot have computed properties"`; - exports[`base functionality - proxy (autofreeze)(patch listener) throws when Object.defineProperty() is used on drafts 1`] = `"[Immer] Object.defineProperty() cannot be used on an Immer draft"`; exports[`base functionality - proxy (autofreeze)(patch listener) throws when Object.setPrototypeOf() is used on a draft 1`] = `"[Immer] Object.setPrototypeOf() cannot be used on an Immer draft"`; @@ -222,8 +210,6 @@ exports[`base functionality - proxy (no freeze) set drafts revokes sets 1`] = `" exports[`base functionality - proxy (no freeze) set drafts revokes sets 2`] = `"[Immer] Cannot use a proxy that has been revoked. Did you pass an object from inside an immer function to an async process? {}"`; -exports[`base functionality - proxy (no freeze) throws on computed properties 1`] = `"[Immer] Immer drafts cannot have computed properties"`; - exports[`base functionality - proxy (no freeze) throws when Object.defineProperty() is used on drafts 1`] = `"[Immer] Object.defineProperty() cannot be used on an Immer draft"`; exports[`base functionality - proxy (no freeze) throws when Object.setPrototypeOf() is used on a draft 1`] = `"[Immer] Object.setPrototypeOf() cannot be used on an Immer draft"`; @@ -264,8 +250,6 @@ exports[`base functionality - proxy (patch listener) set drafts revokes sets 1`] exports[`base functionality - proxy (patch listener) set drafts revokes sets 2`] = `"[Immer] Cannot use a proxy that has been revoked. Did you pass an object from inside an immer function to an async process? {}"`; -exports[`base functionality - proxy (patch listener) throws on computed properties 1`] = `"[Immer] Immer drafts cannot have computed properties"`; - exports[`base functionality - proxy (patch listener) throws when Object.defineProperty() is used on drafts 1`] = `"[Immer] Object.defineProperty() cannot be used on an Immer draft"`; exports[`base functionality - proxy (patch listener) throws when Object.setPrototypeOf() is used on a draft 1`] = `"[Immer] Object.setPrototypeOf() cannot be used on an Immer draft"`; diff --git a/__tests__/base.js b/__tests__/base.js index d37d2450..2f0fc776 100644 --- a/__tests__/base.js +++ b/__tests__/base.js @@ -933,20 +933,68 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) { expect(isEnumerable(nextState, "foo")).toBeFalsy() }) - it("throws on computed properties", () => { - const baseState = {} - Object.defineProperty(baseState, "foo", { - get: () => {}, - enumerable: true + it("can work with own computed props", () => { + const baseState = { + x: 1, + get y() { + return this.x + }, + set y(v) { + this.x = v + } + } + + const nextState = produce(baseState, d => { + expect(d.y).toBe(1) + d.x = 2 + expect(d.x).toBe(2) + expect(d.y).toBe(1) // this has been copied! + d.y = 3 + expect(d.x).toBe(2) + }) + expect(baseState.x).toBe(1) + expect(baseState.y).toBe(1) + + expect(nextState.x).toBe(2) + expect(nextState.y).toBe(3) + if (!autoFreeze) { + nextState.y = 4 // decoupled now! + expect(nextState.y).toBe(4) + expect(nextState.x).toBe(2) + expect(Object.getOwnPropertyDescriptor(nextState, "y").value).toBe(4) + } + }) + + it("can work with class with computed props", () => { + class State { + [immerable] = true + + x = 1 + + set y(v) { + this.x = v + } + + get y() { + return this.x + } + } + + const baseState = new State() + + const nextState = produce(baseState, d => { + expect(d.y).toBe(1) + d.y = 2 + expect(d.x).toBe(2) + expect(d.y).toBe(2) + expect(Object.getOwnPropertyDescriptor(d, "y")).toBeUndefined() }) - expect(() => { - produce(baseState, s => { - // Proxies only throw once a change is made. - if (useProxies) { - s.modified = true - } - }) - }).toThrowErrorMatchingSnapshot() + expect(baseState.x).toBe(1) + expect(baseState.y).toBe(1) + + expect(nextState.x).toBe(2) + expect(nextState.y).toBe(2) + expect(Object.getOwnPropertyDescriptor(nextState, "y")).toBeUndefined() }) it("allows inherited computed properties", () => { @@ -959,6 +1007,7 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) { this.bar = val } }) + proto[immerable] = true const baseState = Object.create(proto) produce(baseState, s => { expect(s.bar).toBeUndefined() @@ -1508,7 +1557,7 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) { }) autoFreeze && - test.skip("issue #462 - frozen", () => { + test("issue #462 - frozen", () => { var origin = { a: { value: "no" @@ -1517,12 +1566,19 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) { value: "no" } } - var im = produce(origin, origin => { - origin.a.value = "im" + const next = produce(origin, draft => { + draft.a.value = "im" }) - expect(() => (origin.b.value = "yes")).toThrow() // should throw! - // to prevent this...: - // expect(im.b.value).toBe('no'); + expect(() => { + origin.b.value = "yes" + }).toThrowError( + "Cannot assign to read only property 'value' of object '#'" + ) + expect(() => { + next.b.value = "yes" + }).toThrowError( + "Cannot assign to read only property 'value' of object '#'" + ) // should throw! }) autoFreeze && @@ -1784,6 +1840,19 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) { expect(nextState).not.toBe(baseState) }) + it("cannot always detect noop assignments - 4", () => { + const baseState = {} + const [nextState, patches] = produceWithPatches(baseState, d => { + d.x = 4 + delete d.x + }) + expect(nextState).toEqual({}) + expect(patches).toEqual([]) + // This differs between ES5 and proxy, and ES5 does it better :( + if (useProxies) expect(nextState).not.toBe(baseState) + else expect(nextState).toBe(baseState) + }) + it("cannot produce undefined by returning undefined", () => { const base = 3 expect(produce(base, () => 4)).toBe(4) @@ -1958,11 +2027,11 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) { }) }) it("returns false for objects returned by the producer", () => { - const object = produce(null, Object.create) + const object = produce([], () => Object.create(null)) expect(isDraft(object)).toBeFalsy() }) it("returns false for arrays returned by the producer", () => { - const array = produce(null, _ => []) + const array = produce({}, _ => []) expect(isDraft(array)).toBeFalsy() }) it("returns false for object drafts returned by the producer", () => { @@ -2036,6 +2105,19 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) { expect(patches.length).toBe(0) }) }) + + if (!autoFreeze) { + describe("#613", () => { + const x1 = {} + const y1 = produce(x1, draft => { + draft.foo = produce({bar: "baz"}, draft1 => { + draft1.bar = "baa" + }) + draft.foo.bar = "a" + }) + expect(y1.foo.bar).toBe("a") + }) + } } function testObjectTypes(produce) { @@ -2090,6 +2172,179 @@ function testObjectTypes(produce) { }) }) } + + describe("class with getters", () => { + class State { + [immerable] = true + _bar = {baz: 1} + foo + get bar() { + return this._bar + } + syncFoo() { + const value = this.bar.baz + this.foo = value + this.bar.baz++ + } + } + const state = new State() + + it("should use a method to assing a field using a getter that return a non primitive object", () => { + const newState = produce(state, draft => { + draft.syncFoo() + }) + expect(newState.foo).toEqual(1) + expect(newState.bar).toEqual({baz: 2}) + expect(state.bar).toEqual({baz: 1}) + }) + }) + + describe("super class with getters", () => { + class BaseState { + [immerable] = true + _bar = {baz: 1} + foo + get bar() { + return this._bar + } + syncFoo() { + const value = this.bar.baz + this.foo = value + this.bar.baz++ + } + } + + class State extends BaseState {} + + const state = new State() + + it("should use a method to assing a field using a getter that return a non primitive object", () => { + const newState = produce(state, draft => { + draft.syncFoo() + }) + expect(newState.foo).toEqual(1) + expect(newState.bar).toEqual({baz: 2}) + expect(state.bar).toEqual({baz: 1}) + }) + }) + + describe("class with setters", () => { + class State { + [immerable] = true + _bar = 0 + get bar() { + return this._bar + } + set bar(x) { + this._bar = x + } + } + const state = new State() + + it("should define a field with a setter", () => { + const newState3 = produce(state, d => { + d.bar = 1 + expect(d._bar).toEqual(1) + }) + expect(newState3._bar).toEqual(1) + expect(newState3.bar).toEqual(1) + expect(state._bar).toEqual(0) + expect(state.bar).toEqual(0) + }) + }) + + describe("setter only", () => { + let setterCalled = 0 + class State { + [immerable] = true + x = 0 + set y(value) { + setterCalled++ + this.x = value + } + } + + const state = new State() + const next = produce(state, draft => { + expect(draft.y).toBeUndefined() + draft.y = 2 // setter is inherited, so works + expect(draft.x).toBe(2) + }) + expect(setterCalled).toBe(1) + expect(next.x).toBe(2) + expect(state.x).toBe(0) + }) + + describe("getter only", () => { + let getterCalled = 0 + class State { + [immerable] = true + x = 0 + get y() { + getterCalled++ + return this.x + } + } + + const state = new State() + const next = produce(state, draft => { + expect(draft.y).toBe(0) + expect(() => { + draft.y = 2 + }).toThrow("Cannot set property y") + draft.x = 2 + expect(draft.y).toBe(2) + }) + expect(next.x).toBe(2) + expect(next.y).toBe(2) + expect(state.x).toBe(0) + }) + + describe("own setter only", () => { + let setterCalled = 0 + const state = { + x: 0, + set y(value) { + setterCalled++ + this.x = value + } + } + + const next = produce(state, draft => { + expect(draft.y).toBeUndefined() + // setter is not preserved, so we can write + draft.y = 2 + expect(draft.x).toBe(0) + expect(draft.y).toBe(2) + }) + expect(setterCalled).toBe(0) + expect(next.x).toBe(0) + expect(next.y).toBe(2) + expect(state.x).toBe(0) + }) + + describe("own getter only", () => { + let getterCalled = 0 + const state = { + x: 0, + get y() { + getterCalled++ + return this.x + } + } + + const next = produce(state, draft => { + expect(draft.y).toBe(0) + // de-referenced, so stores it locally + draft.y = 2 + expect(draft.y).toBe(2) + expect(draft.x).toBe(0) + }) + expect(getterCalled).not.toBe(1) + expect(next.x).toBe(0) + expect(next.y).toBe(2) + expect(state.x).toBe(0) + }) } function testLiteralTypes(produce) { @@ -2123,23 +2378,25 @@ function testLiteralTypes(produce) { describe(name, () => { const value = values[name] - it("does not create a draft", () => { - produce(value, draft => { - expect(draft).toBe(value) - }) - }) - - it("returns the base state when no changes are made", () => { - expect(produce(value, () => {})).toBe(value) - }) - if (value && typeof value == "object") { it("does not return a copy when changes are made", () => { - expect( + expect(() => produce(value, draft => { draft.foo = true }) - ).toBe(value) + ).toThrowError( + "produce can only be called on things that are draftable" + ) + }) + } else { + it("does not create a draft", () => { + produce(value, draft => { + expect(draft).toBe(value) + }) + }) + + it("returns the base state when no changes are made", () => { + expect(produce(value, () => {})).toBe(value) }) } }) diff --git a/__tests__/current.js b/__tests__/current.js new file mode 100644 index 00000000..fc1ed18a --- /dev/null +++ b/__tests__/current.js @@ -0,0 +1,206 @@ +import { + setUseProxies, + setAutoFreeze, + enableAllPlugins, + current, + immerable, + isDraft, + produce, + original +} from "../src/immer" + +enableAllPlugins() + +runTests("proxy", true) +runTests("es5", false) + +function runTests(name, useProxies) { + describe("current - " + name, () => { + beforeAll(() => { + setAutoFreeze(true) + setUseProxies(useProxies) + }) + + it("must be called on draft", () => { + expect(() => { + current({}) + }).toThrowError("[Immer] 'current' expects a draft, got: [object Object]") + }) + + it("can handle simple arrays", () => { + const base = [{x: 1}] + let c + const next = produce(base, draft => { + expect(current(draft)).toEqual(base) + draft[0].x++ + c = current(draft) + expect(c).toEqual([{x: 2}]) + expect(Array.isArray(c)) + draft[0].x++ + }) + expect(next).toEqual([{x: 3}]) + expect(c).toEqual([{x: 2}]) + expect(isDraft(c)).toBe(false) + }) + + it("won't freeze", () => { + const base = {x: 1} + const next = produce(base, draft => { + draft.x++ + expect(Object.isFrozen(current(draft))).toBe(false) + }) + }) + + it("returns original without changes", () => { + const base = {} + produce(base, draft => { + expect(original(draft)).toBe(base) + expect(current(draft)).toBe(base) + }) + }) + + it("can handle property additions", () => { + const base = {} + produce(base, draft => { + draft.x = true + const c = current(draft) + expect(c).not.toBe(base) + expect(c).not.toBe(draft) + expect(c).toEqual({ + x: true + }) + }) + }) + + it("can handle property deletions", () => { + const base = { + x: 1 + } + produce(base, draft => { + delete draft.x + const c = current(draft) + expect(c).not.toBe(base) + expect(c).not.toBe(draft) + expect(c).toEqual({}) + }) + }) + + it("won't reflect changes over time", () => { + const base = { + x: 1 + } + produce(base, draft => { + draft.x++ + const c = current(draft) + expect(c).toEqual({ + x: 2 + }) + draft.x++ + expect(c).toEqual({ + x: 2 + }) + }) + }) + + it("will find drafts inside objects", () => { + const base = { + x: 1, + y: { + z: 2 + }, + z: {} + } + produce(base, draft => { + draft.y.z++ + draft.y = { + nested: draft.y + } + const c = current(draft) + expect(c).toEqual({ + x: 1, + y: { + nested: { + z: 3 + } + }, + z: {} + }) + expect(isDraft(c.y.nested)).toBe(false) + expect(c.z).toBe(base.z) + expect(c.y.nested).not.toBe(draft.y.nested) + }) + }) + + it("handles map - 1", () => { + const base = new Map([["a", {x: 1}]]) + produce(base, draft => { + expect(current(draft)).toBe(base) + draft.delete("a") + let c = current(draft) + expect(current(draft)).not.toBe(base) + expect(current(draft)).not.toBe(draft) + expect(c).toEqual(new Map()) + const obj = {} + draft.set("b", obj) + expect(c).toEqual(new Map()) + expect(current(draft)).toEqual(new Map([["b", obj]])) + expect(c).toBeInstanceOf(Map) + }) + }) + + it("handles map - 2", () => { + const base = new Map([["a", {x: 1}]]) + produce(base, draft => { + draft.get("a").x++ + const c = current(draft) + expect(c).not.toBe(base) + expect(c).toEqual(new Map([["a", {x: 2}]])) + draft.get("a").x++ + expect(c).toEqual(new Map([["a", {x: 2}]])) + }) + }) + + it("handles set", () => { + const base = new Set([1]) + produce(base, draft => { + expect(current(draft)).toBe(base) + draft.add(2) + const c = current(draft) + expect(c).toEqual(new Set([1, 2])) + expect(c).not.toBe(draft) + expect(c).not.toBe(base) + draft.add(3) + expect(c).toEqual(new Set([1, 2])) + expect(c).toBeInstanceOf(Set) + }) + }) + + it("handles simple class", () => { + class Counter { + [immerable] = true + current = 0 + + inc() { + this.current++ + } + } + + const counter1 = new Counter() + produce(counter1, draft => { + expect(current(draft)).toBe(counter1) + draft.inc() + const c = current(draft) + expect(c).not.toBe(draft) + expect(c.current).toBe(1) + c.inc() + expect(c.current).toBe(2) + expect(draft.current).toBe(1) + draft.inc() + draft.inc() + expect(c.current).toBe(2) + expect(draft.current).toBe(3) + expect(c).toBeInstanceOf(Counter) + }) + }) + }) +} diff --git a/__tests__/draft.ts b/__tests__/draft.ts index e9423d05..abfe9172 100644 --- a/__tests__/draft.ts +++ b/__tests__/draft.ts @@ -13,15 +13,6 @@ const toDraft: (value: T) => Draft = x => x as any const fromDraft: (draft: Draft) => T = x => x as any test("draft.ts", () => { - // DraftArray - { - // NOTE: As of 3.2.2, everything fails without "extends any" - ;(val: ReadonlyArray) => { - val = _ as Draft - let elem: Value = _ as Draft - } - } - // Tuple { let val: [1, 2] = _ @@ -302,7 +293,7 @@ test("draft.ts", () => { // NOTE: "extends any" only helps a little. const $ = (val: ReadonlyArray) => { let draft: Draft = _ - val = assert(toDraft(val), draft) + assert(toDraft(val), draft) // $ExpectError: [ts] Argument of type 'DraftArray' is not assignable to parameter of type 'Draft'. [2345] // assert(fromDraft(draft), draft) } diff --git a/__tests__/frozen.js b/__tests__/frozen.js index 34d0c3ba..d1571db4 100644 --- a/__tests__/frozen.js +++ b/__tests__/frozen.js @@ -30,13 +30,13 @@ function runTests(name, useProxies) { expect(isFrozen(next.arr)).toBeTruthy() }) - it("never freezes reused state", () => { + it("freezes reused base state", () => { const base = {arr: [1], obj: {a: 1}} const next = produce(base, draft => { draft.arr.push(1) }) expect(next.obj).toBe(base.obj) - expect(isFrozen(next.obj)).toBeFalsy() + expect(isFrozen(next.obj)).toBeTruthy() }) describe("the result is always auto-frozen when", () => { @@ -220,5 +220,29 @@ function runTests(name, useProxies) { }).not.toThrow() expect(state2.ref.state.x).toBe(2) }) + + it("never freezes symbolic fields #590", () => { + const component = {} + const symbol = Symbol("test") + Object.defineProperty(component, symbol, { + value: {x: 1}, + enumerable: true, + writable: true, + configurable: true + }) + + const state = { + x: 1 + } + + const state2 = produce(state, draft => { + draft.ref = component + }) + + expect(() => { + state2.ref[symbol].x++ + }).not.toThrow() + expect(state2.ref[symbol].x).toBe(2) + }) }) } diff --git a/__tests__/manual.js b/__tests__/manual.js index a4ca3ca7..10d1b768 100644 --- a/__tests__/manual.js +++ b/__tests__/manual.js @@ -101,8 +101,7 @@ function runTests(name, useProxies) { expect(res2).toEqual({a: 2, b: 4}) }) - // TODO: fix - it.skip("combines with produce - 2", () => { + it("combines with produce - 2", () => { const state = {a: 1} const res1 = produce(state, draft => { diff --git a/__tests__/original.js b/__tests__/original.js index 84293f3c..6d645401 100644 --- a/__tests__/original.js +++ b/__tests__/original.js @@ -35,17 +35,25 @@ describe("original", () => { }) }) - it("should return undefined for new values on the draft", () => { + it("should throw undefined for new values on the draft", () => { produce(baseState, draftState => { draftState.c = {} draftState.d = 3 - expect(original(draftState.c)).toBeUndefined() - expect(original(draftState.d)).toBeUndefined() + expect(() => original(draftState.c)).toThrowErrorMatchingInlineSnapshot( + `"[Immer] 'original' expects a draft, got: [object Object]"` + ) + expect(() => original(draftState.d)).toThrowErrorMatchingInlineSnapshot( + `"[Immer] 'original' expects a draft, got: 3"` + ) }) }) it("should return undefined for an object that is not proxied", () => { - expect(original({})).toBeUndefined() - expect(original(3)).toBeUndefined() + expect(() => original({})).toThrowErrorMatchingInlineSnapshot( + `"[Immer] 'original' expects a draft, got: [object Object]"` + ) + expect(() => original(3)).toThrowErrorMatchingInlineSnapshot( + `"[Immer] 'original' expects a draft, got: 3"` + ) }) }) diff --git a/__tests__/patch.js b/__tests__/patch.js index 0e7e34f2..8345ab06 100644 --- a/__tests__/patch.js +++ b/__tests__/patch.js @@ -4,7 +4,8 @@ import produce, { applyPatches, produceWithPatches, enableAllPlugins, - isDraft + isDraft, + immerable } from "../src/immer" enableAllPlugins() @@ -299,6 +300,41 @@ describe("renaming properties", () => { ) }) + describe("nested change in object", () => { + runPatchTest( + { + a: {b: 1} + }, + d => { + d.a.b++ + }, + [{op: "replace", path: ["a", "b"], value: 2}], + [{op: "replace", path: ["a", "b"], value: 1}] + ) + }) + + describe("nested change in map", () => { + runPatchTest( + new Map([["a", new Map([["b", 1]])]]), + d => { + d.get("a").set("b", 2) + }, + [{op: "replace", path: ["a", "b"], value: 2}], + [{op: "replace", path: ["a", "b"], value: 1}] + ) + }) + + describe("nested change in array", () => { + runPatchTest( + [[{b: 1}]], + d => { + d[0][0].b++ + }, + [{op: "replace", path: [0, 0, "b"], value: 2}], + [{op: "replace", path: [0, 0, "b"], value: 1}] + ) + }) + describe("nested map (no changes)", () => { runPatchTest( new Map([["a", new Map([["b", 1]])]]), @@ -476,7 +512,12 @@ describe("arrays - prepend", () => { d => { d.x.unshift(4) }, - [{op: "add", path: ["x", 0], value: 4}] + [ + {op: "replace", path: ["x", 0], value: 4}, + {op: "replace", path: ["x", 1], value: 1}, + {op: "replace", path: ["x", 2], value: 2}, + {op: "add", path: ["x", 3], value: 3} + ] ) }) @@ -486,10 +527,14 @@ describe("arrays - multiple prepend", () => { d => { d.x.unshift(4) d.x.unshift(5) + // 4,5,1,2,3 }, [ - {op: "add", path: ["x", 0], value: 5}, - {op: "add", path: ["x", 1], value: 4} + {op: "replace", path: ["x", 0], value: 5}, + {op: "replace", path: ["x", 1], value: 4}, + {op: "replace", path: ["x", 2], value: 1}, + {op: "add", path: ["x", 3], value: 2}, + {op: "add", path: ["x", 4], value: 3} ] ) }) @@ -500,7 +545,10 @@ describe("arrays - splice middle", () => { d => { d.x.splice(1, 1) }, - [{op: "remove", path: ["x", 1]}] + [ + {op: "replace", path: ["x", 1], value: 3}, + {op: "replace", path: ["x", "length"], value: 2} + ] ) }) @@ -509,13 +557,16 @@ describe("arrays - multiple splice", () => { [0, 1, 2, 3, 4, 5, 0], d => { d.splice(4, 2, 3) + // [0,1,2,3,3,0] d.splice(1, 2, 3) + // [0,3,3,3,0] + expect(d.slice()).toEqual([0, 3, 3, 3, 0]) }, [ {op: "replace", path: [1], value: 3}, {op: "replace", path: [2], value: 3}, - {op: "remove", path: [5]}, - {op: "remove", path: [4]} + {op: "replace", path: [4], value: 0}, + {op: "replace", path: ["length"], value: 5} ] ) }) @@ -526,10 +577,11 @@ describe("arrays - modify and shrink", () => { d => { d.x[0] = 4 d.x.length = 2 + // [0, 2] }, [ {op: "replace", path: ["x", 0], value: 4}, - {op: "remove", path: ["x", 2]} + {op: "replace", path: ["x", "length"], value: 2} ], [ {op: "replace", path: ["x", 0], value: 1}, @@ -544,6 +596,7 @@ describe("arrays - prepend then splice middle", () => { d => { d.x.unshift(4) d.x.splice(2, 1) + // 4, 1, 3 }, [ {op: "replace", path: ["x", 0], value: 4}, @@ -558,6 +611,7 @@ describe("arrays - splice middle then prepend", () => { d => { d.x.splice(1, 1) d.x.unshift(4) + // [4, 1, 3] }, [ {op: "replace", path: ["x", 0], value: 4}, @@ -572,10 +626,7 @@ describe("arrays - truncate", () => { d => { d.x.length -= 2 }, - [ - {op: "remove", path: ["x", 2]}, - {op: "remove", path: ["x", 1]} - ], + [{op: "replace", path: ["x", "length"], value: 1}], [ {op: "add", path: ["x", 1], value: 2}, {op: "add", path: ["x", 2], value: 3} @@ -590,14 +641,12 @@ describe("arrays - pop twice", () => { d.x.pop() d.x.pop() }, - [ - {op: "remove", path: ["x", 2]}, - {op: "remove", path: ["x", 1]} - ] + [{op: "replace", path: ["x", "length"], value: 1}] ) }) describe("arrays - push multiple", () => { + // These patches were more optimal pre immer 7, but not always correct runPatchTest( {x: [1, 2, 3]}, d => { @@ -607,47 +656,48 @@ describe("arrays - push multiple", () => { {op: "add", path: ["x", 3], value: 4}, {op: "add", path: ["x", 4], value: 5} ], - [ - {op: "remove", path: ["x", 4]}, - {op: "remove", path: ["x", 3]} - ] + [{op: "replace", path: ["x", "length"], value: 3}] ) }) describe("arrays - splice (expand)", () => { + // These patches were more optimal pre immer 7, but not always correct runPatchTest( {x: [1, 2, 3]}, d => { - d.x.splice(1, 1, 4, 5, 6) + d.x.splice(1, 1, 4, 5, 6) // [1,4,5,6,3] }, [ {op: "replace", path: ["x", 1], value: 4}, - {op: "add", path: ["x", 2], value: 5}, - {op: "add", path: ["x", 3], value: 6} + {op: "replace", path: ["x", 2], value: 5}, + {op: "add", path: ["x", 3], value: 6}, + {op: "add", path: ["x", 4], value: 3} ], [ {op: "replace", path: ["x", 1], value: 2}, - {op: "remove", path: ["x", 3]}, - {op: "remove", path: ["x", 2]} + {op: "replace", path: ["x", 2], value: 3}, + {op: "replace", path: ["x", "length"], value: 3} ] ) }) describe("arrays - splice (shrink)", () => { + // These patches were more optimal pre immer 7, but not always correct runPatchTest( {x: [1, 2, 3, 4, 5]}, d => { - d.x.splice(1, 3, 6) + d.x.splice(1, 3, 6) // [1, 6, 5] }, [ {op: "replace", path: ["x", 1], value: 6}, - {op: "remove", path: ["x", 3]}, - {op: "remove", path: ["x", 2]} + {op: "replace", path: ["x", 2], value: 5}, + {op: "replace", path: ["x", "length"], value: 3} ], [ {op: "replace", path: ["x", 1], value: 2}, - {op: "add", path: ["x", 2], value: 3}, - {op: "add", path: ["x", 3], value: 4} + {op: "replace", path: ["x", 2], value: 3}, + {op: "add", path: ["x", 3], value: 4}, + {op: "add", path: ["x", 4], value: 5} ] ) }) @@ -737,23 +787,25 @@ describe("sets - mutate - 1", () => { }) describe("arrays - splice should should result in remove op.", () => { + // These patches were more optimal pre immer 7, but not always correct runPatchTest( [1, 2], d => { d.splice(1, 1) }, - [{op: "remove", path: [1]}], + [{op: "replace", path: ["length"], value: 1}], [{op: "add", path: [1], value: 2}] ) }) describe("arrays - NESTED splice should should result in remove op.", () => { + // These patches were more optimal pre immer 7, but not always correct runPatchTest( {a: {b: {c: [1, 2]}}}, d => { d.a.b.c.splice(1, 1) }, - [{op: "remove", path: ["a", "b", "c", 1]}], + [{op: "replace", path: ["a", "b", "c", "length"], value: 1}], [{op: "add", path: ["a", "b", "c", 1], value: 2}] ) }) @@ -951,50 +1003,44 @@ test("replaying patches with interweaved replacements should work correctly", () ).toEqual({x: -1}) }) -test.skip("#468", () => { - const item = {id: 1} +describe("#468", () => { + function run() { + const item = {id: 1} + const state = [item] + const [nextState, patches] = produceWithPatches(state, draft => { + draft[0].id = 2 + draft[1] = item + }) - const state = [item] + expect(nextState).toEqual([{id: 2}, {id: 1}]) + expect(patches).toEqual([ + { + op: "replace", + path: [0, "id"], + value: 2 + }, + { + op: "add", + path: [1], + value: { + id: 1 + } + } + ]) - const [nextState, patches] = produceWithPatches(state, draft => { - draft[0].id = 2 - draft[1] = item - }) + const final = applyPatches(state, patches) + expect(final).toEqual(nextState) + } - expect(nextState).toMatchInlineSnapshot(` - Array [ - Object { - "id": 2, - }, - Object { - "id": 1, - }, - ] - `) - expect(patches).toMatchInlineSnapshot(` - Array [ - Object { - "op": "replace", - "path": Array [ - 0, - "id", - ], - "value": 2, - }, - Object { - "op": "add", - "path": Array [ - 0, - ], - "value": Object { - "id": 2, - }, - }, - ] - `) + test("es5", () => { + setUseProxies(false) + run() + }) - const final = applyPatches(state, patches) - expect(final).toEqual(nextState) + test("proxy", () => { + setUseProxies(true) + run() + }) }) test("#521", () => { @@ -1048,3 +1094,28 @@ test("#559 patches works in a nested reducer with proxies", () => { expect(reversedSubState).toMatchObject(state.sub) }) + +describe("#588", () => { + const reference = {value: {num: 53}} + + class Base { + [immerable] = true + get nested() { + return reference.value + } + set nested(value) {} + } + + let base = new Base() + + runPatchTest( + base, + vdraft => { + reference.value = vdraft + produce(base, bdraft => { + bdraft.nested.num = 42 + }) + }, + [{op: "add", path: ["num"], value: 42}] + ) +}) diff --git a/__tests__/produce.ts b/__tests__/produce.ts index d4838521..c927e1ad 100644 --- a/__tests__/produce.ts +++ b/__tests__/produce.ts @@ -97,6 +97,7 @@ it("can infer state type from recipe function with arguments and initial state", it("cannot infer state type when the function type and default state are missing", () => { type Recipe = (state: S) => S const foo = produce((_: any) => {}) + // @ts-expect-error assert(foo, _ as Recipe) }) diff --git a/__tests__/readme.js b/__tests__/readme.js index 524d4e4e..e71ddbe7 100644 --- a/__tests__/readme.js +++ b/__tests__/readme.js @@ -234,3 +234,30 @@ test("Producers can update Maps", () => { // And trying to change a Map outside a producers is going to: NO! expect(() => usersById_v3.clear()).toThrowErrorMatchingSnapshot() }) + +test("clock class", () => { + class Clock { + [immerable] = true + + constructor(hour, minute) { + this.hour = hour + this.minute = minute + } + + get time() { + return `${this.hour}:${this.minute}` + } + + tick() { + return produce(this, draft => { + draft.minute++ + }) + } + } + + const clock1 = new Clock(12, 10) + const clock2 = clock1.tick() + expect(clock1.time).toEqual("12:10") // 12:10 + expect(clock2.time).toEqual("12:11") // 12:11 + expect(clock2).toBeInstanceOf(Clock) +}) diff --git a/docs/api.md b/docs/api.md index 06e499ca..4f0c3ca8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -12,6 +12,7 @@ title: API overview | `castDraft` | Converts any immutable type to its mutable counterpart. This is just a cast and doesn't actually do anything. | [TypeScript](typescript.md) | | `castImmutable` | Converts any mutable type to its immutable counterpart. This is just a cast and doesn't actually do anything. | [TypeScript](typescript.md) | | `createDraft` | Given a base state, creates a mutable draft for which any modifications will be recorded | [Async](async.md) | +| `current` | Given a draft object (doesn't have to be a tree root), takes a snapshot of the current state of the draft | [Current](current.md) | | `Draft` | Exposed TypeScript type to convert an immutable type to a mutable type | [TypeScript](typescript.md) | | `enableAllPlugins()` | Enables all plugins mentioned below | [Installation](installation#pick-your-immer-version) | | `enableES5()` | Enables support for older JavaScript engines, such as Internet Explorer and React Native | [Installation](installation#pick-your-immer-version) | diff --git a/docs/complex-objects.md b/docs/complex-objects.md index 246e33f5..59138e08 100644 --- a/docs/complex-objects.md +++ b/docs/complex-objects.md @@ -1,59 +1,11 @@ --- id: complex-objects -title: Working with Map, Set and classes +title: Classes ---
-_⚠ Since version 6 support for `Map`s and `Set`s has to be enabled explicitly by calling [`enableMapSet()`](installation#pick-your-immer-version) once when starting your application._ - -Plain objects, arrays, `Map`s and `Set`s are always drafted by Immer. An example of using Maps with immer: - -```javascript -test("Producers can update Maps", () => { - const usersById_v1 = new Map() - - const usersById_v2 = produce(usersById_v1, draft => { - // Modifying a map results in a new map - draft.set("michel", {name: "Michel Weststrate", country: "NL"}) - }) - - const usersById_v3 = produce(usersById_v2, draft => { - // Making a change deep inside a map, results in a new map as well! - draft.get("michel").country = "UK" - }) - - // We got a new map each time! - expect(usersById_v2).not.toBe(usersById_v1) - expect(usersById_v3).not.toBe(usersById_v2) - // With different content obviously - expect(usersById_v1).toMatchInlineSnapshot(`Map {}`) - expect(usersById_v2).toMatchInlineSnapshot(` - Map { - "michel" => Object { - "country": "NL", - "name": "Michel Weststrate", - }, - } - `) - expect(usersById_v3).toMatchInlineSnapshot(` - Map { - "michel" => Object { - "country": "UK", - "name": "Michel Weststrate", - }, - } - `) - // The old one was never modified - expect(usersById_v1.size).toBe(0) - // And trying to change a Map outside a producers is going to: NO! - expect(() => usersById_v3.clear()).toThrowErrorMatchingInlineSnapshot( - `"This object has been frozen and should not be mutated"` - ) -}) -``` - -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. +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 import {immerable} from "immer" @@ -69,10 +21,51 @@ class Foo { Foo[immerable] = true // Option 3 ``` -For arrays, only numeric properties and the `length` property can be mutated. Custom properties are not preserved on arrays. +### Example + +```js +import {immerable, produce} from "immer" + +class Clock { + [immerable] = true + + constructor(hour, minute) { + this.hour = hour + this.minute = minute + } + + get time() { + return `${this.hour}:${minute}` + } + + tick() { + return produce(this, draft => { + draft.minute++ + }) + } +} + +const clock1 = new Clock(12, 10) +const clock2 = clock1.tick() +console.log(clock1.time) // 12:10 +console.log(clock2.time) // 12:11 +console.log(clock2 instanceof Clock) // true +``` + +### Semantics in detail + +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. +2. When creating a draft, Immer will copy all _own_ properties from the base to the draft.This includes non-enumerable and symbolic properties. +3. _Own_ getters will be invoked during the copy process, just like `Object.assign` would. +4. Inherited getters and methods will remain as is and be inherited by the draft. +5. Immer will not invoke constructor functions. +6. The final instance will be constructed with the same mechanism as the draft was created. +7. Only getters that have a setter as well will be writable in the draft, as otherwise the value can't be copied back. -When working with `Date` objects, you should always create a new `Date` instance instead of mutating an existing `Date` object. +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. -Maps and Sets that are produced by Immer will be made artificially immutable. This means that they will throw an exception when trying mutative methods like `set`, `clear` etc. outside a producer. +Immer does not support exotic objects such as DOM Nodes or Buffers. -_Note: The **keys** of a map are never drafted! This is done to avoid confusing semantics and keep keys always referentially equal_ +So when working for example with `Date` objects, you should always create a new `Date` instance instead of mutating an existing `Date` object. diff --git a/docs/current.md b/docs/current.md new file mode 100644 index 00000000..20d042c4 --- /dev/null +++ b/docs/current.md @@ -0,0 +1,56 @@ +--- +id: current +title: Extracting the current state from a draft +sidebar_label: Current +--- + +
+ +Immer exposes a named export `current` that create a copy of the current state of the draft. This can be very useful for debugging purposes (as those objects won't be Proxy objects and not be logged as such). Also, references to `current` can be safely leaked from a produce function. Put differently, `current` provides a snapshot of the current state of a draft. + +Objects generated by `current` work similar to the objects created by produce itself. + +1. Unmodified objects will be structurally shared with the original objects. +1. If no changes are made to a draft, generally it holds that `original(draft) === current(draft)`, but this is not guaranteed. +1. Future changes to the draft won't be reflected in the object produced by `current` (except for references to undraftable objects) +1. Unlinke `produce` objects created by `current` will _not_ be frozen. + +Use `current` sparingly, it can be a potentially expensive operation, especially when using ES5. + +Note that `current` cannot be invoked on objects that aren't drafts. + +### Example + +The following example shows the effect of `current` (and `original`): + +```js +const base = { + x: 0 +} + +const next = produce(base, draft => { + draft.x++ + const orig = original(draft) + const copy = current(draft) + console.log(orig.x) + console.log(copy.x) + + setTimeout(() => { + // this will execute after the produce has finised! + console.log(orig.x) + console.log(copy.x) + }, 100) + + draft.x++ + console.log(draft.x) +}) +console.log(next.x) + +// This will print +// 0 (orig.x) +// 1 (copy.x) +// 2 (draft.x) +// 2 (next.x) +// 0 (after timeout, orig.x) +// 1 (after timeout, copy.x) +``` diff --git a/docs/example-reducer.md b/docs/example-reducer.md index 1cc61d61..3ff8ec7e 100644 --- a/docs/example-reducer.md +++ b/docs/example-reducer.md @@ -35,7 +35,7 @@ const byId = (state = {}, action) => { } ``` -After using Immer, that simply becomes: +After using Immer, our reducer can be expressed as: ```javascript import produce from "immer" @@ -50,8 +50,8 @@ const byId = produce((draft, action) => { }, {}) ``` -Notice that it is not needed to handle the default case, a producer that doesn't do anything will simply return the original state. +Notice that it is not necessary to handle the default case, a producer that doesn't do anything will return the original state. Creating Redux reducer is just a sample application of the Immer package. Immer is not just designed to simplify Redux reducers. It can be used in any context where you have an immutable data tree that you want to clone and modify (with structural sharing). -_Note: it might be tempting after using producers for a while, to just place `produce` in your root reducer and then pass the draft to each reducer and work directly over such draft. Don't do that. It kills the point of Redux where each reducer is testable as pure reducer. Immer is best used when applying it to small individual pieces of logic._ +_Note: it might be tempting after using producers for a while, to just place `produce` in your root reducer and then pass the draft to each reducer and work directly over said draft. Don't do that. It removes the benefit of using Redux as a system where each reducer is testable as a pure function. Immer is best used when applied to small, individual pieces of logic._ diff --git a/docs/freezing.md b/docs/freezing.md index daeecbff..7c84e7cd 100644 --- a/docs/freezing.md +++ b/docs/freezing.md @@ -16,4 +16,6 @@ title: Auto freezing Immer automatically freezes any state trees that are modified using `produce`. This protects against accidental modifications of the state tree outside of a producer. This comes with a performance impact, so it is recommended to disable this option in production. By default, it is turned on during local development and turned off in production. Use `setAutoFreeze(true / false)` to explicitly turn this feature on or off. +Immer will never freeze (the contents of) non-enumerable, non-own or symbolic properties, unless their content was drafted. + _⚠️ If auto freezing is enabled, recipes are not entirely side-effect free: Any plain object or array that ends up in the produced result, will be frozen, even when these objects were not frozen before the start of the producer! ⚠️_ diff --git a/docs/installation.md b/docs/installation.md index b9ee2241..a6bea24e 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -12,7 +12,7 @@ Immer can be installed as a direct dependency, and will work in any ES5 environm - CDN: Exposed global is `immer` - Unpkg: `` - JSDelivr: `` - - ⚠️ When using a CDN, it is best to check the url in your browser and see what version it resolves to, so that your users aren't accidentally served a newer version in the future when updates are release. So use an url like: https://unpkg.com/immer@6.0.3/dist/immer.umd.production.min.js instead. Substitute `production.min` with `development` in the URL for a development build. + - ⚠️ When using a CDN, it is best to check the url in your browser and see what version it resolves to, so that your users aren't accidentally served a newer version in the future when updates are release. So use an url like: https://unpkg.com/immer@6.0.3/dist/immer.umd.production.min.js instead. Substitute `production.min` with `development` in the URL for a development build. ## Pick your Immer version @@ -59,12 +59,12 @@ Import size report for immer: ┌───────────────────────┬───────────┬────────────┬───────────┐ │ (index) │ just this │ cumulative │ increment │ ├───────────────────────┼───────────┼────────────┼───────────┤ -│ import * from 'immer' │ 5606 │ 0 │ 0 │ -│ produce │ 3085 │ 3085 │ 0 │ -│ enableES5 │ 3870 │ 3878 │ 793 │ -│ enableMapSet │ 3893 │ 4654 │ 776 │ -│ enablePatches │ 3826 │ 5387 │ 733 │ -│ enableAllPlugins │ 5382 │ 5421 │ 34 │ +│ import * from 'immer' │ 5662 │ 0 │ 0 │ +│ produce │ 3100 │ 3100 │ 0 │ +│ enableES5 │ 3761 │ 3770 │ 670 │ +│ enableMapSet │ 3885 │ 4527 │ 757 │ +│ enablePatches │ 3891 │ 5301 │ 774 │ +│ enableAllPlugins │ 5297 │ 5348 │ 47 │ └───────────────────────┴───────────┴────────────┴───────────┘ (this report was generated by npmjs.com/package/import-size) ``` diff --git a/docs/map-set.md b/docs/map-set.md new file mode 100644 index 00000000..c9e5c624 --- /dev/null +++ b/docs/map-set.md @@ -0,0 +1,58 @@ +--- +id: map-set +title: Map and Set +--- + +
+ +_⚠ Since version 6 support for `Map`s and `Set`s has to be enabled explicitly by calling [`enableMapSet()`](installation#pick-your-immer-version) once when starting your application._ + +Plain objects, arrays, `Map`s and `Set`s are always drafted by Immer. An example of using Maps with immer: + +```javascript +test("Producers can update Maps", () => { + const usersById_v1 = new Map() + + const usersById_v2 = produce(usersById_v1, draft => { + // Modifying a map results in a new map + draft.set("michel", {name: "Michel Weststrate", country: "NL"}) + }) + + const usersById_v3 = produce(usersById_v2, draft => { + // Making a change deep inside a map, results in a new map as well! + draft.get("michel").country = "UK" + }) + + // We got a new map each time! + expect(usersById_v2).not.toBe(usersById_v1) + expect(usersById_v3).not.toBe(usersById_v2) + // With different content obviously + expect(usersById_v1).toMatchInlineSnapshot(`Map {}`) + expect(usersById_v2).toMatchInlineSnapshot(` + Map { + "michel" => Object { + "country": "NL", + "name": "Michel Weststrate", + }, + } + `) + expect(usersById_v3).toMatchInlineSnapshot(` + Map { + "michel" => Object { + "country": "UK", + "name": "Michel Weststrate", + }, + } + `) + // The old one was never modified + expect(usersById_v1.size).toBe(0) + // And trying to change a Map outside a producers is going to: NO! + expect(() => usersById_v3.clear()).toThrowErrorMatchingInlineSnapshot( + `"This object has been frozen and should not be mutated"` + ) +}) +``` + +Maps and Sets that are produced by Immer will be made artificially immutable. This means that they will throw an exception when trying mutative methods like `set`, `clear` etc. outside a producer. + +_Note: The **keys** of a map are never drafted! This is done to avoid confusing semantics and keep keys always referentially equal_ diff --git a/docs/original.md b/docs/original.md index ac62a82d..331dd6ec 100644 --- a/docs/original.md +++ b/docs/original.md @@ -1,6 +1,6 @@ --- id: original -title: Extracting the original object from a proxied instance +title: Extracting the original state from a draft sidebar_label: Original --- @@ -17,7 +17,7 @@ const nextState = produce(baseState, draftState => { }) ``` -Just want to know if a value is a proxied instance? Use the `isDraft` function! +Just want to know if a value is a proxied instance? Use the `isDraft` function! Note that `original` cannot be invoked on objects that aren't drafts. ```js import {isDraft, produce} from "immer" diff --git a/docs/performance.md b/docs/performance.md index 292d873a..5f0bc0bc 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -37,3 +37,9 @@ Most important observation: - Immer is roughly as fast as ImmutableJS. However, the _immutableJS + toJS_ makes clear the cost that often needs to be paid later; converting the immutableJS objects back to plain objects, to be able to pass them to components, over the network etc... (And there is also the upfront cost of converting data received from e.g. the server to immutable JS) - Generating patches doesn't significantly slow down immer - The ES5 fallback implementation is roughly twice as slow as the proxy implementation, in some cases worse. + +## Performance tips + +- When adding a large data set to the state tree in an Immer producer (for example data received from a JSON endpoint), it is worth to cool `Object.freeze(json)` on the root of the data to be added first. This will allow Immer to add the new data to the tree faster, as it will skeeping freezing it, or searching the tree for any changes (drafts) that might be made. +- Immer will convert anything you read in a draft recursively into a draft as well. If you have expensive side effect free operations on a draft that involves a lot of reading, for example finding an index using `find(Index)` in a very large array, you can speed this up by first doing the search, and only call the `produce` function once you know the index. Thereby preventing Immer to turn everything that was searched for in a draft. Or, perform the search on the original value of a draft, by using `original(someDraft)`, which boils to the same thing. +- Always try to pull produce 'up', for example `for (let x of y) produce(base, d => d.push(x))` is exponentially slower than `produce(base, d => { for (let x of y) d.push(x)})` diff --git a/docs/pitfalls.md b/docs/pitfalls.md index 2d6e5f67..a89533db 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -5,13 +5,14 @@ title: Pitfalls
+1. For performance tips, see [Performance Tips](https://immerjs.github.io/immer/docs/performance/#performance-tips). 1. Don't redefine draft like, `draft = myCoolNewState`. Instead, either modify the `draft` or return a new state. See [Returning data from producers](https://immerjs.github.io/immer/docs/return). 1. Immer assumes your state to be a unidirectional tree. That is, no object should appear twice in the tree, and there should be no circular references. 1. Since Immer uses proxies, reading huge amounts of data from state comes with an overhead (especially in the ES5 implementation). If this ever becomes an issue (measure before you optimize!), do the current state analysis before entering the producer function or read from the `currentState` rather than the `draftState`. Also, realize that immer is opt-in everywhere, so it is perfectly fine to manually write super performance critical reducers, and use immer for all the normal ones. Also note that `original` can be used to get the original state of an object, which is cheaper to read. -1. Always try to pull `produce` 'up', for example `for (let x of y) produce(base, d => d.push(x))` is exponentially slower than `produce(base, d => { for (let x of y) d.push(x)})` 1. It is possible to return values from producers, except, it is not possible to return `undefined` that way, as it is indistinguishable from not updating the draft at all! If you want to replace the draft with `undefined`, just return `nothing` from the producer. 1. Immer [does not support exotic objects](https://github.com/immerjs/immer/issues/504) such as window.location. 1. You will need to enable your own classes to work properly with Immer. For docs on the topic, check out the section on [working with complex objects](https://immerjs.github.io/immer/docs/complex-objects). +1. For arrays, only numeric properties and the `length` property can be mutated. Custom properties are not preserved on arrays. 1. Note that data that comes from the closure, and not from the base state, will never be drafted, even when the data has become part of the new draft: ```javascript diff --git a/docs/update-patterns.md b/docs/update-patterns.md new file mode 100644 index 00000000..fce8b556 --- /dev/null +++ b/docs/update-patterns.md @@ -0,0 +1,144 @@ +--- +id: update-patterns +title: Update patterns +--- + +
+ +Working with immutable data, before Immer, used to mean learning all the immutable update patterns. + +To help 'unlearning' those patterns here is an overview how you can leverage the built-in JavaScript APIs to update objects and collections: + +### Object mutations + +```javascript +import produce from "immer" + +const todosObj = { + id1: {done: false, body: "Take out the trash"}, + id2: {done: false, body: "Check Email"} +} + +// add +const addedTodosObj = produce(todosObj, draft => { + draft["id3"] = {done: false, body: "Buy bananas"} +}) + +// delete +const deletedTodosObj = produce(todosObj, draft => { + delete draft["id1"] +}) + +// update +const updatedTodosObj = produce(todosObj, draft => { + draft["id1"].done = true +}) +``` + +### Array mutations + +```javascript +import produce from "immer" + +const todosArray = [ + {id: "id1", done: false, body: "Take out the trash"}, + {id: "id2", done: false, body: "Check Email"} +] + +// add +const addedTodosArray = produce(todosArray, draft => { + draft.push({id: "id3", done: false, body: "Buy bananas"}) +}) + +// delete by index +const deletedTodosArray = produce(todosArray, draft => { + draft.splice(3 /*the index */, 1) +}) + +// update by index +const updatedTodosArray = produce(todosArray, draft => { + draft[3].done = true +}) + +// insert at index +const updatedTodosArray = produce(todosArray, draft => { + draft.splice(3, 0, {id: "id3", done: false, body: "Buy bananas"}) +}) + +// remove last item +const updatedTodosArray = produce(todosArray, draft => { + draft.pop() +}) + +// remove first item +const updatedTodosArray = produce(todosArray, draft => { + draft.shift() +}) + +// add item at the beginning of the array +const addedTodosArray = produce(todosArray, draft => { + draft.unshift({id: "id3", done: false, body: "Buy bananas"}) +}) + +// delete by id +const deletedTodosArray = produce(todosArray, draft => { + const index = draft.findIndex(todo => todo.id === "id1") + if (index !== -1) draft.splice(index, 1) +}) + +// update by id +const updatedTodosArray = produce(todosArray, draft => { + const index = draft.findIndex(todo => todo.id === "id1") + if (index !== -1) draft[index].done = true +}) + +// filtering items +const updatedTodosArray = produce(todosArray, draft => { + // creating a new state is simpler in this example + // (note that we don't need produce in this case, + // but as shown below, if the filter is not on the top + // level produce is still pretty useful) + return draft.filter(todo => todo.done) +}) +``` + +### Nested data structures + +```javascript +import produce from "immer" + +// example complex data structure +const store = { + users: new Map([ + [ + "17", + { + name: "Michel", + todos: [ + { + title: "Get coffee", + done: false + } + ] + } + ] + ]) +} + +// updating something deeply in-an-object-in-an-array-in-a-map-in-an-object: +const nextStore = produce(store, draft => { + draft.users.get("17").todos[0].done = true +}) + +// filtering out all unfinished todo's +const nextStore = produce(store, draft => { + const user = draft.users.get("17") + // when filtering, creating a fresh collection is simpler than + // removing irrelvant items + user.todos = user.todos.filter(todo => todo.done) +}) +``` + +Note that many array operations can be used to insert multiple items at once by passing multiple arguments or using the spread operation: `todos.unshift(...items)`. + +Note that when working with arrays that contain objects that are typically identified by some id, we recommend to use `Map` or index based objects (as shown above) instead of performing frequent find operations, lookup tables perform much better in general. diff --git a/jest.config.js b/jest.config.js index eb63a006..19b709be 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,7 +5,8 @@ module.exports = { "ts-jest": { tsConfig: { noUnusedLocals: false - } + }, + disableSourceMapSupport: true } }, preset: "ts-jest/presets/js-with-ts", diff --git a/package.json b/package.json index 5c526a34..b5523369 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "immer", - "version": "6.0.0-alpha.4", + "version": "7.0.0-beta.0", "description": "Create your next immutable state by mutating the current one", "main": "dist/index.js", "module": "dist/immer.esm.js", @@ -92,7 +92,7 @@ "spec.ts": "^1.1.0", "ts-jest": "^25.2.0", "tsdx": "^0.12.3", - "typescript": "^3.7.3", + "typescript": "^3.9.3", "webpack": "^4.41.6", "webpack-cli": "^3.3.11" } diff --git a/src/core/current.ts b/src/core/current.ts new file mode 100644 index 00000000..d809049d --- /dev/null +++ b/src/core/current.ts @@ -0,0 +1,60 @@ +import { + die, + isDraft, + shallowCopy, + each, + DRAFT_STATE, + get, + set, + ImmerState, + isDraftable, + ArchtypeMap, + ArchtypeSet, + getArchtype, + getPlugin +} from "../internal" + +export function current(value: T): T +export function current(value: any): any { + if (!isDraft(value)) die(22, value) + return currentImpl(value) +} + +function currentImpl(value: any): any { + if (!isDraftable(value)) return value + const state: ImmerState | undefined = value[DRAFT_STATE] + let copy: any + const archType = getArchtype(value) + if (state) { + if ( + !state.modified_ && + (state.type_ < 4 || !getPlugin("ES5").hasChanges_(state as any)) + ) + return state.base_ + // Optimization: avoid generating new drafts during copying + state.finalized_ = true + copy = copyHelper(value, archType) + state.finalized_ = false + } else { + copy = copyHelper(value, archType) + } + + each(copy, (key, childValue) => { + if (state && get(state.base_, key) === childValue) return // no need to copy or search in something that didn't change + set(copy, key, currentImpl(childValue)) + }) + // In the future, we might consider freezing here, based on the current settings + return archType === ArchtypeSet ? new Set(copy) : copy +} + +function copyHelper(value: any, archType: number): any { + // creates a shallow copy, even if it is a map or set + switch (archType) { + case ArchtypeMap: + return new Map(value) + case ArchtypeSet: + // Set will be cloned as array temporarily, so that we can replace individual items + return Array.from(value) + } + return shallowCopy(value) +} diff --git a/src/core/finalize.ts b/src/core/finalize.ts index 200b264f..82f4a728 100644 --- a/src/core/finalize.ts +++ b/src/core/finalize.ts @@ -7,20 +7,18 @@ import { each, has, freeze, - shallowCopy, ImmerState, isDraft, SetState, set, - is, - get, ProxyTypeES5Object, ProxyTypeES5Array, ProxyTypeSet, getPlugin, die, revokeScope, - isFrozen + isFrozen, + shallowCopy } from "../internal" export function processResult(result: any, scope: ImmerScope) { @@ -87,7 +85,7 @@ function finalize(rootScope: ImmerScope, value: any, path?: PatchPath) { const result = // For ES5, create a good copy from the draft first, with added keys and without deleted keys. state.type_ === ProxyTypeES5Object || state.type_ === ProxyTypeES5Array - ? (state.copy_ = shallowCopy(state.draft_, true)) + ? (state.copy_ = shallowCopy(state.draft_)) : state.copy_ // finalize all children of the copy each(result as any, (key, childValue) => @@ -134,12 +132,8 @@ function finalizeProperty( rootScope.canAutoFreeze_ = false } else return } - // Unchanged draft properties are ignored. - if (parentState && is(childValue, get(parentState!.base_, prop))) { - return - } // Search new objects for unfinalized drafts. Frozen objects should never contain drafts. - if (isDraftable(childValue)) { + if (isDraftable(childValue) && !Object.isFrozen(childValue)) { 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 diff --git a/src/core/immerClass.ts b/src/core/immerClass.ts index aeab5746..e6c1bdcb 100644 --- a/src/core/immerClass.ts +++ b/src/core/immerClass.ts @@ -5,7 +5,6 @@ import { Drafted, isDraftable, processResult, - NOTHING, Patch, Objectish, DRAFT_STATE, @@ -14,9 +13,7 @@ import { isDraft, isMap, isSet, - markChangedProxy, createProxyProxy, - freeze, getPlugin, die, hasProxies, @@ -25,7 +22,10 @@ import { revokeScope, leaveScope, usePatchesInScope, - getCurrentScope + getCurrentScope, + NOTHING, + freeze, + current } from "../internal" interface ProducersFns { @@ -115,13 +115,13 @@ export class Immer implements ProducersFns { } usePatchesInScope(scope, patchListener) return processResult(result, scope) - } else { + } else if (!base || typeof base !== "object") { result = recipe(base) if (result === NOTHING) return undefined if (result === undefined) result = base if (this.autoFreeze_) freeze(result, true) return result - } + } else die(21, base) } produceWithPatches(arg1: any, arg2?: any, arg3?: any): any { @@ -140,6 +140,7 @@ export class Immer implements ProducersFns { createDraft(base: T): Draft { if (!isDraftable(base)) die(8) + if (isDraft(base)) base = current(base) const scope = enterScope(this) const proxy = createProxy(this, base, undefined) proxy[DRAFT_STATE].isManual_ = true @@ -151,7 +152,7 @@ export class Immer implements ProducersFns { draft: D, patchListener?: PatchListener ): D extends Draft ? T : never { - const state: ImmerState = draft && draft[DRAFT_STATE] + const state: ImmerState = draft && (draft as any)[DRAFT_STATE] if (__DEV__) { if (!state || !state.isManual_) die(9) if (state.finalized_) die(10) @@ -177,7 +178,7 @@ export class Immer implements ProducersFns { * By default, feature detection is used, so calling this is rarely necessary. */ setUseProxies(value: boolean) { - if (!hasProxies) { + if (value && !hasProxies) { die(20) } this.useProxies_ = value @@ -225,11 +226,3 @@ export function createProxy( scope.drafts_.push(draft) return draft } - -export function markChanged(immer: Immer, state: ImmerState) { - if (immer.useProxies_) { - markChangedProxy(state) - } else { - getPlugin("ES5").markChangedES5_(state) - } -} diff --git a/src/core/proxy.ts b/src/core/proxy.ts index 4c185481..867c779c 100644 --- a/src/core/proxy.ts +++ b/src/core/proxy.ts @@ -1,4 +1,3 @@ -"use strict" import { each, has, @@ -25,16 +24,13 @@ interface ProxyBaseState extends ImmerBaseState { [property: string]: boolean } parent_?: ImmerState - drafts_?: { - [property: string]: Drafted - } revoke_(): void } export interface ProxyObjectState extends ProxyBaseState { type_: typeof ProxyTypeProxyObject - base_: AnyObject - copy_: AnyObject | null + base_: any + copy_: any draft_: Drafted } @@ -73,8 +69,6 @@ export function createProxyProxy( base_: base, // The base proxy. draft_: null as any, // set below - // Any property proxies. - drafts_: {}, // The base copy with any updated values. copy_: null, // Called by the `produce` function. @@ -104,35 +98,30 @@ export function createProxyProxy( /** * Object drafts */ -const objectTraps: ProxyHandler = { +export const objectTraps: ProxyHandler = { get(state, prop) { if (prop === DRAFT_STATE) return state - let {drafts_: drafts} = state - // Check for existing draft in unmodified state. - if (!state.modified_ && has(drafts, prop)) { - return drafts![prop as any] + const source = latest(state) + if (!has(source, prop)) { + // non-existing or non-own property... + return readPropFromProto(state, source, prop) } - - const value = latest(state)[prop] + const value = source[prop] if (state.finalized_ || !isDraftable(value)) { return value } - // Check for existing draft in modified state. - if (state.modified_) { - // Assigned values are never drafted. This catches any drafts we created, too. - if (value !== peek(state.base_, prop)) return value - // Store drafts on the copy (when one exists). - // @ts-ignore - drafts = state.copy_ + // Assigned values are never drafted. This catches any drafts we created, too. + if (value === peek(state.base_, prop)) { + prepareCopy(state) + return (state.copy_![prop as any] = createProxy( + state.scope_.immer_, + value, + state + )) } - - return (drafts![prop as any] = createProxy( - state.scope_.immer_, - value, - state - )) + return value }, has(state, prop) { return prop in latest(state) @@ -141,19 +130,13 @@ const objectTraps: ProxyHandler = { return Reflect.ownKeys(latest(state)) }, set(state, prop: string /* strictly not, but helps TS */, value) { + state.assigned_[prop] = true if (!state.modified_) { - const baseValue = peek(state.base_, prop) - // Optimize based on value's truthiness. Truthy values are guaranteed to - // never be undefined, so we can avoid the `in` operator. Lastly, truthy - // values may be drafts, but falsy values are never drafts. - const isUnchanged = value - ? is(baseValue, value) || value === state.drafts_![prop] - : is(baseValue, value) && prop in state.base_ - if (isUnchanged) return true + if (is(value, peek(latest(state), prop)) && value !== undefined) + return true prepareCopy(state) - markChangedProxy(state) + markChanged(state) } - state.assigned_[prop] = true // @ts-ignore state.copy_![prop] = value return true @@ -163,8 +146,8 @@ const objectTraps: ProxyHandler = { if (peek(state.base_, prop) !== undefined || prop in state.base_) { state.assigned_[prop] = false prepareCopy(state) - markChangedProxy(state) - } else if (state.assigned_[prop]) { + markChanged(state) + } else { // if an originally not assigned property was deleted delete state.assigned_[prop] } @@ -177,12 +160,13 @@ const objectTraps: ProxyHandler = { getOwnPropertyDescriptor(state, prop) { const owner = latest(state) const desc = Reflect.getOwnPropertyDescriptor(owner, prop) - if (desc) { - desc.writable = true - desc.configurable = - state.type_ !== ProxyTypeProxyArray || prop !== "length" + if (!desc) return desc + return { + writable: true, + configurable: state.type_ !== ProxyTypeProxyArray || prop !== "length", + enumerable: desc.enumerable, + value: owner[prop] } - return desc }, defineProperty() { die(11) @@ -216,42 +200,37 @@ arrayTraps.set = function(state, prop, value) { return objectTraps.set!.call(this, state[0], prop, value, state[0]) } -/** - * Map drafts - */ - // Access a property without creating an Immer draft. -function peek(draft: Drafted, prop: PropertyKey): any { +function peek(draft: Drafted, prop: PropertyKey) { const state = draft[DRAFT_STATE] - const desc = Reflect.getOwnPropertyDescriptor( - state ? latest(state) : draft, - prop - ) - return desc && desc.value + const source = state ? latest(state) : draft + return source[prop] } -export function markChangedProxy(state: ImmerState) { +function readPropFromProto(state: ImmerState, source: any, prop: PropertyKey) { + // 'in' checks proto! + if (!(prop in source)) return undefined + let proto = Object.getPrototypeOf(source) + while (proto) { + const desc = Object.getOwnPropertyDescriptor(proto, prop) + // This is a very special case, if the prop is a getter defined by the + // prototype, we should invoke it with the draft as context! + if (desc) return `value` in desc ? desc.value : desc.get?.call(state.draft_) + proto = Object.getPrototypeOf(proto) + } + return undefined +} + +export function markChanged(state: ImmerState) { if (!state.modified_) { state.modified_ = true - if ( - state.type_ === ProxyTypeProxyObject || - state.type_ === ProxyTypeProxyArray - ) { - const copy = (state.copy_ = shallowCopy(state.base_)) - each(state.drafts_!, (key, value) => { - // @ts-ignore - copy[key] = value - }) - state.drafts_ = undefined - } - if (state.parent_) { - markChangedProxy(state.parent_) + markChanged(state.parent_) } } } -function prepareCopy(state: ProxyState) { +export function prepareCopy(state: {base_: any; copy_: any}) { if (!state.copy_) { state.copy_ = shallowCopy(state.base_) } diff --git a/src/immer.ts b/src/immer.ts index 62ee55f7..ea3fc1e1 100644 --- a/src/immer.ts +++ b/src/immer.ts @@ -12,6 +12,7 @@ export { Patch, PatchListener, original, + current, isDraft, isDraftable, NOTHING as nothing, diff --git a/src/internal.ts b/src/internal.ts index 66124585..de7236a6 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -8,3 +8,4 @@ export * from "./core/scope" export * from "./core/finalize" export * from "./core/proxy" export * from "./core/immerClass" +export * from "./core/current" diff --git a/src/plugins/es5.ts b/src/plugins/es5.ts index 2361f560..a0ae53cd 100644 --- a/src/plugins/es5.ts +++ b/src/plugins/es5.ts @@ -1,25 +1,22 @@ import { ImmerState, Drafted, - Objectish, ES5ArrayState, ES5ObjectState, each, has, isDraft, - isDraftable, - shallowCopy, latest, DRAFT_STATE, is, loadPlugin, ImmerScope, - createProxy, ProxyTypeES5Array, ProxyTypeES5Object, - AnyObject, getCurrentScope, - die + die, + markChanged, + objectTraps } from "../internal" type ES5State = ES5ArrayState | ES5ObjectState @@ -30,9 +27,6 @@ export function enableES5() { result: any, isReplaced: boolean ) { - scope.drafts_!.forEach((draft: any) => { - ;(draft[DRAFT_STATE] as ES5State).finalizing_ = true - }) if (!isReplaced) { if (scope.patches_) { markChangesRecursively(scope.drafts_![0]) @@ -49,26 +43,44 @@ export function enableES5() { } } + function createES5Draft(isArray: boolean, base: any) { + // Create a new object / array, where each own property is trapped with an accessor + const descriptors = Object.getOwnPropertyDescriptors(base) + // Descriptors we want to skip: + if (isArray) delete descriptors.length + delete descriptors[DRAFT_STATE as any] + for (let key in descriptors) { + descriptors[key] = proxyProperty( + key, + isArray || !!descriptors[key].enumerable + ) + } + if (isArray) { + const draft = new Array(base.length) + Object.defineProperties(draft, descriptors) + return draft + } else { + return Object.create(Object.getPrototypeOf(base), descriptors) + } + } + function createES5Proxy_( base: T, parent?: ImmerState ): Drafted { const isArray = Array.isArray(base) - const draft: any = clonePotentialDraft(base) - - each(draft, prop => { - proxyProperty(draft, prop, isArray || isEnumerable(base, prop)) - }) + const draft = createES5Draft(isArray, base) const state: ES5ObjectState | ES5ArrayState = { type_: isArray ? ProxyTypeES5Array : (ProxyTypeES5Object as any), scope_: parent ? parent.scope_ : getCurrentScope(), modified_: false, - finalizing_: false, finalized_: false, assigned_: {}, parent_: parent, + // base is the object we are drafting base_: base, + // draft is the draft object itself, that traps all reads and reads from either the base (if unmodified) or copy (if modified) draft_: draft, copy_: null, revoked_: false, @@ -83,94 +95,36 @@ export function enableES5() { return draft } - // Access a property without creating an Immer draft. - function peek(draft: Drafted, prop: PropertyKey) { - const state: ES5State = draft[DRAFT_STATE] - if (state && !state.finalizing_) { - state.finalizing_ = true - const value = draft[prop] - state.finalizing_ = false - return value - } - return draft[prop] - } - - function get(state: ES5State, prop: string | number) { - assertUnrevoked(state) - const value = peek(latest(state), prop) - if (state.finalizing_) return value - // Create a draft if the value is unmodified. - if (value === peek(state.base_, prop) && isDraftable(value)) { - prepareCopy(state) - // @ts-ignore - return (state.copy_![prop] = createProxy( - state.scope_.immer_, - value, - state - )) - } - return value - } - - function set(state: ES5State, prop: string | number, value: any) { - assertUnrevoked(state) - state.assigned_[prop] = true - if (!state.modified_) { - if (is(value, peek(latest(state), prop))) return - markChangedES5_(state) - prepareCopy(state) - } - // @ts-ignore - state.copy_![prop] = value - } - - function markChangedES5_(state: ImmerState) { - if (!state.modified_) { - state.modified_ = true - if (state.parent_) markChangedES5_(state.parent_) - } - } - - function prepareCopy(state: ES5State) { - if (!state.copy_) state.copy_ = clonePotentialDraft(state.base_) - } - - function clonePotentialDraft(base: Objectish) { - const state: ES5State | undefined = base && (base as any)[DRAFT_STATE] - if (state) { - state.finalizing_ = true - const draft = shallowCopy(state.draft_, true) - state.finalizing_ = false - return draft - } - return shallowCopy(base) - } - // property descriptors are recycled to make sure we don't create a get and set closure per property, // but share them all instead const descriptors: {[prop: string]: PropertyDescriptor} = {} function proxyProperty( - draft: Drafted, prop: string | number, enumerable: boolean - ) { + ): PropertyDescriptor { let desc = descriptors[prop] if (desc) { desc.enumerable = enumerable } else { descriptors[prop] = desc = { - // configurable: true, + configurable: true, enumerable, get(this: any) { - return get(this[DRAFT_STATE], prop) + const state = this[DRAFT_STATE] + if (__DEV__) assertUnrevoked(state) + // @ts-ignore + return objectTraps.get(state, prop) }, set(this: any, value) { - set(this[DRAFT_STATE], prop, value) + const state = this[DRAFT_STATE] + if (__DEV__) assertUnrevoked(state) + // @ts-ignore + objectTraps.set(state, prop, value) } } } - Object.defineProperty(draft, prop, desc) + return desc } // This looks expensive, but only proxies are visited, and only objects without known changes are scanned. @@ -184,10 +138,10 @@ export function enableES5() { if (!state.modified_) { switch (state.type_) { case ProxyTypeES5Array: - if (hasArrayChanges(state)) markChangedES5_(state) + if (hasArrayChanges(state)) markChanged(state) break case ProxyTypeES5Object: - if (hasObjectChanges(state)) markChangedES5_(state) + if (hasObjectChanges(state)) markChanged(state) break } } @@ -201,7 +155,6 @@ export function enableES5() { const {base_, draft_, assigned_, type_} = state if (type_ === ProxyTypeES5Object) { // Look for added keys. - // TODO: looks quite duplicate to hasObjectChanges, // probably there is a faster way to detect changes, as sweep + recurse seems to do some // unnecessary work. // also: probably we can store the information we detect here, to speed up tree finalization! @@ -210,7 +163,7 @@ export function enableES5() { // The `undefined` check is a fast path for pre-existing keys. if ((base_ as any)[key] === undefined && !has(base_, key)) { assigned_[key] = true - markChangedES5_(state) + markChanged(state) } else if (!assigned_[key]) { // Only untouched properties trigger recursion. markChangesRecursively(draft_[key]) @@ -221,12 +174,12 @@ export function enableES5() { // The `undefined` check is a fast path for pre-existing keys. if (draft_[key] === undefined && !has(draft_, key)) { assigned_[key] = false - markChangedES5_(state) + markChanged(state) } }) } else if (type_ === ProxyTypeES5Array) { if (hasArrayChanges(state as ES5ArrayState)) { - markChangedES5_(state) + markChanged(state) assigned_.length = true } @@ -295,10 +248,10 @@ export function enableES5() { return false } - /*#__PURE__*/ - function isEnumerable(base: AnyObject, prop: PropertyKey): boolean { - const desc = Object.getOwnPropertyDescriptor(base, prop) - return desc && desc.enumerable ? true : false + function hasChanges_(state: ES5State) { + return state.type_ === ProxyTypeES5Object + ? hasObjectChanges(state) + : hasArrayChanges(state) } function assertUnrevoked(state: any /*ES5State | MapState | SetState*/) { @@ -307,7 +260,7 @@ export function enableES5() { loadPlugin("ES5", { createES5Proxy_, - markChangedES5_, - willFinalizeES5_ + willFinalizeES5_, + hasChanges_ }) } diff --git a/src/plugins/mapset.ts b/src/plugins/mapset.ts index a2a5d922..8e5b111c 100644 --- a/src/plugins/mapset.ts +++ b/src/plugins/mapset.ts @@ -82,7 +82,7 @@ export function enableMapSet() { assertUnrevoked(state) if (latest(state).get(key) !== value) { prepareMapCopy(state) - markChanged(state.scope_.immer_, state) + markChanged(state) state.assigned_!.set(key, true) state.copy_!.set(key, value) state.assigned_!.set(key, true) @@ -98,7 +98,7 @@ export function enableMapSet() { const state: MapState = this[DRAFT_STATE] assertUnrevoked(state) prepareMapCopy(state) - markChanged(state.scope_.immer_, state) + markChanged(state) state.assigned_!.set(key, false) state.copy_!.delete(key) return true @@ -108,7 +108,7 @@ export function enableMapSet() { const state: MapState = this[DRAFT_STATE] assertUnrevoked(state) prepareMapCopy(state) - markChanged(state.scope_.immer_, state) + markChanged(state) state.assigned_ = new Map() return state.copy_!.clear() } @@ -243,7 +243,7 @@ export function enableMapSet() { assertUnrevoked(state) if (!this.has(value)) { prepareSetCopy(state) - markChanged(state.scope_.immer_, state) + markChanged(state) state.copy_!.add(value) } return this @@ -257,7 +257,7 @@ export function enableMapSet() { const state: SetState = this[DRAFT_STATE] assertUnrevoked(state) prepareSetCopy(state) - markChanged(state.scope_.immer_, state) + markChanged(state) return ( state.copy_!.delete(value) || (state.drafts_.has(value) @@ -270,7 +270,7 @@ export function enableMapSet() { const state: SetState = this[DRAFT_STATE] assertUnrevoked(state) prepareSetCopy(state) - markChanged(state.scope_.immer_, state) + markChanged(state) return state.copy_!.clear() } diff --git a/src/plugins/patches.ts b/src/plugins/patches.ts index fe26cf73..6470c9d5 100644 --- a/src/plugins/patches.ts +++ b/src/plugins/patches.ts @@ -24,9 +24,9 @@ import { ArchtypeMap, ArchtypeSet, ArchtypeArray, - die + die, + isDraft } from "../internal" -import {isDraft} from "../utils/common" export function enablePatches() { const REPLACE = "replace" @@ -78,22 +78,8 @@ export function enablePatches() { ;[patches, inversePatches] = [inversePatches, patches] } - const delta = copy_.length - base_.length - - // Find the first replaced index. - let start = 0 - while (base_[start] === copy_[start] && start < base_.length) { - ++start - } - - // Find the last replaced index. Search from the end to optimize splice patches. - let end = base_.length - while (end > start && base_[end - 1] === copy_[end + delta - 1]) { - --end - } - // Process replaced indices. - for (let i = start; i < end; ++i) { + for (let i = 0; i < base_.length; i++) { if (assigned_[i] && copy_[i] !== base_[i]) { const path = basePath.concat([i]) patches.push({ @@ -111,21 +97,22 @@ export function enablePatches() { } } - const replaceCount = patches.length - // Process added indices. - for (let i = end + delta - 1; i >= end; --i) { + for (let i = base_.length; i < copy_.length; i++) { const path = basePath.concat([i]) - patches[replaceCount + i - end] = { + patches.push({ op: ADD, path, // Need to maybe clone it, as it can in fact be the original value // due to the base/copy inversion at the start of this function value: clonePatchValueIfNeeded(copy_[i]) - } + }) + } + if (base_.length < copy_.length) { inversePatches.push({ - op: REMOVE, - path + op: REPLACE, + path: basePath.concat(["length"]), + value: base_.length }) } } diff --git a/src/types/index.js.flow b/src/types/index.js.flow index 01a17602..263585a0 100644 --- a/src/types/index.js.flow +++ b/src/types/index.js.flow @@ -1,42 +1,42 @@ // @flow export interface Patch { - op: "replace" | "remove" | "add", - path: (string|number)[], - value?: any + op: "replace" | "remove" | "add"; + path: (string | number)[]; + value?: any; } export type PatchListener = (patches: Patch[], inversePatches: Patch[]) => void -type Base = {...} | Array | Map | Set; +type Base = {...} | Array | Map | Set interface IProduce { - /** - * Immer takes a state, and runs a function against it. - * That function can freely mutate the state, as it will create copies-on-write. - * This means that the original state will stay unchanged, and once the function finishes, the modified state is returned. - * - * If the first argument is a function, this is interpreted as the recipe, and will create a curried function that will execute the recipe - * any time it is called with the current state. - * - * @param currentState - the state to start with - * @param recipe - function that receives a proxy of the current state as first argument and which can be freely modified - * @param initialState - if a curried function is created and this argument was given, it will be used as fallback if the curried function is called with a state of undefined - * @returns The next state: a new state, or the current state if nothing was modified - */ - ( - currentState: S, - recipe: (draftState: S) => S | void, - patchListener?: PatchListener - ): S; - // curried invocations with inital state - ( - recipe: (draftState: S, a: A, b: B, c: C, ...extraArgs: any[]) => S | void, - initialState: S - ): (currentState: S | void, a: A, b: B, c: C, ...extraArgs: any[]) => S; - // curried invocations without inital state - ( - recipe: (draftState: S, a: A, b: B, c: C, ...extraArgs: any[]) => S | void - ): (currentState: S, a: A, b: B, c: C, ...extraArgs: any[]) => S; + /** + * Immer takes a state, and runs a function against it. + * That function can freely mutate the state, as it will create copies-on-write. + * This means that the original state will stay unchanged, and once the function finishes, the modified state is returned. + * + * If the first argument is a function, this is interpreted as the recipe, and will create a curried function that will execute the recipe + * any time it is called with the current state. + * + * @param currentState - the state to start with + * @param recipe - function that receives a proxy of the current state as first argument and which can be freely modified + * @param initialState - if a curried function is created and this argument was given, it will be used as fallback if the curried function is called with a state of undefined + * @returns The next state: a new state, or the current state if nothing was modified + */ + ( + currentState: S, + recipe: (draftState: S) => S | void, + patchListener?: PatchListener + ): S; + // curried invocations with inital state + ( + recipe: (draftState: S, a: A, b: B, c: C, ...extraArgs: any[]) => S | void, + initialState: S + ): (currentState: S | void, a: A, b: B, c: C, ...extraArgs: any[]) => S; + // curried invocations without inital state + ( + recipe: (draftState: S, a: A, b: B, c: C, ...extraArgs: any[]) => S | void + ): (currentState: S, a: A, b: B, c: C, ...extraArgs: any[]) => S; } declare export var produce: IProduce @@ -62,18 +62,20 @@ declare export function setUseProxies(useProxies: boolean): void declare export function applyPatches(state: S, patches: Patch[]): S -declare export function original(value: S): ?S +declare export function original(value: S): S + +declare export function current(value: S): S declare export function isDraft(value: any): boolean /** - * Creates a mutable draft from an (immutable) object / array. + * Creates a mutable draft from an (immutable) object / array. * The draft can be modified until `finishDraft` is called */ declare export function createDraft(base: T): T /** - * Given a draft that was created using `createDraft`, + * Given a draft that was created using `createDraft`, * finalizes the draft into a new immutable object. * Optionally a patch-listener can be provided to gather the patches that are needed to construct the object. */ diff --git a/src/utils/common.ts b/src/utils/common.ts index fa1ea8a5..43545d0f 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -5,7 +5,6 @@ import { Objectish, Drafted, AnyObject, - AnyArray, AnyMap, AnySet, ImmerState, @@ -48,10 +47,8 @@ export function isPlainObject(value: any): boolean { /*#__PURE__*/ export function original(value: T): T | undefined export function original(value: Drafted): any { - if (value && value[DRAFT_STATE]) { - return value[DRAFT_STATE].base_ as any - } - // otherwise return undefined + if (!isDraft(value)) die(23, value) + return value[DRAFT_STATE].base_ } /*#__PURE__*/ @@ -72,9 +69,9 @@ export function each( ): void export function each(obj: any, iter: any, enumerableOnly = false) { if (getArchtype(obj) === ArchtypeObject) { - ;(enumerableOnly ? Object.keys : ownKeys)(obj).forEach(key => - iter(key, obj[key], obj) - ) + ;(enumerableOnly ? Object.keys : ownKeys)(obj).forEach(key => { + if (!enumerableOnly || typeof key !== "symbol") iter(key, obj[key], obj) + }) } else { obj.forEach((entry: any, index: any) => iter(index, entry, obj)) } @@ -145,38 +142,32 @@ export function latest(state: ImmerState): any { } /*#__PURE__*/ -export function shallowCopy( - base: T, - invokeGetters?: boolean -): T -export function shallowCopy(base: any, invokeGetters = false) { +export function shallowCopy(base: any) { if (Array.isArray(base)) return base.slice() - const clone = Object.create(Object.getPrototypeOf(base)) - each(base, (key: any) => { - if (key === DRAFT_STATE) { - return // Never copy over draft state. - } - const desc = Object.getOwnPropertyDescriptor(base, key)! - let {value} = desc - if (desc.get) { - if (!invokeGetters) die(1) - value = desc.get.call(base) + const descriptors = Object.getOwnPropertyDescriptors(base) + delete descriptors[DRAFT_STATE as any] + for (let key in descriptors) { + const desc = descriptors[key] + if (desc.writable === false) { + desc.writable = true + desc.configurable = true } - if (desc.enumerable) { - clone[key] = value - } else { - Object.defineProperty(clone, key, { - value, - writable: true, - configurable: true - }) - } - }) - return clone + // 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 Object.create(Object.getPrototypeOf(base), descriptors) } export function freeze(obj: any, deep: boolean): void { - if (isDraft(obj) || isFrozen(obj) || !isDraftable(obj)) return + if (Object.isFrozen(obj) || isDraft(obj) || !isDraftable(obj)) return if (getArchtype(obj) > 1 /* Map or Set */) { obj.set = obj.add = obj.clear = obj.delete = dontMutateFrozenCollections as any } diff --git a/src/utils/errors.ts b/src/utils/errors.ts index dcccd8dd..bdb27a30 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -29,10 +29,17 @@ const errors = { 18(plugin: string) { return `The plugin for '${plugin}' has not been loaded into Immer. To enable the plugin, import and call \`enable${plugin}()\` when initializing your application.` }, - 19(plugin: string) { - return "plugin not loaded: " + plugin + 19: "plugin not loaded", + 20: "Cannot use proxies if Proxy, Proxy.revocable or Reflect are not available", + 21(thing: string) { + return `produce can only be called on things that are draftable: plain objects, arrays, Map, Set or classes that are marked with '[immerable]: true'. Got '${thing}'` }, - 20: "Cannot use proxies if Proxy, Proxy.revocable or Reflect are not available" + 22(thing: string) { + return `'current' expects a draft, got: ${thing}` + }, + 23(thing: string) { + return `'original' expects a draft, got: ${thing}` + } } as const export function die(error: keyof typeof errors, ...args: any[]): never { diff --git a/src/utils/plugins.ts b/src/utils/plugins.ts index 9c9902d2..a6f7d3e9 100644 --- a/src/utils/plugins.ts +++ b/src/utils/plugins.ts @@ -5,7 +5,6 @@ import { Drafted, AnyObject, ImmerBaseState, - AnyArray, AnyMap, AnySet, ProxyTypeES5Array, @@ -38,7 +37,7 @@ const plugins: { base: T, parent?: ImmerState ): Drafted - markChangedES5_(state: ImmerState): void + hasChanges_(state: ES5ArrayState | ES5ObjectState): boolean } MapSet?: { proxyMap_(target: T, parent?: ImmerState): T @@ -69,7 +68,6 @@ export function loadPlugin( /** ES5 Plugin */ interface ES5BaseState extends ImmerBaseState { - finalizing_: boolean assigned_: {[key: string]: any} parent_?: ImmerState revoked_: boolean @@ -85,8 +83,8 @@ export interface ES5ObjectState extends ES5BaseState { export interface ES5ArrayState extends ES5BaseState { type_: typeof ProxyTypeES5Array draft_: Drafted - base_: AnyArray - copy_: AnyArray | null + base_: any + copy_: any } /** Map / Set plugin */ diff --git a/website/i18n/en.json b/website/i18n/en.json index 6f7c873b..6e64893a 100644 --- a/website/i18n/en.json +++ b/website/i18n/en.json @@ -16,7 +16,11 @@ "title": "Built with Immer" }, "complex-objects": { - "title": "Working with Map, Set and classes" + "title": "Classes" + }, + "current": { + "title": "Extracting the current state from a draft", + "sidebar_label": "Current" }, "curried-produce": { "title": "Curried producers" @@ -41,8 +45,11 @@ "title": "Introduction to Immer", "sidebar_label": "Introduction" }, + "map-set": { + "title": "Map and Set" + }, "original": { - "title": "Extracting the original object from a proxied instance", + "title": "Extracting the original state from a draft", "sidebar_label": "Original" }, "patches": { @@ -69,6 +76,9 @@ "typescript": { "title": "Using TypeScript or Flow", "sidebar_label": "TypeScript / Flow" + }, + "update-patterns": { + "title": "Update patterns" } }, "links": { diff --git a/website/sidebars.json b/website/sidebars.json index 9d1c04c0..c20a4614 100755 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -1,7 +1,7 @@ { "Immer": { - "Basics": ["introduction", "installation", "produce", "curried-produce", "example-reducer", "example-setstate"], - "Advanced Features": ["api", "typescript", "return", "patches", "freezing", "original", "async", "complex-objects"], + "Basics": ["introduction", "installation", "produce", "curried-produce", "example-reducer", "example-setstate", "update-patterns"], + "Advanced Features": ["api", "map-set", "complex-objects", "current", "original", "patches", "freezing", "return", "async", "typescript"], "Resources": ["performance", "resources", "faq", "pitfalls", "built-with", "support"] } } diff --git a/yarn.lock b/yarn.lock index 5f80f911..0c9a5c73 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11701,6 +11701,11 @@ typescript@^3.7.3: resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.7.5.tgz#0692e21f65fd4108b9330238aac11dd2e177a1ae" integrity sha512-/P5lkRXkWHNAbcJIiHPfRoKqyd7bsyCma1hZNUGfn20qm64T6ZBlrzprymeu918H+mB/0rIg2gGK/BXkhhYgBw== +typescript@^3.9.3: + version "3.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.3.tgz#d3ac8883a97c26139e42df5e93eeece33d610b8a" + integrity sha512-D/wqnB2xzNFIcoBG9FG8cXRDjiqSTbG2wd8DMZeQyJlP1vfTkIxH4GKveWaEBYySKIg+USu+E+EDIR47SqnaMQ== + uglify-js@^3.1.4: version "3.7.7" resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.7.7.tgz#21e52c7dccda80a53bf7cde69628a7e511aec9c9"