From a9c0fd02c7575cfa6e61563e50a51ccc71949d9d Mon Sep 17 00:00:00 2001 From: use-tabs Date: Thu, 27 Jun 2019 12:15:12 -0400 Subject: [PATCH] style: use tabs Tabs allow for custom indentation per user --- .prettierrc | 3 +- __tests__/base.js | 2482 +++++++++++++++++++------------------- __tests__/curry.js | 114 +- __tests__/draft.ts | 286 ++--- __tests__/frozen.js | 248 ++-- __tests__/hooks.js | 404 +++---- __tests__/immutable.ts | 36 +- __tests__/manual.js | 254 ++-- __tests__/null-base.js | 10 +- __tests__/original.js | 88 +- __tests__/patch.js | 674 +++++------ __tests__/polyfills.js | 84 +- __tests__/produce.ts | 652 +++++----- __tests__/readme.js | 332 ++--- __tests__/test-data.json | 101 +- __tests__/tsconfig.json | 10 +- src/common.js | 150 +-- src/es5.js | 396 +++--- src/immer.d.ts | 252 ++-- src/immer.js | 511 ++++---- src/index.js | 10 +- src/patches.js | 230 ++-- src/proxy.js | 274 ++--- src/scope.js | 56 +- 24 files changed, 3811 insertions(+), 3846 deletions(-) diff --git a/.prettierrc b/.prettierrc index 0824594e..74a4d8ba 100644 --- a/.prettierrc +++ b/.prettierrc @@ -5,9 +5,8 @@ "requirePragma": false, "semi": false, "singleQuote": false, - "tabWidth": 4, "trailingComma": "none", - "useTabs": false, + "useTabs": true, "overrides": [ { "files": [".prettierrc", "*.json"], diff --git a/__tests__/base.js b/__tests__/base.js index c490618f..fe684828 100644 --- a/__tests__/base.js +++ b/__tests__/base.js @@ -8,7 +8,7 @@ import * as lodash from "lodash" jest.setTimeout(1000) test("immer should have no dependencies", () => { - expect(require("../package.json").dependencies).toBeUndefined() + expect(require("../package.json").dependencies).toBeUndefined() }) runBaseTest("proxy (no freeze)", true, false) @@ -19,1256 +19,1250 @@ runBaseTest("es5 (autofreeze)", false, true) runBaseTest("es5 (autofreeze)(patch listener)", false, true, true) function runBaseTest(name, useProxies, autoFreeze, useListener) { - const listener = useListener ? function() {} : undefined - const {produce} = createPatchedImmer({ - useProxies, - autoFreeze - }) - - // When `useListener` is true, append a function to the arguments of every - // uncurried `produce` call in every test. This makes tests easier to read. - function createPatchedImmer(options) { - const immer = new Immer(options) - - const {produce} = immer - immer.produce = (...args) => - typeof args[1] === "function" && args.length < 3 - ? produce(...args, listener) - : produce(...args) - - return immer - } - - describe(`base functionality - ${name}`, () => { - let baseState - let origBaseState - - beforeEach(() => { - origBaseState = baseState = createBaseState() - }) - - it("returns the original state when no changes are made", () => { - const nextState = produce(baseState, s => { - expect(s.aProp).toBe("hi") - expect(s.anObject.nested).toMatchObject({yummie: true}) - }) - expect(nextState).toBe(baseState) - }) - - it("does structural sharing", () => { - const random = Math.random() - const nextState = produce(baseState, s => { - s.aProp = random - }) - expect(nextState).not.toBe(baseState) - expect(nextState.aProp).toBe(random) - expect(nextState.nested).toBe(baseState.nested) - }) - - it("deep change bubbles up", () => { - const nextState = produce(baseState, s => { - s.anObject.nested.yummie = false - }) - expect(nextState).not.toBe(baseState) - expect(nextState.anObject).not.toBe(baseState.anObject) - expect(baseState.anObject.nested.yummie).toBe(true) - expect(nextState.anObject.nested.yummie).toBe(false) - expect(nextState.anArray).toBe(baseState.anArray) - }) - - it("can add props", () => { - const nextState = produce(baseState, s => { - s.anObject.cookie = {tasty: true} - }) - expect(nextState).not.toBe(baseState) - expect(nextState.anObject).not.toBe(baseState.anObject) - expect(nextState.anObject.nested).toBe(baseState.anObject.nested) - expect(nextState.anObject.cookie).toEqual({tasty: true}) - }) - - it("can delete props", () => { - const nextState = produce(baseState, s => { - delete s.anObject.nested - }) - expect(nextState).not.toBe(baseState) - expect(nextState.anObject).not.toBe(baseState.anObject) - expect(nextState.anObject.nested).toBe(undefined) - }) - - // Found by: https://github.com/mweststrate/immer/pull/267 - it("can delete props added in the producer", () => { - const nextState = produce(baseState, s => { - s.anObject.test = true - delete s.anObject.test - }) - if (useProxies) { - expect(nextState).not.toBe(baseState) - expect(nextState).toEqual(baseState) - } else { - // The copy is avoided in ES5. - expect(nextState).toBe(baseState) - } - }) - - // Found by: https://github.com/mweststrate/immer/issues/328 - it("can set a property that was just deleted", () => { - const baseState = {a: 1} - const nextState = produce(baseState, s => { - delete s.a - s.a = 2 - }) - expect(nextState.a).toBe(2) - }) - - it("can set a property to its original value after deleting it", () => { - const baseState = {a: {b: 1}} - const nextState = produce(baseState, s => { - const a = s.a - delete s.a - s.a = a - }) - if (useProxies) { - expect(nextState).not.toBe(baseState) - expect(nextState).toEqual(baseState) - } else { - // The copy is avoided in ES5. - expect(nextState).toBe(baseState) - } - }) - - it("can get property descriptors", () => { - const getDescriptor = Object.getOwnPropertyDescriptor - const baseState = deepFreeze([{a: 1}]) - produce(baseState, arr => { - const obj = arr[0] - const desc = { - configurable: true, - enumerable: true, - ...(useProxies && {writable: true}) - } - - // Known property - expect(getDescriptor(obj, "a")).toMatchObject(desc) - expect(getDescriptor(arr, 0)).toMatchObject(desc) - - // Deleted property - delete obj.a - arr.pop() - expect(getDescriptor(obj, "a")).toBeUndefined() - expect(getDescriptor(arr, 0)).toBeUndefined() - - // Unknown property - expect(getDescriptor(obj, "b")).toBeUndefined() - expect(getDescriptor(arr, 100)).toBeUndefined() - - // Added property - obj.b = 2 - arr[100] = 1 - expect(getDescriptor(obj, "b")).toBeDefined() - expect(getDescriptor(arr, 100)).toBeDefined() - }) - }) - - describe("array drafts", () => { - it("supports Array.isArray()", () => { - const nextState = produce(baseState, s => { - expect(Array.isArray(s.anArray)).toBeTruthy() - s.anArray.push(1) - }) - expect(Array.isArray(nextState.anArray)).toBeTruthy() - }) - - it("supports index access", () => { - const value = baseState.anArray[0] - const nextState = produce(baseState, s => { - expect(s.anArray[0]).toBe(value) - }) - expect(nextState).toBe(baseState) - }) - - it("supports iteration", () => { - const base = [{id: 1, a: 1}, {id: 2, a: 1}] - const findById = (collection, id) => { - for (const item of collection) { - if (item.id === id) return item - } - return null - } - const result = produce(base, draft => { - const obj1 = findById(draft, 1) - const obj2 = findById(draft, 2) - obj1.a = 2 - obj2.a = 2 - }) - expect(result[0].a).toEqual(2) - expect(result[1].a).toEqual(2) - }) - - it("can assign an index via bracket notation", () => { - const nextState = produce(baseState, s => { - s.anArray[3] = true - }) - expect(nextState).not.toBe(baseState) - expect(nextState.anArray).not.toBe(baseState.anArray) - expect(nextState.anArray[3]).toEqual(true) - }) - - it("can use splice() to both add and remove items", () => { - const nextState = produce(baseState, s => { - s.anArray.splice(1, 1, "a", "b") - }) - expect(nextState.anArray).not.toBe(baseState.anArray) - expect(nextState.anArray[1]).toBe("a") - expect(nextState.anArray[2]).toBe("b") - }) - - it("can truncate via the length property", () => { - const baseLength = baseState.anArray.length - const nextState = produce(baseState, s => { - s.anArray.length = baseLength - 1 - }) - expect(nextState.anArray).not.toBe(baseState.anArray) - expect(nextState.anArray.length).toBe(baseLength - 1) - }) - - it("can extend via the length property", () => { - const baseLength = baseState.anArray.length - const nextState = produce(baseState, s => { - s.anArray.length = baseLength + 1 - }) - expect(nextState.anArray).not.toBe(baseState.anArray) - expect(nextState.anArray.length).toBe(baseLength + 1) - }) - - // Reported here: https://github.com/mweststrate/immer/issues/116 - it("can pop then push", () => { - const nextState = produce([1, 2, 3], s => { - s.pop() - s.push(100) - }) - expect(nextState).toEqual([1, 2, 100]) - }) - - it("can be sorted", () => { - const baseState = [3, 1, 2] - const nextState = produce(baseState, s => { - s.sort() - }) - expect(nextState).not.toBe(baseState) - expect(nextState).toEqual([1, 2, 3]) - }) - - it("supports modifying nested objects", () => { - const baseState = [{a: 1}, {}] - const nextState = produce(baseState, s => { - s[0].a++ - s[1].a = 0 - }) - expect(nextState).not.toBe(baseState) - expect(nextState[0].a).toBe(2) - expect(nextState[1].a).toBe(0) - }) - - it("never preserves non-numeric properties", () => { - const baseState = [] - baseState.x = 7 - const nextState = produce(baseState, s => { - s.push(3) - }) - expect("x" in nextState).toBeFalsy() - }) - - if (useProxies) { - it("throws when a non-numeric property is added", () => { - expect(() => { - produce([], d => { - d.x = 3 - }) - }).toThrowErrorMatchingSnapshot() - }) - - it("throws when a non-numeric property is deleted", () => { - expect(() => { - const baseState = [] - baseState.x = 7 - produce(baseState, d => { - delete d.x - }) - }).toThrowErrorMatchingSnapshot() - }) - } - }) - - it("supports `immerable` symbol on constructor", () => { - class One {} - One[immerable] = true - const baseState = new One() - const nextState = produce(baseState, draft => { - expect(draft).not.toBe(baseState) - draft.foo = true - }) - expect(nextState).not.toBe(baseState) - expect(nextState.foo).toBeTruthy() - }) - - it("preserves symbol properties", () => { - const test = Symbol("test") - const baseState = {[test]: true} - const nextState = produce(baseState, s => { - expect(s[test]).toBeTruthy() - s.foo = true - }) - expect(nextState).toEqual({ - [test]: true, - foo: true - }) - }) - - it("preserves non-enumerable properties", () => { - const baseState = {} - // Non-enumerable object property - Object.defineProperty(baseState, "foo", { - value: {a: 1}, - enumerable: false - }) - // Non-enumerable primitive property - Object.defineProperty(baseState, "bar", { - value: 1, - enumerable: false - }) - const nextState = produce(baseState, s => { - expect(s.foo).toBeTruthy() - expect(isEnumerable(s, "foo")).toBeFalsy() - s.bar++ - expect(isEnumerable(s, "foo")).toBeFalsy() - s.foo.a++ - expect(isEnumerable(s, "foo")).toBeFalsy() - }) - expect(nextState.foo).toBeTruthy() - expect(isEnumerable(nextState, "foo")).toBeFalsy() - }) - - it("throws on computed properties", () => { - const baseState = {} - Object.defineProperty(baseState, "foo", { - get: () => {}, - enumerable: true - }) - expect(() => { - produce(baseState, s => { - // Proxies only throw once a change is made. - if (useProxies) { - s.modified = true - } - }) - }).toThrowErrorMatchingSnapshot() - }) - - it("allows inherited computed properties", () => { - const proto = {} - Object.defineProperty(proto, "foo", { - get() { - return this.bar - }, - set(val) { - this.bar = val - } - }) - const baseState = Object.create(proto) - produce(baseState, s => { - expect(s.bar).toBeUndefined() - s.foo = {} - expect(s.bar).toBeDefined() - expect(s.foo).toBe(s.bar) - }) - }) - - it("supports a base state with multiple references to an object", () => { - const obj = {} - const res = produce({a: obj, b: obj}, d => { - // Two drafts are created for each occurrence of an object in the base state. - expect(d.a).not.toBe(d.b) - d.a.z = true - expect(d.b.z).toBeUndefined() - }) - expect(res.b).toBe(obj) - expect(res.a).not.toBe(res.b) - expect(res.a.z).toBeTruthy() - }) - - // NOTE: Except the root draft. - it("supports multiple references to any modified draft", () => { - const next = produce({a: {b: 1}}, d => { - d.a.b++ - d.b = d.a - }) - expect(next.a).toBe(next.b) - }) - - it("can rename nested objects (no changes)", () => { - const nextState = produce({obj: {}}, s => { - s.foo = s.obj - delete s.obj - }) - expect(nextState).toEqual({foo: {}}) - }) - - // Very similar to the test before, but the reused object has one - // property changed, one added, and one removed. - it("can rename nested objects (with changes)", () => { - const nextState = produce({obj: {a: 1, b: 1}}, s => { - s.obj.a = true // change - delete s.obj.b // delete - s.obj.c = true // add - - s.foo = s.obj - delete s.obj - }) - expect(nextState).toEqual({foo: {a: true, c: true}}) - }) - - it("can nest a draft in a new object (no changes)", () => { - const baseState = {obj: {}} - const nextState = produce(baseState, s => { - s.foo = {bar: s.obj} - delete s.obj - }) - expect(nextState.foo.bar).toBe(baseState.obj) - }) - - it("can nest a modified draft in a new object", () => { - const nextState = produce({obj: {a: 1, b: 1}}, s => { - s.obj.a = true // change - delete s.obj.b // delete - s.obj.c = true // add - - s.foo = {bar: s.obj} - delete s.obj - }) - expect(nextState).toEqual({foo: {bar: {a: true, c: true}}}) - }) - - it("supports assigning undefined to an existing property", () => { - const nextState = produce(baseState, s => { - s.aProp = undefined - }) - expect(nextState).not.toBe(baseState) - expect(nextState.aProp).toBe(undefined) - }) - - it("supports assigning undefined to a new property", () => { - const baseState = {} - const nextState = produce(baseState, s => { - s.aProp = undefined - }) - expect(nextState).not.toBe(baseState) - expect(nextState.aProp).toBe(undefined) - }) - - // NOTE: ES5 drafts only protect existing properties when revoked. - it("revokes the draft once produce returns", () => { - const expectRevoked = (fn, shouldThrow = true) => { - if (shouldThrow) expect(fn).toThrowErrorMatchingSnapshot() - else expect(fn).not.toThrow() - } - - // Test object drafts: - let draft - produce({a: 1, b: 1}, s => { - draft = s - delete s.b - }) - - // Access known property on object draft. - expectRevoked(() => { - draft.a - }) - - // Assign known property on object draft. - expectRevoked(() => { - draft.a = true - }) - - // Access unknown property on object draft. - expectRevoked(() => { - draft.z - }, useProxies) - - // Assign unknown property on object draft. - expectRevoked(() => { - draft.z = true - }, useProxies) - - // Test array drafts: - produce([1, 2], s => { - draft = s - s.pop() - }) - - // Access known index of an array draft. - expectRevoked(() => { - draft[0] - }) - - // Assign known index of an array draft. - expectRevoked(() => { - draft[0] = true - }) - - // Access unknown index of an array draft. - expectRevoked(() => { - draft[1] - }, useProxies) - - // Assign unknown index of an array draft. - expectRevoked(() => { - draft[1] = true - }, useProxies) - }) - - it("can access a child draft that was created before the draft was modified", () => { - produce({a: {}}, s => { - const before = s.a - s.b = 1 - expect(s.a).toBe(before) - }) - }) - - it("should reflect all changes made in the draft immediately", () => { - produce(baseState, draft => { - draft.anArray[0] = 5 - draft.anArray.unshift("test") - expect(enumerableOnly(draft.anArray)).toEqual([ - "test", - 5, - 2, - {c: 3}, - 1 - ]) - draft.stuffz = "coffee" - expect(draft.stuffz).toBe("coffee") - }) - }) - - if (useProxies) - it("throws when Object.defineProperty() is used on drafts", () => { - expect(() => { - produce({}, draft => { - Object.defineProperty(draft, "xx", { - enumerable: true, - writeable: true, - value: 2 - }) - }) - }).toThrowErrorMatchingSnapshot() - }) - - it("should handle constructor correctly", () => { - const baseState = { - arr: new Array(), - obj: new Object() - } - const result = produce(baseState, draft => { - draft.arrConstructed = draft.arr.constructor(1) - draft.objConstructed = draft.obj.constructor(1) - }) - expect(result.arrConstructed).toEqual(new Array().constructor(1)) - expect(result.objConstructed).toEqual(new Object().constructor(1)) - }) - - it("should handle equality correctly - 1", () => { - const baseState = { - y: 3 / 0, - z: NaN - } - const nextState = produce(baseState, draft => { - draft.y = 4 / 0 - draft.z = NaN - }) - expect(nextState).toBe(baseState) - }) - - it("should handle equality correctly - 2", () => { - const baseState = { - x: -0 - } - const nextState = produce(baseState, draft => { - draft.x = +0 - }) - expect(nextState).not.toBe(baseState) - expect(nextState).not.toEqual(baseState) - }) - - // AKA: recursive produce calls - describe("nested producers", () => { - describe("when base state is not a draft", () => { - // This test ensures the global state used to manage proxies is - // never left in a corrupted state by a nested `produce` call. - it("never affects its parent producer implicitly", () => { - const base = {obj: {a: 1}} - const next = produce(base, draft => { - // Notice how `base.obj` is passed, not `draft.obj` - const obj2 = produce(base.obj, draft2 => { - draft2.a = 0 - }) - expect(obj2.a).toBe(0) - expect(draft.obj.a).toBe(1) // effects should not be visible outside - }) - expect(next).toBe(base) - }) - }) - - describe("when base state is a draft", () => { - it("always wraps the draft in a new draft", () => { - produce({}, parent => { - produce(parent, child => { - expect(child).not.toBe(parent) - expect(isDraft(child)).toBeTruthy() - expect(original(child)).toBe(parent) - }) - }) - }) - - // Reported by: https://github.com/mweststrate/immer/issues/343 - it("ensures each property is drafted", () => { - produce({a: {}, b: {}}, parent => { - parent.a // Access "a" but not "b" - produce(parent, child => { - child.c = 1 - expect(isDraft(child.a)).toBeTruthy() - expect(isDraft(child.b)).toBeTruthy() - }) - }) - }) - - it("preserves any pending changes", () => { - produce({a: 1, b: 1, d: 1}, parent => { - parent.b = 2 - parent.c = 2 - delete parent.d - produce(parent, child => { - expect(child.a).toBe(1) // unchanged - expect(child.b).toBe(2) // changed - expect(child.c).toBe(2) // added - expect(child.d).toBeUndefined() // deleted - }) - }) - }) - }) - - describe("when base state contains a draft", () => { - it("wraps unowned draft with its own draft", () => { - produce({a: {}}, parent => { - produce({a: parent.a}, child => { - expect(child.a).not.toBe(parent.a) - expect(isDraft(child.a)).toBeTruthy() - expect(original(child.a)).toBe(parent.a) - }) - }) - }) - - it("returns unowned draft if no changes were made", () => { - produce({a: {}}, parent => { - const result = produce({a: parent.a}, () => {}) - expect(result.a).toBe(parent.a) - }) - }) - - it("clones the unowned draft when changes are made", () => { - produce({a: {}}, parent => { - const result = produce({a: parent.a}, child => { - child.a.b = 1 - }) - expect(result.a).not.toBe(parent.a) - expect(result.a.b).toBe(1) - expect("b" in parent.a).toBeFalsy() - }) - }) - - // We cannot auto-freeze the result of a nested producer, - // because it may contain a draft from a parent producer. - it("never auto-freezes the result", () => { - produce({a: {}}, parent => { - const r = produce({a: parent.a}, child => { - child.b = 1 // Ensure a copy is returned. - }) - expect(Object.isFrozen(r)).toBeFalsy() - }) - }) - }) - - // "Upvalues" are variables from a parent scope. - it("does not finalize upvalue drafts", () => { - produce({a: {}, b: {}}, parent => { - expect(produce({}, () => parent)).toBe(parent) - parent.x // Ensure proxy not revoked. - - expect(produce({}, () => [parent])[0]).toBe(parent) - parent.x // Ensure proxy not revoked. - - expect(produce({}, () => parent.a)).toBe(parent.a) - parent.a.x // Ensure proxy not revoked. - - // Modified parent test - parent.c = 1 - expect(produce({}, () => [parent.b])[0]).toBe(parent.b) - parent.b.x // Ensure proxy not revoked. - }) - }) - - it("works with interweaved Immer instances", () => { - const options = {useProxies, autoFreeze} - const one = createPatchedImmer(options) - const two = createPatchedImmer(options) - - const base = {} - const result = one.produce(base, s1 => - two.produce({s1}, s2 => { - expect(original(s2.s1)).toBe(s1) - s2.n = 1 - s2.s1 = one.produce({s2}, s3 => { - expect(original(s3.s2)).toBe(s2) - expect(original(s3.s2.s1)).toBe(s2.s1) - return s3.s2.s1 - }) - }) - ) - expect(result.n).toBe(1) - expect(result.s1).toBe(base) - }) - }) - - if (useProxies) - it("throws when Object.setPrototypeOf() is used on a draft", () => { - produce({}, draft => { - expect(() => - Object.setPrototypeOf(draft, Array) - ).toThrowErrorMatchingSnapshot() - }) - }) - - it("supports the 'in' operator", () => { - produce(baseState, draft => { - // Known property - expect("anArray" in draft).toBe(true) - expect(Reflect.has(draft, "anArray")).toBe(true) - - // Unknown property - expect("bla" in draft).toBe(false) - expect(Reflect.has(draft, "bla")).toBe(false) - - // Known index - expect(0 in draft.anArray).toBe(true) - expect("0" in draft.anArray).toBe(true) - expect(Reflect.has(draft.anArray, 0)).toBe(true) - expect(Reflect.has(draft.anArray, "0")).toBe(true) - - // Unknown index - expect(17 in draft.anArray).toBe(false) - expect("17" in draft.anArray).toBe(false) - expect(Reflect.has(draft.anArray, 17)).toBe(false) - expect(Reflect.has(draft.anArray, "17")).toBe(false) - }) - }) - - it("'this' should not be bound anymore - 1", () => { - const base = {x: 3} - const next1 = produce(base, function() { - expect(this).toBe(undefined) - }) - }) - - it("'this' should not be bound anymore - 2", () => { - const incrementor = produce(function() { - expect(this).toBe(undefined) - }) - incrementor() - }) - - it("should be possible to use dynamic bound this", () => { - const world = { - counter: {count: 1}, - inc: produce(function(draft) { - expect(this).toBe(world) - draft.counter.count = this.counter.count + 1 - }) - } - - expect(world.inc(world).counter.count).toBe(2) - }) - - // See here: https://github.com/mweststrate/immer/issues/89 - it("supports the spread operator", () => { - const base = {foo: {x: 0, y: 0}, bar: [0, 0]} - const result = produce(base, draft => { - draft.foo = {x: 1, ...draft.foo, y: 1} - draft.bar = [1, ...draft.bar, 1] - }) - expect(result).toEqual({ - foo: {x: 0, y: 1}, - bar: [1, 0, 0, 1] - }) - }) - - it("processes with lodash.set", () => { - const base = [{id: 1, a: 1}] - const result = produce(base, draft => { - lodash.set(draft, "[0].a", 2) - }) - expect(base[0].a).toEqual(1) - expect(result[0].a).toEqual(2) - }) - - it("processes with lodash.find", () => { - const base = [{id: 1, a: 1}] - const result = produce(base, draft => { - const obj1 = lodash.find(draft, {id: 1}) - lodash.set(obj1, "a", 2) - }) - expect(base[0].a).toEqual(1) - expect(result[0].a).toEqual(2) - }) - - describe("recipe functions", () => { - it("can return a new object", () => { - const base = {x: 3} - const res = produce(base, d => { - return {x: d.x + 1} - }) - expect(res).not.toBe(base) - expect(res).toEqual({x: 4}) - }) - - it("can return the draft", () => { - const base = {x: 3} - const res = produce(base, d => { - d.x = 4 - return d - }) - expect(res).not.toBe(base) - expect(res).toEqual({x: 4}) - }) - - it("can return an unmodified child draft", () => { - const base = {a: {}} - const res = produce(base, d => { - return d.a - }) - expect(res).toBe(base.a) - }) - - // TODO: Avoid throwing if only the child draft was modified. - it("cannot return a modified child draft", () => { - const base = {a: {}} - expect(() => { - produce(base, d => { - d.a.b = 1 - return d.a - }) - }).toThrowErrorMatchingSnapshot() - }) - - it("can return a frozen object", () => { - const res = deepFreeze([{x: 3}]) - expect(produce({}, () => res)).toBe(res) - }) - - it("can return an object with two references to another object", () => { - const next = produce({}, d => { - const obj = {} - return {obj, arr: [obj]} - }) - expect(next.obj).toBe(next.arr[0]) - }) - - it("can return an object with two references to an unmodified draft", () => { - const base = {a: {}} - const next = produce(base, d => { - return [d.a, d.a] - }) - expect(next[0]).toBe(base.a) - expect(next[0]).toBe(next[1]) - }) - - it("cannot return an object that references itself", () => { - const res = {} - res.self = res - expect(() => { - produce(res, () => res.self) - }).toThrowErrorMatchingSnapshot() - }) - }) - - describe("async recipe function", () => { - it("can modify the draft", () => { - const base = {a: 0, b: 0} - return produce(base, async d => { - d.a = 1 - await Promise.resolve() - d.b = 1 - }).then(res => { - expect(res).not.toBe(base) - expect(res).toEqual({a: 1, b: 1}) - }) - }) - - it("works with rejected promises", () => { - let draft - const base = {a: 0, b: 0} - const err = new Error("passed") - return produce(base, d => { - draft = d - draft.b = 1 - return Promise.reject(err) - }).then( - () => { - throw "failed" - }, - e => { - expect(e).toBe(err) - expect(() => draft.a).toThrowErrorMatchingSnapshot() - } - ) - }) - - it("supports recursive produce calls after await", () => { - const base = {obj: {k: 1}} - return produce(base, d => { - const obj = d.obj - delete d.obj - return Promise.resolve().then(() => { - d.a = produce({}, d => { - d.b = obj // Assign a draft owned by the parent scope. - }) - - // Auto-freezing is prevented when an unowned draft exists. - expect(Object.isFrozen(d.a)).toBeFalsy() - - // Ensure `obj` is not revoked. - obj.c = 1 - }) - }).then(res => { - expect(res).not.toBe(base) - expect(res).toEqual({ - a: {b: {k: 1, c: 1}} - }) - }) - }) - }) - - it("throws when the draft is modified and another object is returned", () => { - const base = {x: 3} - expect(() => { - produce(base, draft => { - draft.x = 4 - return {x: 5} - }) - }).toThrowErrorMatchingSnapshot() - }) - - it("should fix #117 - 1", () => { - const reducer = (state, action) => - produce(state, draft => { - switch (action.type) { - case "SET_STARTING_DOTS": - return draft.availableStartingDots.map(a => a) - default: - break - } - }) - const base = { - availableStartingDots: [ - {dots: 4, count: 1}, - {dots: 3, count: 2}, - {dots: 2, count: 3}, - {dots: 1, count: 4} - ] - } - const next = reducer(base, {type: "SET_STARTING_DOTS"}) - expect(next).toEqual(base.availableStartingDots) - expect(next).not.toBe(base.availableStartingDots) - }) - - it("should fix #117 - 2", () => { - const reducer = (state, action) => - produce(state, draft => { - switch (action.type) { - case "SET_STARTING_DOTS": - return { - dots: draft.availableStartingDots.map(a => a) - } - default: - break - } - }) - const base = { - availableStartingDots: [ - {dots: 4, count: 1}, - {dots: 3, count: 2}, - {dots: 2, count: 3}, - {dots: 1, count: 4} - ] - } - const next = reducer(base, {type: "SET_STARTING_DOTS"}) - expect(next).toEqual({dots: base.availableStartingDots}) - }) - - it("cannot always detect noop assignments - 0", () => { - const baseState = {x: {y: 3}} - const nextState = produce(baseState, d => { - const a = d.x - d.x = a - }) - expect(nextState).toBe(baseState) - }) - - it("cannot always detect noop assignments - 1", () => { - const baseState = {x: {y: 3}} - const nextState = produce(baseState, d => { - const a = d.x - d.x = 4 - d.x = a - }) - // Ideally, this should actually be the same instances - // but this would be pretty expensive to detect, - // so we don't atm - expect(nextState).not.toBe(baseState) - }) - - it("cannot always detect noop assignments - 2", () => { - const baseState = {x: {y: 3}} - const nextState = produce(baseState, d => { - const a = d.x - const stuff = a.y + 3 - d.x = 4 - d.x = a - }) - // Ideally, this should actually be the same instances - // but this would be pretty expensive to detect, - // so we don't atm - expect(nextState).not.toBe(baseState) - }) - - it("cannot always detect noop assignments - 3", () => { - const baseState = {x: 3} - const nextState = produce(baseState, d => { - d.x = 3 - }) - expect(nextState).toBe(baseState) - }) - - it("cannot always detect noop assignments - 4", () => { - const baseState = {x: 3} - const nextState = produce(baseState, d => { - d.x = 4 - d.x = 3 - }) - // Ideally, this should actually be the same instances - // but this would be pretty expensive to detect, - // so we don't atm - expect(nextState).not.toBe(baseState) - }) - - it("cannot produce undefined by returning undefined", () => { - const base = 3 - expect(produce(base, () => 4)).toBe(4) - expect(produce(base, () => null)).toBe(null) - expect(produce(base, () => undefined)).toBe(3) - expect(produce(base, () => {})).toBe(3) - expect(produce(base, () => nothing)).toBe(undefined) - - expect(produce({}, () => undefined)).toEqual({}) - expect(produce({}, () => nothing)).toBe(undefined) - expect(produce(3, () => nothing)).toBe(undefined) - - expect(produce(() => undefined)({})).toEqual({}) - expect(produce(() => nothing)({})).toBe(undefined) - expect(produce(() => nothing)(3)).toBe(undefined) - }) - - describe("base state type", () => { - testObjectTypes(produce) - testLiteralTypes(produce) - }) - - afterEach(() => { - expect(baseState).toBe(origBaseState) - expect(baseState).toEqual(createBaseState()) - }) - - class Foo {} - function createBaseState() { - const data = { - anInstance: new Foo(), - anArray: [3, 2, {c: 3}, 1], - aProp: "hi", - anObject: { - nested: { - yummie: true - }, - coffee: false - } - } - return autoFreeze ? deepFreeze(data) : data - } - }) - - describe(`isDraft - ${name}`, () => { - it("returns true for object drafts", () => { - produce({}, state => { - expect(isDraft(state)).toBeTruthy() - }) - }) - it("returns true for array drafts", () => { - produce([], state => { - expect(isDraft(state)).toBeTruthy() - }) - }) - it("returns true for objects nested in object drafts", () => { - produce({a: {b: {}}}, state => { - expect(isDraft(state.a)).toBeTruthy() - expect(isDraft(state.a.b)).toBeTruthy() - }) - }) - it("returns false for new objects added to a draft", () => { - produce({}, state => { - state.a = {} - expect(isDraft(state.a)).toBeFalsy() - }) - }) - it("returns false for objects returned by the producer", () => { - const object = produce(null, Object.create) - expect(isDraft(object)).toBeFalsy() - }) - it("returns false for arrays returned by the producer", () => { - const array = produce(null, _ => []) - expect(isDraft(array)).toBeFalsy() - }) - it("returns false for object drafts returned by the producer", () => { - const object = produce({}, state => state) - expect(isDraft(object)).toBeFalsy() - }) - it("returns false for array drafts returned by the producer", () => { - const array = produce([], state => state) - expect(isDraft(array)).toBeFalsy() - }) - }) + const listener = useListener ? function() {} : undefined + const {produce} = createPatchedImmer({ + useProxies, + autoFreeze + }) + + // When `useListener` is true, append a function to the arguments of every + // uncurried `produce` call in every test. This makes tests easier to read. + function createPatchedImmer(options) { + const immer = new Immer(options) + + const {produce} = immer + immer.produce = (...args) => + typeof args[1] === "function" && args.length < 3 + ? produce(...args, listener) + : produce(...args) + + return immer + } + + describe(`base functionality - ${name}`, () => { + let baseState + let origBaseState + + beforeEach(() => { + origBaseState = baseState = createBaseState() + }) + + it("returns the original state when no changes are made", () => { + const nextState = produce(baseState, s => { + expect(s.aProp).toBe("hi") + expect(s.anObject.nested).toMatchObject({yummie: true}) + }) + expect(nextState).toBe(baseState) + }) + + it("does structural sharing", () => { + const random = Math.random() + const nextState = produce(baseState, s => { + s.aProp = random + }) + expect(nextState).not.toBe(baseState) + expect(nextState.aProp).toBe(random) + expect(nextState.nested).toBe(baseState.nested) + }) + + it("deep change bubbles up", () => { + const nextState = produce(baseState, s => { + s.anObject.nested.yummie = false + }) + expect(nextState).not.toBe(baseState) + expect(nextState.anObject).not.toBe(baseState.anObject) + expect(baseState.anObject.nested.yummie).toBe(true) + expect(nextState.anObject.nested.yummie).toBe(false) + expect(nextState.anArray).toBe(baseState.anArray) + }) + + it("can add props", () => { + const nextState = produce(baseState, s => { + s.anObject.cookie = {tasty: true} + }) + expect(nextState).not.toBe(baseState) + expect(nextState.anObject).not.toBe(baseState.anObject) + expect(nextState.anObject.nested).toBe(baseState.anObject.nested) + expect(nextState.anObject.cookie).toEqual({tasty: true}) + }) + + it("can delete props", () => { + const nextState = produce(baseState, s => { + delete s.anObject.nested + }) + expect(nextState).not.toBe(baseState) + expect(nextState.anObject).not.toBe(baseState.anObject) + expect(nextState.anObject.nested).toBe(undefined) + }) + + // Found by: https://github.com/mweststrate/immer/pull/267 + it("can delete props added in the producer", () => { + const nextState = produce(baseState, s => { + s.anObject.test = true + delete s.anObject.test + }) + if (useProxies) { + expect(nextState).not.toBe(baseState) + expect(nextState).toEqual(baseState) + } else { + // The copy is avoided in ES5. + expect(nextState).toBe(baseState) + } + }) + + // Found by: https://github.com/mweststrate/immer/issues/328 + it("can set a property that was just deleted", () => { + const baseState = {a: 1} + const nextState = produce(baseState, s => { + delete s.a + s.a = 2 + }) + expect(nextState.a).toBe(2) + }) + + it("can set a property to its original value after deleting it", () => { + const baseState = {a: {b: 1}} + const nextState = produce(baseState, s => { + const a = s.a + delete s.a + s.a = a + }) + if (useProxies) { + expect(nextState).not.toBe(baseState) + expect(nextState).toEqual(baseState) + } else { + // The copy is avoided in ES5. + expect(nextState).toBe(baseState) + } + }) + + it("can get property descriptors", () => { + const getDescriptor = Object.getOwnPropertyDescriptor + const baseState = deepFreeze([{a: 1}]) + produce(baseState, arr => { + const obj = arr[0] + const desc = { + configurable: true, + enumerable: true, + ...(useProxies && {writable: true}) + } + + // Known property + expect(getDescriptor(obj, "a")).toMatchObject(desc) + expect(getDescriptor(arr, 0)).toMatchObject(desc) + + // Deleted property + delete obj.a + arr.pop() + expect(getDescriptor(obj, "a")).toBeUndefined() + expect(getDescriptor(arr, 0)).toBeUndefined() + + // Unknown property + expect(getDescriptor(obj, "b")).toBeUndefined() + expect(getDescriptor(arr, 100)).toBeUndefined() + + // Added property + obj.b = 2 + arr[100] = 1 + expect(getDescriptor(obj, "b")).toBeDefined() + expect(getDescriptor(arr, 100)).toBeDefined() + }) + }) + + describe("array drafts", () => { + it("supports Array.isArray()", () => { + const nextState = produce(baseState, s => { + expect(Array.isArray(s.anArray)).toBeTruthy() + s.anArray.push(1) + }) + expect(Array.isArray(nextState.anArray)).toBeTruthy() + }) + + it("supports index access", () => { + const value = baseState.anArray[0] + const nextState = produce(baseState, s => { + expect(s.anArray[0]).toBe(value) + }) + expect(nextState).toBe(baseState) + }) + + it("supports iteration", () => { + const base = [{id: 1, a: 1}, {id: 2, a: 1}] + const findById = (collection, id) => { + for (const item of collection) { + if (item.id === id) return item + } + return null + } + const result = produce(base, draft => { + const obj1 = findById(draft, 1) + const obj2 = findById(draft, 2) + obj1.a = 2 + obj2.a = 2 + }) + expect(result[0].a).toEqual(2) + expect(result[1].a).toEqual(2) + }) + + it("can assign an index via bracket notation", () => { + const nextState = produce(baseState, s => { + s.anArray[3] = true + }) + expect(nextState).not.toBe(baseState) + expect(nextState.anArray).not.toBe(baseState.anArray) + expect(nextState.anArray[3]).toEqual(true) + }) + + it("can use splice() to both add and remove items", () => { + const nextState = produce(baseState, s => { + s.anArray.splice(1, 1, "a", "b") + }) + expect(nextState.anArray).not.toBe(baseState.anArray) + expect(nextState.anArray[1]).toBe("a") + expect(nextState.anArray[2]).toBe("b") + }) + + it("can truncate via the length property", () => { + const baseLength = baseState.anArray.length + const nextState = produce(baseState, s => { + s.anArray.length = baseLength - 1 + }) + expect(nextState.anArray).not.toBe(baseState.anArray) + expect(nextState.anArray.length).toBe(baseLength - 1) + }) + + it("can extend via the length property", () => { + const baseLength = baseState.anArray.length + const nextState = produce(baseState, s => { + s.anArray.length = baseLength + 1 + }) + expect(nextState.anArray).not.toBe(baseState.anArray) + expect(nextState.anArray.length).toBe(baseLength + 1) + }) + + // Reported here: https://github.com/mweststrate/immer/issues/116 + it("can pop then push", () => { + const nextState = produce([1, 2, 3], s => { + s.pop() + s.push(100) + }) + expect(nextState).toEqual([1, 2, 100]) + }) + + it("can be sorted", () => { + const baseState = [3, 1, 2] + const nextState = produce(baseState, s => { + s.sort() + }) + expect(nextState).not.toBe(baseState) + expect(nextState).toEqual([1, 2, 3]) + }) + + it("supports modifying nested objects", () => { + const baseState = [{a: 1}, {}] + const nextState = produce(baseState, s => { + s[0].a++ + s[1].a = 0 + }) + expect(nextState).not.toBe(baseState) + expect(nextState[0].a).toBe(2) + expect(nextState[1].a).toBe(0) + }) + + it("never preserves non-numeric properties", () => { + const baseState = [] + baseState.x = 7 + const nextState = produce(baseState, s => { + s.push(3) + }) + expect("x" in nextState).toBeFalsy() + }) + + if (useProxies) { + it("throws when a non-numeric property is added", () => { + expect(() => { + produce([], d => { + d.x = 3 + }) + }).toThrowErrorMatchingSnapshot() + }) + + it("throws when a non-numeric property is deleted", () => { + expect(() => { + const baseState = [] + baseState.x = 7 + produce(baseState, d => { + delete d.x + }) + }).toThrowErrorMatchingSnapshot() + }) + } + }) + + it("supports `immerable` symbol on constructor", () => { + class One {} + One[immerable] = true + const baseState = new One() + const nextState = produce(baseState, draft => { + expect(draft).not.toBe(baseState) + draft.foo = true + }) + expect(nextState).not.toBe(baseState) + expect(nextState.foo).toBeTruthy() + }) + + it("preserves symbol properties", () => { + const test = Symbol("test") + const baseState = {[test]: true} + const nextState = produce(baseState, s => { + expect(s[test]).toBeTruthy() + s.foo = true + }) + expect(nextState).toEqual({ + [test]: true, + foo: true + }) + }) + + it("preserves non-enumerable properties", () => { + const baseState = {} + // Non-enumerable object property + Object.defineProperty(baseState, "foo", { + value: {a: 1}, + enumerable: false + }) + // Non-enumerable primitive property + Object.defineProperty(baseState, "bar", { + value: 1, + enumerable: false + }) + const nextState = produce(baseState, s => { + expect(s.foo).toBeTruthy() + expect(isEnumerable(s, "foo")).toBeFalsy() + s.bar++ + expect(isEnumerable(s, "foo")).toBeFalsy() + s.foo.a++ + expect(isEnumerable(s, "foo")).toBeFalsy() + }) + expect(nextState.foo).toBeTruthy() + expect(isEnumerable(nextState, "foo")).toBeFalsy() + }) + + it("throws on computed properties", () => { + const baseState = {} + Object.defineProperty(baseState, "foo", { + get: () => {}, + enumerable: true + }) + expect(() => { + produce(baseState, s => { + // Proxies only throw once a change is made. + if (useProxies) { + s.modified = true + } + }) + }).toThrowErrorMatchingSnapshot() + }) + + it("allows inherited computed properties", () => { + const proto = {} + Object.defineProperty(proto, "foo", { + get() { + return this.bar + }, + set(val) { + this.bar = val + } + }) + const baseState = Object.create(proto) + produce(baseState, s => { + expect(s.bar).toBeUndefined() + s.foo = {} + expect(s.bar).toBeDefined() + expect(s.foo).toBe(s.bar) + }) + }) + + it("supports a base state with multiple references to an object", () => { + const obj = {} + const res = produce({a: obj, b: obj}, d => { + // Two drafts are created for each occurrence of an object in the base state. + expect(d.a).not.toBe(d.b) + d.a.z = true + expect(d.b.z).toBeUndefined() + }) + expect(res.b).toBe(obj) + expect(res.a).not.toBe(res.b) + expect(res.a.z).toBeTruthy() + }) + + // NOTE: Except the root draft. + it("supports multiple references to any modified draft", () => { + const next = produce({a: {b: 1}}, d => { + d.a.b++ + d.b = d.a + }) + expect(next.a).toBe(next.b) + }) + + it("can rename nested objects (no changes)", () => { + const nextState = produce({obj: {}}, s => { + s.foo = s.obj + delete s.obj + }) + expect(nextState).toEqual({foo: {}}) + }) + + // Very similar to the test before, but the reused object has one + // property changed, one added, and one removed. + it("can rename nested objects (with changes)", () => { + const nextState = produce({obj: {a: 1, b: 1}}, s => { + s.obj.a = true // change + delete s.obj.b // delete + s.obj.c = true // add + + s.foo = s.obj + delete s.obj + }) + expect(nextState).toEqual({foo: {a: true, c: true}}) + }) + + it("can nest a draft in a new object (no changes)", () => { + const baseState = {obj: {}} + const nextState = produce(baseState, s => { + s.foo = {bar: s.obj} + delete s.obj + }) + expect(nextState.foo.bar).toBe(baseState.obj) + }) + + it("can nest a modified draft in a new object", () => { + const nextState = produce({obj: {a: 1, b: 1}}, s => { + s.obj.a = true // change + delete s.obj.b // delete + s.obj.c = true // add + + s.foo = {bar: s.obj} + delete s.obj + }) + expect(nextState).toEqual({foo: {bar: {a: true, c: true}}}) + }) + + it("supports assigning undefined to an existing property", () => { + const nextState = produce(baseState, s => { + s.aProp = undefined + }) + expect(nextState).not.toBe(baseState) + expect(nextState.aProp).toBe(undefined) + }) + + it("supports assigning undefined to a new property", () => { + const baseState = {} + const nextState = produce(baseState, s => { + s.aProp = undefined + }) + expect(nextState).not.toBe(baseState) + expect(nextState.aProp).toBe(undefined) + }) + + // NOTE: ES5 drafts only protect existing properties when revoked. + it("revokes the draft once produce returns", () => { + const expectRevoked = (fn, shouldThrow = true) => { + if (shouldThrow) expect(fn).toThrowErrorMatchingSnapshot() + else expect(fn).not.toThrow() + } + + // Test object drafts: + let draft + produce({a: 1, b: 1}, s => { + draft = s + delete s.b + }) + + // Access known property on object draft. + expectRevoked(() => { + draft.a + }) + + // Assign known property on object draft. + expectRevoked(() => { + draft.a = true + }) + + // Access unknown property on object draft. + expectRevoked(() => { + draft.z + }, useProxies) + + // Assign unknown property on object draft. + expectRevoked(() => { + draft.z = true + }, useProxies) + + // Test array drafts: + produce([1, 2], s => { + draft = s + s.pop() + }) + + // Access known index of an array draft. + expectRevoked(() => { + draft[0] + }) + + // Assign known index of an array draft. + expectRevoked(() => { + draft[0] = true + }) + + // Access unknown index of an array draft. + expectRevoked(() => { + draft[1] + }, useProxies) + + // Assign unknown index of an array draft. + expectRevoked(() => { + draft[1] = true + }, useProxies) + }) + + it("can access a child draft that was created before the draft was modified", () => { + produce({a: {}}, s => { + const before = s.a + s.b = 1 + expect(s.a).toBe(before) + }) + }) + + it("should reflect all changes made in the draft immediately", () => { + produce(baseState, draft => { + draft.anArray[0] = 5 + draft.anArray.unshift("test") + expect(enumerableOnly(draft.anArray)).toEqual(["test", 5, 2, {c: 3}, 1]) + draft.stuffz = "coffee" + expect(draft.stuffz).toBe("coffee") + }) + }) + + if (useProxies) + it("throws when Object.defineProperty() is used on drafts", () => { + expect(() => { + produce({}, draft => { + Object.defineProperty(draft, "xx", { + enumerable: true, + writeable: true, + value: 2 + }) + }) + }).toThrowErrorMatchingSnapshot() + }) + + it("should handle constructor correctly", () => { + const baseState = { + arr: new Array(), + obj: new Object() + } + const result = produce(baseState, draft => { + draft.arrConstructed = draft.arr.constructor(1) + draft.objConstructed = draft.obj.constructor(1) + }) + expect(result.arrConstructed).toEqual(new Array().constructor(1)) + expect(result.objConstructed).toEqual(new Object().constructor(1)) + }) + + it("should handle equality correctly - 1", () => { + const baseState = { + y: 3 / 0, + z: NaN + } + const nextState = produce(baseState, draft => { + draft.y = 4 / 0 + draft.z = NaN + }) + expect(nextState).toBe(baseState) + }) + + it("should handle equality correctly - 2", () => { + const baseState = { + x: -0 + } + const nextState = produce(baseState, draft => { + draft.x = +0 + }) + expect(nextState).not.toBe(baseState) + expect(nextState).not.toEqual(baseState) + }) + + // AKA: recursive produce calls + describe("nested producers", () => { + describe("when base state is not a draft", () => { + // This test ensures the global state used to manage proxies is + // never left in a corrupted state by a nested `produce` call. + it("never affects its parent producer implicitly", () => { + const base = {obj: {a: 1}} + const next = produce(base, draft => { + // Notice how `base.obj` is passed, not `draft.obj` + const obj2 = produce(base.obj, draft2 => { + draft2.a = 0 + }) + expect(obj2.a).toBe(0) + expect(draft.obj.a).toBe(1) // effects should not be visible outside + }) + expect(next).toBe(base) + }) + }) + + describe("when base state is a draft", () => { + it("always wraps the draft in a new draft", () => { + produce({}, parent => { + produce(parent, child => { + expect(child).not.toBe(parent) + expect(isDraft(child)).toBeTruthy() + expect(original(child)).toBe(parent) + }) + }) + }) + + // Reported by: https://github.com/mweststrate/immer/issues/343 + it("ensures each property is drafted", () => { + produce({a: {}, b: {}}, parent => { + parent.a // Access "a" but not "b" + produce(parent, child => { + child.c = 1 + expect(isDraft(child.a)).toBeTruthy() + expect(isDraft(child.b)).toBeTruthy() + }) + }) + }) + + it("preserves any pending changes", () => { + produce({a: 1, b: 1, d: 1}, parent => { + parent.b = 2 + parent.c = 2 + delete parent.d + produce(parent, child => { + expect(child.a).toBe(1) // unchanged + expect(child.b).toBe(2) // changed + expect(child.c).toBe(2) // added + expect(child.d).toBeUndefined() // deleted + }) + }) + }) + }) + + describe("when base state contains a draft", () => { + it("wraps unowned draft with its own draft", () => { + produce({a: {}}, parent => { + produce({a: parent.a}, child => { + expect(child.a).not.toBe(parent.a) + expect(isDraft(child.a)).toBeTruthy() + expect(original(child.a)).toBe(parent.a) + }) + }) + }) + + it("returns unowned draft if no changes were made", () => { + produce({a: {}}, parent => { + const result = produce({a: parent.a}, () => {}) + expect(result.a).toBe(parent.a) + }) + }) + + it("clones the unowned draft when changes are made", () => { + produce({a: {}}, parent => { + const result = produce({a: parent.a}, child => { + child.a.b = 1 + }) + expect(result.a).not.toBe(parent.a) + expect(result.a.b).toBe(1) + expect("b" in parent.a).toBeFalsy() + }) + }) + + // We cannot auto-freeze the result of a nested producer, + // because it may contain a draft from a parent producer. + it("never auto-freezes the result", () => { + produce({a: {}}, parent => { + const r = produce({a: parent.a}, child => { + child.b = 1 // Ensure a copy is returned. + }) + expect(Object.isFrozen(r)).toBeFalsy() + }) + }) + }) + + // "Upvalues" are variables from a parent scope. + it("does not finalize upvalue drafts", () => { + produce({a: {}, b: {}}, parent => { + expect(produce({}, () => parent)).toBe(parent) + parent.x // Ensure proxy not revoked. + + expect(produce({}, () => [parent])[0]).toBe(parent) + parent.x // Ensure proxy not revoked. + + expect(produce({}, () => parent.a)).toBe(parent.a) + parent.a.x // Ensure proxy not revoked. + + // Modified parent test + parent.c = 1 + expect(produce({}, () => [parent.b])[0]).toBe(parent.b) + parent.b.x // Ensure proxy not revoked. + }) + }) + + it("works with interweaved Immer instances", () => { + const options = {useProxies, autoFreeze} + const one = createPatchedImmer(options) + const two = createPatchedImmer(options) + + const base = {} + const result = one.produce(base, s1 => + two.produce({s1}, s2 => { + expect(original(s2.s1)).toBe(s1) + s2.n = 1 + s2.s1 = one.produce({s2}, s3 => { + expect(original(s3.s2)).toBe(s2) + expect(original(s3.s2.s1)).toBe(s2.s1) + return s3.s2.s1 + }) + }) + ) + expect(result.n).toBe(1) + expect(result.s1).toBe(base) + }) + }) + + if (useProxies) + it("throws when Object.setPrototypeOf() is used on a draft", () => { + produce({}, draft => { + expect(() => + Object.setPrototypeOf(draft, Array) + ).toThrowErrorMatchingSnapshot() + }) + }) + + it("supports the 'in' operator", () => { + produce(baseState, draft => { + // Known property + expect("anArray" in draft).toBe(true) + expect(Reflect.has(draft, "anArray")).toBe(true) + + // Unknown property + expect("bla" in draft).toBe(false) + expect(Reflect.has(draft, "bla")).toBe(false) + + // Known index + expect(0 in draft.anArray).toBe(true) + expect("0" in draft.anArray).toBe(true) + expect(Reflect.has(draft.anArray, 0)).toBe(true) + expect(Reflect.has(draft.anArray, "0")).toBe(true) + + // Unknown index + expect(17 in draft.anArray).toBe(false) + expect("17" in draft.anArray).toBe(false) + expect(Reflect.has(draft.anArray, 17)).toBe(false) + expect(Reflect.has(draft.anArray, "17")).toBe(false) + }) + }) + + it("'this' should not be bound anymore - 1", () => { + const base = {x: 3} + const next1 = produce(base, function() { + expect(this).toBe(undefined) + }) + }) + + it("'this' should not be bound anymore - 2", () => { + const incrementor = produce(function() { + expect(this).toBe(undefined) + }) + incrementor() + }) + + it("should be possible to use dynamic bound this", () => { + const world = { + counter: {count: 1}, + inc: produce(function(draft) { + expect(this).toBe(world) + draft.counter.count = this.counter.count + 1 + }) + } + + expect(world.inc(world).counter.count).toBe(2) + }) + + // See here: https://github.com/mweststrate/immer/issues/89 + it("supports the spread operator", () => { + const base = {foo: {x: 0, y: 0}, bar: [0, 0]} + const result = produce(base, draft => { + draft.foo = {x: 1, ...draft.foo, y: 1} + draft.bar = [1, ...draft.bar, 1] + }) + expect(result).toEqual({ + foo: {x: 0, y: 1}, + bar: [1, 0, 0, 1] + }) + }) + + it("processes with lodash.set", () => { + const base = [{id: 1, a: 1}] + const result = produce(base, draft => { + lodash.set(draft, "[0].a", 2) + }) + expect(base[0].a).toEqual(1) + expect(result[0].a).toEqual(2) + }) + + it("processes with lodash.find", () => { + const base = [{id: 1, a: 1}] + const result = produce(base, draft => { + const obj1 = lodash.find(draft, {id: 1}) + lodash.set(obj1, "a", 2) + }) + expect(base[0].a).toEqual(1) + expect(result[0].a).toEqual(2) + }) + + describe("recipe functions", () => { + it("can return a new object", () => { + const base = {x: 3} + const res = produce(base, d => { + return {x: d.x + 1} + }) + expect(res).not.toBe(base) + expect(res).toEqual({x: 4}) + }) + + it("can return the draft", () => { + const base = {x: 3} + const res = produce(base, d => { + d.x = 4 + return d + }) + expect(res).not.toBe(base) + expect(res).toEqual({x: 4}) + }) + + it("can return an unmodified child draft", () => { + const base = {a: {}} + const res = produce(base, d => { + return d.a + }) + expect(res).toBe(base.a) + }) + + // TODO: Avoid throwing if only the child draft was modified. + it("cannot return a modified child draft", () => { + const base = {a: {}} + expect(() => { + produce(base, d => { + d.a.b = 1 + return d.a + }) + }).toThrowErrorMatchingSnapshot() + }) + + it("can return a frozen object", () => { + const res = deepFreeze([{x: 3}]) + expect(produce({}, () => res)).toBe(res) + }) + + it("can return an object with two references to another object", () => { + const next = produce({}, d => { + const obj = {} + return {obj, arr: [obj]} + }) + expect(next.obj).toBe(next.arr[0]) + }) + + it("can return an object with two references to an unmodified draft", () => { + const base = {a: {}} + const next = produce(base, d => { + return [d.a, d.a] + }) + expect(next[0]).toBe(base.a) + expect(next[0]).toBe(next[1]) + }) + + it("cannot return an object that references itself", () => { + const res = {} + res.self = res + expect(() => { + produce(res, () => res.self) + }).toThrowErrorMatchingSnapshot() + }) + }) + + describe("async recipe function", () => { + it("can modify the draft", () => { + const base = {a: 0, b: 0} + return produce(base, async d => { + d.a = 1 + await Promise.resolve() + d.b = 1 + }).then(res => { + expect(res).not.toBe(base) + expect(res).toEqual({a: 1, b: 1}) + }) + }) + + it("works with rejected promises", () => { + let draft + const base = {a: 0, b: 0} + const err = new Error("passed") + return produce(base, d => { + draft = d + draft.b = 1 + return Promise.reject(err) + }).then( + () => { + throw "failed" + }, + e => { + expect(e).toBe(err) + expect(() => draft.a).toThrowErrorMatchingSnapshot() + } + ) + }) + + it("supports recursive produce calls after await", () => { + const base = {obj: {k: 1}} + return produce(base, d => { + const obj = d.obj + delete d.obj + return Promise.resolve().then(() => { + d.a = produce({}, d => { + d.b = obj // Assign a draft owned by the parent scope. + }) + + // Auto-freezing is prevented when an unowned draft exists. + expect(Object.isFrozen(d.a)).toBeFalsy() + + // Ensure `obj` is not revoked. + obj.c = 1 + }) + }).then(res => { + expect(res).not.toBe(base) + expect(res).toEqual({ + a: {b: {k: 1, c: 1}} + }) + }) + }) + }) + + it("throws when the draft is modified and another object is returned", () => { + const base = {x: 3} + expect(() => { + produce(base, draft => { + draft.x = 4 + return {x: 5} + }) + }).toThrowErrorMatchingSnapshot() + }) + + it("should fix #117 - 1", () => { + const reducer = (state, action) => + produce(state, draft => { + switch (action.type) { + case "SET_STARTING_DOTS": + return draft.availableStartingDots.map(a => a) + default: + break + } + }) + const base = { + availableStartingDots: [ + {dots: 4, count: 1}, + {dots: 3, count: 2}, + {dots: 2, count: 3}, + {dots: 1, count: 4} + ] + } + const next = reducer(base, {type: "SET_STARTING_DOTS"}) + expect(next).toEqual(base.availableStartingDots) + expect(next).not.toBe(base.availableStartingDots) + }) + + it("should fix #117 - 2", () => { + const reducer = (state, action) => + produce(state, draft => { + switch (action.type) { + case "SET_STARTING_DOTS": + return { + dots: draft.availableStartingDots.map(a => a) + } + default: + break + } + }) + const base = { + availableStartingDots: [ + {dots: 4, count: 1}, + {dots: 3, count: 2}, + {dots: 2, count: 3}, + {dots: 1, count: 4} + ] + } + const next = reducer(base, {type: "SET_STARTING_DOTS"}) + expect(next).toEqual({dots: base.availableStartingDots}) + }) + + it("cannot always detect noop assignments - 0", () => { + const baseState = {x: {y: 3}} + const nextState = produce(baseState, d => { + const a = d.x + d.x = a + }) + expect(nextState).toBe(baseState) + }) + + it("cannot always detect noop assignments - 1", () => { + const baseState = {x: {y: 3}} + const nextState = produce(baseState, d => { + const a = d.x + d.x = 4 + d.x = a + }) + // Ideally, this should actually be the same instances + // but this would be pretty expensive to detect, + // so we don't atm + expect(nextState).not.toBe(baseState) + }) + + it("cannot always detect noop assignments - 2", () => { + const baseState = {x: {y: 3}} + const nextState = produce(baseState, d => { + const a = d.x + const stuff = a.y + 3 + d.x = 4 + d.x = a + }) + // Ideally, this should actually be the same instances + // but this would be pretty expensive to detect, + // so we don't atm + expect(nextState).not.toBe(baseState) + }) + + it("cannot always detect noop assignments - 3", () => { + const baseState = {x: 3} + const nextState = produce(baseState, d => { + d.x = 3 + }) + expect(nextState).toBe(baseState) + }) + + it("cannot always detect noop assignments - 4", () => { + const baseState = {x: 3} + const nextState = produce(baseState, d => { + d.x = 4 + d.x = 3 + }) + // Ideally, this should actually be the same instances + // but this would be pretty expensive to detect, + // so we don't atm + expect(nextState).not.toBe(baseState) + }) + + it("cannot produce undefined by returning undefined", () => { + const base = 3 + expect(produce(base, () => 4)).toBe(4) + expect(produce(base, () => null)).toBe(null) + expect(produce(base, () => undefined)).toBe(3) + expect(produce(base, () => {})).toBe(3) + expect(produce(base, () => nothing)).toBe(undefined) + + expect(produce({}, () => undefined)).toEqual({}) + expect(produce({}, () => nothing)).toBe(undefined) + expect(produce(3, () => nothing)).toBe(undefined) + + expect(produce(() => undefined)({})).toEqual({}) + expect(produce(() => nothing)({})).toBe(undefined) + expect(produce(() => nothing)(3)).toBe(undefined) + }) + + describe("base state type", () => { + testObjectTypes(produce) + testLiteralTypes(produce) + }) + + afterEach(() => { + expect(baseState).toBe(origBaseState) + expect(baseState).toEqual(createBaseState()) + }) + + class Foo {} + function createBaseState() { + const data = { + anInstance: new Foo(), + anArray: [3, 2, {c: 3}, 1], + aProp: "hi", + anObject: { + nested: { + yummie: true + }, + coffee: false + } + } + return autoFreeze ? deepFreeze(data) : data + } + }) + + describe(`isDraft - ${name}`, () => { + it("returns true for object drafts", () => { + produce({}, state => { + expect(isDraft(state)).toBeTruthy() + }) + }) + it("returns true for array drafts", () => { + produce([], state => { + expect(isDraft(state)).toBeTruthy() + }) + }) + it("returns true for objects nested in object drafts", () => { + produce({a: {b: {}}}, state => { + expect(isDraft(state.a)).toBeTruthy() + expect(isDraft(state.a.b)).toBeTruthy() + }) + }) + it("returns false for new objects added to a draft", () => { + produce({}, state => { + state.a = {} + expect(isDraft(state.a)).toBeFalsy() + }) + }) + it("returns false for objects returned by the producer", () => { + const object = produce(null, Object.create) + expect(isDraft(object)).toBeFalsy() + }) + it("returns false for arrays returned by the producer", () => { + const array = produce(null, _ => []) + expect(isDraft(array)).toBeFalsy() + }) + it("returns false for object drafts returned by the producer", () => { + const object = produce({}, state => state) + expect(isDraft(object)).toBeFalsy() + }) + it("returns false for array drafts returned by the producer", () => { + const array = produce([], state => state) + expect(isDraft(array)).toBeFalsy() + }) + }) } function testObjectTypes(produce) { - class Foo { - constructor(foo) { - this.foo = foo - this[immerable] = true - } - } - const values = { - "empty object": {}, - "plain object": {a: 1, b: 2}, - "object (no prototype)": Object.create(null), - "empty array": [], - "plain array": [1, 2], - "class instance (draftable)": new Foo(1) - } - for (const name in values) { - const value = values[name] - const copy = shallowCopy(value) - testObjectType(name, value) - testObjectType(name + " (frozen)", Object.freeze(copy)) - } - function testObjectType(name, base) { - describe(name, () => { - it("creates a draft", () => { - produce(base, draft => { - expect(draft).not.toBe(base) - expect(shallowCopy(draft, true)).toEqual(base) - }) - }) - - it("preserves the prototype", () => { - const proto = Object.getPrototypeOf(base) - produce(base, draft => { - expect(Object.getPrototypeOf(draft)).toBe(proto) - }) - }) - - it("returns the base state when no changes are made", () => { - expect(produce(base, () => {})).toBe(base) - }) - - it("returns a copy when changes are made", () => { - const random = Math.random() - const result = produce(base, draft => { - draft[0] = random - }) - expect(result).not.toBe(base) - expect(result.constructor).toBe(base.constructor) - expect(result[0]).toBe(random) - }) - }) - } + class Foo { + constructor(foo) { + this.foo = foo + this[immerable] = true + } + } + const values = { + "empty object": {}, + "plain object": {a: 1, b: 2}, + "object (no prototype)": Object.create(null), + "empty array": [], + "plain array": [1, 2], + "class instance (draftable)": new Foo(1) + } + for (const name in values) { + const value = values[name] + const copy = shallowCopy(value) + testObjectType(name, value) + testObjectType(name + " (frozen)", Object.freeze(copy)) + } + function testObjectType(name, base) { + describe(name, () => { + it("creates a draft", () => { + produce(base, draft => { + expect(draft).not.toBe(base) + expect(shallowCopy(draft, true)).toEqual(base) + }) + }) + + it("preserves the prototype", () => { + const proto = Object.getPrototypeOf(base) + produce(base, draft => { + expect(Object.getPrototypeOf(draft)).toBe(proto) + }) + }) + + it("returns the base state when no changes are made", () => { + expect(produce(base, () => {})).toBe(base) + }) + + it("returns a copy when changes are made", () => { + const random = Math.random() + const result = produce(base, draft => { + draft[0] = random + }) + expect(result).not.toBe(base) + expect(result.constructor).toBe(base.constructor) + expect(result[0]).toBe(random) + }) + }) + } } function testLiteralTypes(produce) { - class Foo {} - const values = { - "falsy number": 0, - "truthy number": 1, - "negative number": -1, - NaN: NaN, - infinity: 1 / 0, - true: true, - false: false, - "empty string": "", - "truthy string": "1", - null: null, - undefined: undefined, - - /** - * These objects are treated as literals because Immer - * does not know how to draft them. - */ - function: () => {}, - "regexp object": /.+/g, - "boxed number": new Number(0), - "boxed string": new String(""), - "boxed boolean": new Boolean(), - "date object": new Date(), - "class instance (not draftable)": new Foo() - } - for (const name in values) { - 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( - produce(value, draft => { - draft.foo = true - }) - ).toBe(value) - }) - } - }) - } + class Foo {} + const values = { + "falsy number": 0, + "truthy number": 1, + "negative number": -1, + NaN: NaN, + infinity: 1 / 0, + true: true, + false: false, + "empty string": "", + "truthy string": "1", + null: null, + undefined: undefined, + + /** + * These objects are treated as literals because Immer + * does not know how to draft them. + */ + function: () => {}, + "regexp object": /.+/g, + "boxed number": new Number(0), + "boxed string": new String(""), + "boxed boolean": new Boolean(), + "date object": new Date(), + "class instance (not draftable)": new Foo() + } + for (const name in values) { + 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( + produce(value, draft => { + draft.foo = true + }) + ).toBe(value) + }) + } + }) + } } function enumerableOnly(x) { - const copy = Array.isArray(x) ? x.slice() : Object.assign({}, x) - each(copy, (prop, value) => { - if (value && typeof value === "object") { - copy[prop] = enumerableOnly(value) - } - }) - return copy + const copy = Array.isArray(x) ? x.slice() : Object.assign({}, x) + each(copy, (prop, value) => { + if (value && typeof value === "object") { + copy[prop] = enumerableOnly(value) + } + }) + return copy } diff --git a/__tests__/curry.js b/__tests__/curry.js index 3ad7b905..ba5d3cbb 100644 --- a/__tests__/curry.js +++ b/__tests__/curry.js @@ -5,74 +5,68 @@ runTests("proxy", true) runTests("es5", false) function runTests(name, useProxies) { - describe("curry - " + name, () => { - setUseProxies(useProxies) + describe("curry - " + name, () => { + setUseProxies(useProxies) - it("should check arguments", () => { - expect(() => produce()).toThrowErrorMatchingSnapshot() - expect(() => produce({})).toThrowErrorMatchingSnapshot() - expect(() => produce({}, {})).toThrowErrorMatchingSnapshot() - expect(() => - produce({}, () => {}, []) - ).toThrowErrorMatchingSnapshot() - }) + it("should check arguments", () => { + expect(() => produce()).toThrowErrorMatchingSnapshot() + expect(() => produce({})).toThrowErrorMatchingSnapshot() + expect(() => produce({}, {})).toThrowErrorMatchingSnapshot() + expect(() => produce({}, () => {}, [])).toThrowErrorMatchingSnapshot() + }) - it("should support currying", () => { - const state = [{}, {}, {}] - const mapper = produce((item, index) => { - item.index = index - }) + it("should support currying", () => { + const state = [{}, {}, {}] + const mapper = produce((item, index) => { + item.index = index + }) - expect(state.map(mapper)).not.toBe(state) - expect(state.map(mapper)).toEqual([ - {index: 0}, - {index: 1}, - {index: 2} - ]) - expect(state).toEqual([{}, {}, {}]) - }) + expect(state.map(mapper)).not.toBe(state) + expect(state.map(mapper)).toEqual([{index: 0}, {index: 1}, {index: 2}]) + expect(state).toEqual([{}, {}, {}]) + }) - it("should support returning new states from curring", () => { - const reducer = produce((item, index) => { - if (!item) { - return {hello: "world"} - } - item.index = index - }) + it("should support returning new states from curring", () => { + const reducer = produce((item, index) => { + if (!item) { + return {hello: "world"} + } + item.index = index + }) - expect(reducer(undefined, 3)).toEqual({hello: "world"}) - expect(reducer({}, 3)).toEqual({index: 3}) - }) + expect(reducer(undefined, 3)).toEqual({hello: "world"}) + expect(reducer({}, 3)).toEqual({index: 3}) + }) - it("should support passing an initial state as second argument", () => { - const reducer = produce( - (item, index) => { - item.index = index - }, - {hello: "world"} - ) + it("should support passing an initial state as second argument", () => { + const reducer = produce( + (item, index) => { + item.index = index + }, + {hello: "world"} + ) - expect(reducer(undefined, 3)).toEqual({hello: "world", index: 3}) - expect(reducer({}, 3)).toEqual({index: 3}) - expect(reducer()).toEqual({hello: "world", index: undefined}) - }) + expect(reducer(undefined, 3)).toEqual({hello: "world", index: 3}) + expect(reducer({}, 3)).toEqual({index: 3}) + expect(reducer()).toEqual({hello: "world", index: undefined}) + }) - it("can has fun with change detection", () => { - const spread = produce(Object.assign) + it("can has fun with change detection", () => { + const spread = produce(Object.assign) - const base = { - x: 1, - y: 1 - } + const base = { + x: 1, + y: 1 + } - expect({...base}).not.toBe(base) - expect(spread(base, {})).toBe(base) - expect(spread(base, {y: 1})).toBe(base) - expect(spread(base, {...base})).toBe(base) - expect(spread(base, {...base, y: 2})).not.toBe(base) - expect(spread(base, {...base, y: 2})).toEqual({x: 1, y: 2}) - expect(spread(base, {z: 3})).toEqual({x: 1, y: 1, z: 3}) - expect(spread(base, {y: 1})).toBe(base) - }) - }) + expect({...base}).not.toBe(base) + expect(spread(base, {})).toBe(base) + expect(spread(base, {y: 1})).toBe(base) + expect(spread(base, {...base})).toBe(base) + expect(spread(base, {...base, y: 2})).not.toBe(base) + expect(spread(base, {...base, y: 2})).toEqual({x: 1, y: 2}) + expect(spread(base, {z: 3})).toEqual({x: 1, y: 1, z: 3}) + expect(spread(base, {y: 1})).toBe(base) + }) + }) } diff --git a/__tests__/draft.ts b/__tests__/draft.ts index 85a14e8c..53a60caa 100644 --- a/__tests__/draft.ts +++ b/__tests__/draft.ts @@ -7,281 +7,281 @@ declare const fromDraft: (draft: Draft) => T // DraftArray { - // NOTE: As of 3.2.2, everything fails without "extends any" - ;(val: ReadonlyArray) => { - val = _ as Draft - let elem: Value = _ as Draft - } + // 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] = _ - assert(toDraft(val), val) - assert(fromDraft(toDraft(val)), val) + let val: [1, 2] = _ + assert(toDraft(val), val) + assert(fromDraft(toDraft(val)), val) } // Tuple (nested in a tuple) { - let val: [[1, 2]] = _ - assert(toDraft(val), val) - assert(fromDraft(toDraft(val)), val) + let val: [[1, 2]] = _ + assert(toDraft(val), val) + assert(fromDraft(toDraft(val)), val) } // Tuple (nested in two mutable arrays) { - let val: [1, 2][][] = _ - let draft: typeof val = _ - val = assert(toDraft(val), draft) - assert(fromDraft(draft), val) + let val: [1, 2][][] = _ + let draft: typeof val = _ + val = assert(toDraft(val), draft) + assert(fromDraft(draft), val) } // Tuple (nested in two readonly arrays) { - let val: ReadonlyArray> = _ - let draft: [1, 2][][] = _ - val = assert(toDraft(val), draft) + let val: ReadonlyArray> = _ + let draft: [1, 2][][] = _ + val = assert(toDraft(val), draft) } // Readonly tuple { - // TODO: Uncomment this when readonly tuples are supported. - // More info: https://stackoverflow.com/a/53822074/2228559 - // let val: Readonly<[1, 2]> = _ - // let draft: [1, 2] = _ - // draft = assert(toDraft(val), draft) - // val = fromDraft(draft) + // TODO: Uncomment this when readonly tuples are supported. + // More info: https://stackoverflow.com/a/53822074/2228559 + // let val: Readonly<[1, 2]> = _ + // let draft: [1, 2] = _ + // draft = assert(toDraft(val), draft) + // val = fromDraft(draft) } // Mutable array { - let val: string[] = _ - assert(toDraft(val), val) - assert(fromDraft(toDraft(val)), val) + let val: string[] = _ + assert(toDraft(val), val) + assert(fromDraft(toDraft(val)), val) } // Mutable array (nested in tuple) { - let val: [string[]] = _ - assert(toDraft(val), val) - assert(fromDraft(toDraft(val)), val) + let val: [string[]] = _ + assert(toDraft(val), val) + assert(fromDraft(toDraft(val)), val) } // Readonly array { - let val: ReadonlyArray = _ - let draft: string[] = _ - val = assert(toDraft(val), draft) - fromDraft(draft) + let val: ReadonlyArray = _ + let draft: string[] = _ + val = assert(toDraft(val), draft) + fromDraft(draft) } // Readonly array (nested in readonly object) { - let val: {readonly a: ReadonlyArray} = _ - let draft: {a: string[]} = _ - val = assert(toDraft(val), draft) - fromDraft(draft) + let val: {readonly a: ReadonlyArray} = _ + let draft: {a: string[]} = _ + val = assert(toDraft(val), draft) + fromDraft(draft) } // Mutable object { - let val: {a: 1} = _ - assert(toDraft(val), val) - assert(fromDraft(toDraft(val)), val) + let val: {a: 1} = _ + assert(toDraft(val), val) + assert(fromDraft(toDraft(val)), val) } // Mutable object (nested in mutable object) { - let val: {a: {b: 1}} = _ - assert(toDraft(val), val) - assert(fromDraft(toDraft(val)), val) + let val: {a: {b: 1}} = _ + assert(toDraft(val), val) + assert(fromDraft(toDraft(val)), val) } // Interface { - interface Foo { - a: {b: number} - } - let val: Foo = _ - assert(toDraft(val), val) - assert(fromDraft(toDraft(val)), val) + interface Foo { + a: {b: number} + } + let val: Foo = _ + assert(toDraft(val), val) + assert(fromDraft(toDraft(val)), val) } // Interface (nested in interface) { - interface Foo { - a: {b: number} - } - interface Bar { - foo: Foo - } - let val: Bar = _ - assert(toDraft(val), val) - assert(fromDraft(toDraft(val)), val) + interface Foo { + a: {b: number} + } + interface Bar { + foo: Foo + } + let val: Bar = _ + assert(toDraft(val), val) + assert(fromDraft(toDraft(val)), val) } // Readonly object { - let val: {readonly a: 1} = _ - let draft: {a: 1} = _ - val = assert(toDraft(val), draft) + let val: {readonly a: 1} = _ + let draft: {a: 1} = _ + val = assert(toDraft(val), draft) } // Readonly object (nested in tuple) { - let val: [{readonly a: 1}] = _ - let draft: [{a: 1}] = _ - val = assert(toDraft(val), draft) + let val: [{readonly a: 1}] = _ + let draft: [{a: 1}] = _ + val = assert(toDraft(val), draft) } // Loose function { - let val: Function = _ - assert(toDraft(val), val) - assert(fromDraft(toDraft(val)), val) + let val: Function = _ + assert(toDraft(val), val) + assert(fromDraft(toDraft(val)), val) } // Strict function { - let val: () => void = _ - assert(toDraft(val), val) - assert(fromDraft(toDraft(val)), val) + let val: () => void = _ + assert(toDraft(val), val) + assert(fromDraft(toDraft(val)), val) } // Class type (mutable) { - class Foo { - private test: any - constructor(public bar: string) {} - } - let val: Foo = _ - // TODO: Uncomment this when plain object types can be distinguished from class types. - // More info here: https://github.com/Microsoft/TypeScript/issues/29063 - // assert(toDraft(val), val) - // assert(fromDraft(toDraft(val)), val) + class Foo { + private test: any + constructor(public bar: string) {} + } + let val: Foo = _ + // TODO: Uncomment this when plain object types can be distinguished from class types. + // More info here: https://github.com/Microsoft/TypeScript/issues/29063 + // assert(toDraft(val), val) + // assert(fromDraft(toDraft(val)), val) } // Class type (readonly) { - class Foo { - private test: any - constructor(readonly bar: string) {} - } - let val: Foo = _ - // TODO: Uncomment this when plain object types can be distinguished from class types. - // More info here: https://github.com/Microsoft/TypeScript/issues/29063 - // assert(toDraft(val), val) - // assert(fromDraft(toDraft(val)), val) + class Foo { + private test: any + constructor(readonly bar: string) {} + } + let val: Foo = _ + // TODO: Uncomment this when plain object types can be distinguished from class types. + // More info here: https://github.com/Microsoft/TypeScript/issues/29063 + // assert(toDraft(val), val) + // assert(fromDraft(toDraft(val)), val) } // Map instance { - let val: Map = _ - assert(toDraft(val), val) - assert(fromDraft(toDraft(val)), val) + let val: Map = _ + assert(toDraft(val), val) + assert(fromDraft(toDraft(val)), val) - // Weak maps - let weak: WeakMap = _ - assert(toDraft(weak), weak) - assert(fromDraft(toDraft(weak)), weak) + // Weak maps + let weak: WeakMap = _ + assert(toDraft(weak), weak) + assert(fromDraft(toDraft(weak)), weak) } // Set instance { - let val: Set = _ - assert(toDraft(val), val) - assert(fromDraft(toDraft(val)), val) + let val: Set = _ + assert(toDraft(val), val) + assert(fromDraft(toDraft(val)), val) - // Weak sets - let weak: WeakSet = _ - assert(toDraft(weak), weak) - assert(fromDraft(toDraft(weak)), weak) + // Weak sets + let weak: WeakSet = _ + assert(toDraft(weak), weak) + assert(fromDraft(toDraft(weak)), weak) } // Promise object { - let val: Promise = _ - assert(toDraft(val), val) - assert(fromDraft(toDraft(val)), val) + let val: Promise = _ + assert(toDraft(val), val) + assert(fromDraft(toDraft(val)), val) } // Date instance { - let val: Date = _ - assert(toDraft(val), val) - assert(fromDraft(toDraft(val)), val) + let val: Date = _ + assert(toDraft(val), val) + assert(fromDraft(toDraft(val)), val) } // RegExp instance { - let val: RegExp = _ - assert(toDraft(val), val) - assert(fromDraft(toDraft(val)), val) + let val: RegExp = _ + assert(toDraft(val), val) + assert(fromDraft(toDraft(val)), val) } // Boxed primitive { - let val: Boolean = _ - assert(toDraft(val), val) - assert(fromDraft(toDraft(val)), val) + let val: Boolean = _ + assert(toDraft(val), val) + assert(fromDraft(toDraft(val)), val) } // String literal { - let val: string = _ - assert(toDraft(val), val) - assert(fromDraft(toDraft(val)), val) + let val: string = _ + assert(toDraft(val), val) + assert(fromDraft(toDraft(val)), val) } // Any { - let val: any = _ - assert(toDraft(val), val) - assert(fromDraft(toDraft(val)), val) + let val: any = _ + assert(toDraft(val), val) + assert(fromDraft(toDraft(val)), val) } // Never { - let val: never = _ as never - assert(toDraft(val), val) - assert(fromDraft(toDraft(val)), val) + let val: never = _ as never + assert(toDraft(val), val) + assert(fromDraft(toDraft(val)), val) } // Unknown { - let val: unknown = _ - assert(toDraft(val), val) - assert(fromDraft(toDraft(val)), val) + let val: unknown = _ + assert(toDraft(val), val) + assert(fromDraft(toDraft(val)), val) } // Numeral { - let val: 1 = _ - assert(toDraft(val), val) - assert(fromDraft(toDraft(val)), val) + let val: 1 = _ + assert(toDraft(val), val) + assert(fromDraft(toDraft(val)), val) } // Union of numerals { - let val: 1 | 2 | 3 = _ - assert(toDraft(val), val) - assert(fromDraft(toDraft(val)), val) + let val: 1 | 2 | 3 = _ + assert(toDraft(val), val) + assert(fromDraft(toDraft(val)), val) } // Union of tuple, array, object { - let val: [0] | ReadonlyArray | Readonly<{a: 1}> = _ - let draft: [0] | string[] | {a: 1} = _ - val = assert(toDraft(val), draft) + let val: [0] | ReadonlyArray | Readonly<{a: 1}> = _ + let draft: [0] | string[] | {a: 1} = _ + val = assert(toDraft(val), draft) } // Generic type { - // NOTE: "extends any" only helps a little. - const $ = (val: ReadonlyArray) => { - let draft: Draft = _ - val = assert(toDraft(val), draft) - // $ExpectError: [ts] Argument of type 'DraftArray' is not assignable to parameter of type 'Draft'. [2345] - // assert(fromDraft(draft), draft) - } + // NOTE: "extends any" only helps a little. + const $ = (val: ReadonlyArray) => { + let draft: Draft = _ + val = 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 1906d167..2c690430 100644 --- a/__tests__/frozen.js +++ b/__tests__/frozen.js @@ -7,128 +7,128 @@ runTests("proxy", true) runTests("es5", false) function runTests(name, useProxies) { - describe("auto freeze - " + name, () => { - setUseProxies(useProxies) - setAutoFreeze(true) - - it("never freezes the base state", () => { - const base = {arr: [1], obj: {a: 1}} - const next = produce(base, draft => { - draft.arr.push(1) - }) - expect(isFrozen(base)).toBeFalsy() - expect(isFrozen(base.arr)).toBeFalsy() - expect(isFrozen(next)).toBeTruthy() - expect(isFrozen(next.arr)).toBeTruthy() - }) - - it("never freezes reused 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() - }) - - describe("the result is always auto-frozen when", () => { - it("the root draft is mutated (and no error is thrown)", () => { - const base = {} - const next = produce(base, draft => { - draft.a = 1 - }) - expect(next).not.toBe(base) - expect(isFrozen(next)).toBeTruthy() - }) - - it("a nested draft is mutated (and no error is thrown)", () => { - const base = {a: {}} - const next = produce(base, draft => { - draft.a.b = 1 - }) - expect(next).not.toBe(base) - expect(isFrozen(next)).toBeTruthy() - expect(isFrozen(next.a)).toBeTruthy() - }) - }) - - describe("the result is never auto-frozen when", () => { - it("the producer is a no-op", () => { - const base = {} - const next = produce(base, () => {}) - expect(next).toBe(base) - expect(isFrozen(next)).toBeFalsy() - }) - - it("the root draft is returned", () => { - const base = {} - const next = produce(base, draft => draft) - expect(next).toBe(base) - expect(isFrozen(next)).toBeFalsy() - }) - - it("a nested draft is returned", () => { - const base = {a: {}} - const next = produce(base, draft => draft.a) - expect(next).toBe(base.a) - expect(isFrozen(next)).toBeFalsy() - }) - - it("the base state is returned", () => { - const base = {} - const next = produce(base, () => base) - expect(next).toBe(base) - expect(isFrozen(next)).toBeFalsy() - }) - - it("a new object replaces a primitive base", () => { - const obj = {} - const next = produce(null, () => obj) - expect(next).toBe(obj) - expect(isFrozen(next)).toBeFalsy() - }) - - it("a new object replaces the entire draft", () => { - const obj = {a: {b: {}}} - const next = produce({}, () => obj) - expect(next).toBe(obj) - expect(isFrozen(next)).toBeFalsy() - expect(isFrozen(next.a)).toBeFalsy() - expect(isFrozen(next.a.b)).toBeFalsy() - }) - - it("a new object is added to the root draft", () => { - const base = {} - const next = produce(base, draft => { - draft.a = {} - }) - expect(next).not.toBe(base) - expect(isFrozen(next)).toBeTruthy() - expect(isFrozen(next.a)).toBeFalsy() - }) - - it("a new object is added to a nested draft", () => { - const base = {a: {}} - const next = produce(base, draft => { - draft.a.b = {} - }) - expect(next).not.toBe(base) - expect(isFrozen(next)).toBeTruthy() - expect(isFrozen(next.a)).toBeTruthy() - expect(isFrozen(next.a.b)).toBeFalsy() - }) - }) - - it("can handle already frozen trees", () => { - const a = [] - const b = {a: a} - Object.freeze(a) - Object.freeze(b) - const n = produce(b, draft => { - draft.c = true - draft.a.push(3) - }) - expect(n).toEqual({c: true, a: [3]}) - }) - }) + describe("auto freeze - " + name, () => { + setUseProxies(useProxies) + setAutoFreeze(true) + + it("never freezes the base state", () => { + const base = {arr: [1], obj: {a: 1}} + const next = produce(base, draft => { + draft.arr.push(1) + }) + expect(isFrozen(base)).toBeFalsy() + expect(isFrozen(base.arr)).toBeFalsy() + expect(isFrozen(next)).toBeTruthy() + expect(isFrozen(next.arr)).toBeTruthy() + }) + + it("never freezes reused 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() + }) + + describe("the result is always auto-frozen when", () => { + it("the root draft is mutated (and no error is thrown)", () => { + const base = {} + const next = produce(base, draft => { + draft.a = 1 + }) + expect(next).not.toBe(base) + expect(isFrozen(next)).toBeTruthy() + }) + + it("a nested draft is mutated (and no error is thrown)", () => { + const base = {a: {}} + const next = produce(base, draft => { + draft.a.b = 1 + }) + expect(next).not.toBe(base) + expect(isFrozen(next)).toBeTruthy() + expect(isFrozen(next.a)).toBeTruthy() + }) + }) + + describe("the result is never auto-frozen when", () => { + it("the producer is a no-op", () => { + const base = {} + const next = produce(base, () => {}) + expect(next).toBe(base) + expect(isFrozen(next)).toBeFalsy() + }) + + it("the root draft is returned", () => { + const base = {} + const next = produce(base, draft => draft) + expect(next).toBe(base) + expect(isFrozen(next)).toBeFalsy() + }) + + it("a nested draft is returned", () => { + const base = {a: {}} + const next = produce(base, draft => draft.a) + expect(next).toBe(base.a) + expect(isFrozen(next)).toBeFalsy() + }) + + it("the base state is returned", () => { + const base = {} + const next = produce(base, () => base) + expect(next).toBe(base) + expect(isFrozen(next)).toBeFalsy() + }) + + it("a new object replaces a primitive base", () => { + const obj = {} + const next = produce(null, () => obj) + expect(next).toBe(obj) + expect(isFrozen(next)).toBeFalsy() + }) + + it("a new object replaces the entire draft", () => { + const obj = {a: {b: {}}} + const next = produce({}, () => obj) + expect(next).toBe(obj) + expect(isFrozen(next)).toBeFalsy() + expect(isFrozen(next.a)).toBeFalsy() + expect(isFrozen(next.a.b)).toBeFalsy() + }) + + it("a new object is added to the root draft", () => { + const base = {} + const next = produce(base, draft => { + draft.a = {} + }) + expect(next).not.toBe(base) + expect(isFrozen(next)).toBeTruthy() + expect(isFrozen(next.a)).toBeFalsy() + }) + + it("a new object is added to a nested draft", () => { + const base = {a: {}} + const next = produce(base, draft => { + draft.a.b = {} + }) + expect(next).not.toBe(base) + expect(isFrozen(next)).toBeTruthy() + expect(isFrozen(next.a)).toBeTruthy() + expect(isFrozen(next.a.b)).toBeFalsy() + }) + }) + + it("can handle already frozen trees", () => { + const a = [] + const b = {a: a} + Object.freeze(a) + Object.freeze(b) + const n = produce(b, draft => { + draft.c = true + draft.a.push(3) + }) + expect(n).toEqual({c: true, a: [3]}) + }) + }) } diff --git a/__tests__/hooks.js b/__tests__/hooks.js index 759b41e9..ae474649 100644 --- a/__tests__/hooks.js +++ b/__tests__/hooks.js @@ -6,222 +6,222 @@ describe("hooks (proxy) -", () => createHookTests(true)) describe("hooks (es5) -", () => createHookTests(false)) function createHookTests(useProxies) { - let produce, onAssign, onDelete, onCopy + let produce, onAssign, onDelete, onCopy - beforeEach(() => { - ;({produce, onAssign, onDelete, onCopy} = new Immer({ - autoFreeze: true, - useProxies, - onAssign: defuseProxies(jest.fn().mockName("onAssign")), - onDelete: defuseProxies(jest.fn().mockName("onDelete")), - onCopy: defuseProxies(jest.fn().mockName("onCopy")) - })) - }) + beforeEach(() => { + ;({produce, onAssign, onDelete, onCopy} = new Immer({ + autoFreeze: true, + useProxies, + onAssign: defuseProxies(jest.fn().mockName("onAssign")), + onDelete: defuseProxies(jest.fn().mockName("onDelete")), + onCopy: defuseProxies(jest.fn().mockName("onCopy")) + })) + }) - describe("onAssign()", () => { - useSharedTests(() => onAssign) - describe("when draft is an object", () => { - test("assign", () => { - produce({a: 0, b: 0, c: 0}, s => { - s.a++ - s.c++ - }) - expectCalls(onAssign) - }) - test("assign (no change)", () => { - produce({a: 0}, s => { - s.a = 0 - }) - expect(onAssign).not.toBeCalled() - }) - test("delete", () => { - produce({a: 1}, s => { - delete s.a - }) - expect(onAssign).not.toBeCalled() - }) - test("nested assignments", () => { - produce({a: {b: {c: 1, d: 1, e: 1}}}, s => { - const {b} = s.a - b.c = 2 - delete b.d - b.e = 1 // no-op - }) - expectCalls(onAssign) - }) - }) - describe("when draft is an array", () => { - test("assign", () => { - produce([1], s => { - s[0] = 0 - }) - expectCalls(onAssign) - }) - test("push", () => { - produce([], s => { - s.push(4) - }) - expectCalls(onAssign) - }) - test("pop", () => { - produce([1], s => { - s.pop() - }) - expect(onAssign).not.toBeCalled() - }) - test("unshift", () => { - produce([1], s => { - s.unshift(0) - }) - expectCalls(onAssign) - }) - test("length = 0", () => { - produce([1], s => { - s.length = 0 - }) - expect(onAssign).not.toBeCalled() - }) - test("splice (length += 1)", () => { - produce([1, 2, 3], s => { - s.splice(1, 1, 0, 0) - }) - expectCalls(onAssign) - }) - test("splice (length += 0)", () => { - produce([1, 2, 3], s => { - s.splice(1, 1, 0) - }) - expectCalls(onAssign) - }) - test("splice (length -= 1)", () => { - produce([1, 2, 3], s => { - s.splice(0, 2, 6) - }) - expectCalls(onAssign) - }) - }) - describe("when a draft is moved into a new object", () => { - it("is called in the right order", () => { - const calls = [] - onAssign.mockImplementation((_, prop) => { - calls.push(prop) - }) - produce({a: {b: 1, c: {}}}, s => { - s.a.b = 0 - s.a.c.d = 1 - s.x = {y: {z: s.a}} - delete s.a - }) - // Sibling properties use enumeration order, which means new - // properties come last among their siblings. The deepest - // properties always come first in their ancestor chain. - expect(calls).toEqual(["b", "d", "c", "x"]) - }) - }) - }) + describe("onAssign()", () => { + useSharedTests(() => onAssign) + describe("when draft is an object", () => { + test("assign", () => { + produce({a: 0, b: 0, c: 0}, s => { + s.a++ + s.c++ + }) + expectCalls(onAssign) + }) + test("assign (no change)", () => { + produce({a: 0}, s => { + s.a = 0 + }) + expect(onAssign).not.toBeCalled() + }) + test("delete", () => { + produce({a: 1}, s => { + delete s.a + }) + expect(onAssign).not.toBeCalled() + }) + test("nested assignments", () => { + produce({a: {b: {c: 1, d: 1, e: 1}}}, s => { + const {b} = s.a + b.c = 2 + delete b.d + b.e = 1 // no-op + }) + expectCalls(onAssign) + }) + }) + describe("when draft is an array", () => { + test("assign", () => { + produce([1], s => { + s[0] = 0 + }) + expectCalls(onAssign) + }) + test("push", () => { + produce([], s => { + s.push(4) + }) + expectCalls(onAssign) + }) + test("pop", () => { + produce([1], s => { + s.pop() + }) + expect(onAssign).not.toBeCalled() + }) + test("unshift", () => { + produce([1], s => { + s.unshift(0) + }) + expectCalls(onAssign) + }) + test("length = 0", () => { + produce([1], s => { + s.length = 0 + }) + expect(onAssign).not.toBeCalled() + }) + test("splice (length += 1)", () => { + produce([1, 2, 3], s => { + s.splice(1, 1, 0, 0) + }) + expectCalls(onAssign) + }) + test("splice (length += 0)", () => { + produce([1, 2, 3], s => { + s.splice(1, 1, 0) + }) + expectCalls(onAssign) + }) + test("splice (length -= 1)", () => { + produce([1, 2, 3], s => { + s.splice(0, 2, 6) + }) + expectCalls(onAssign) + }) + }) + describe("when a draft is moved into a new object", () => { + it("is called in the right order", () => { + const calls = [] + onAssign.mockImplementation((_, prop) => { + calls.push(prop) + }) + produce({a: {b: 1, c: {}}}, s => { + s.a.b = 0 + s.a.c.d = 1 + s.x = {y: {z: s.a}} + delete s.a + }) + // Sibling properties use enumeration order, which means new + // properties come last among their siblings. The deepest + // properties always come first in their ancestor chain. + expect(calls).toEqual(["b", "d", "c", "x"]) + }) + }) + }) - describe("onDelete()", () => { - useSharedTests(() => onDelete) - describe("when draft is an object -", () => { - test("delete", () => { - produce({a: 1, b: 1, c: 1}, s => { - delete s.a - delete s.c - }) - expectCalls(onDelete) - }) - test("delete (no change)", () => { - produce({}, s => { - delete s.a - }) - expect(onDelete).not.toBeCalled() - }) - test("nested deletions", () => { - produce({a: {b: {c: 1}}}, s => { - delete s.a.b.c - }) - expectCalls(onDelete) - }) - }) - describe("when draft is an array -", () => { - test("pop", () => { - produce([1], s => { - s.pop() - }) - expectCalls(onDelete) - }) - test("length = 0", () => { - produce([1], s => { - s.length = 0 - }) - expectCalls(onDelete) - }) - test("splice (length -= 1)", () => { - produce([1, 2, 3], s => { - s.splice(0, 2, 6) - }) - expectCalls(onDelete) - }) - }) - }) + describe("onDelete()", () => { + useSharedTests(() => onDelete) + describe("when draft is an object -", () => { + test("delete", () => { + produce({a: 1, b: 1, c: 1}, s => { + delete s.a + delete s.c + }) + expectCalls(onDelete) + }) + test("delete (no change)", () => { + produce({}, s => { + delete s.a + }) + expect(onDelete).not.toBeCalled() + }) + test("nested deletions", () => { + produce({a: {b: {c: 1}}}, s => { + delete s.a.b.c + }) + expectCalls(onDelete) + }) + }) + describe("when draft is an array -", () => { + test("pop", () => { + produce([1], s => { + s.pop() + }) + expectCalls(onDelete) + }) + test("length = 0", () => { + produce([1], s => { + s.length = 0 + }) + expectCalls(onDelete) + }) + test("splice (length -= 1)", () => { + produce([1, 2, 3], s => { + s.splice(0, 2, 6) + }) + expectCalls(onDelete) + }) + }) + }) - describe("onCopy()", () => { - useSharedTests(() => onCopy) - it("is called in the right order", () => { - const calls = [] - onCopy.mockImplementation(s => { - calls.push(s.base) - }) - const base = {a: {b: {c: 1}}} - produce(base, s => { - delete s.a.b.c - }) - expect(calls).toShallowEqual([base.a.b, base.a, base]) - }) - }) + describe("onCopy()", () => { + useSharedTests(() => onCopy) + it("is called in the right order", () => { + const calls = [] + onCopy.mockImplementation(s => { + calls.push(s.base) + }) + const base = {a: {b: {c: 1}}} + produce(base, s => { + delete s.a.b.c + }) + expect(calls).toShallowEqual([base.a.b, base.a, base]) + }) + }) - function useSharedTests(getHook) { - it("is called before the parent is frozen", () => { - const hook = getHook() - hook.mockImplementation(s => { - // Parent object must not be frozen. - expect(Object.isFrozen(s.base)).toBeFalsy() - }) - produce({a: {b: {c: 0}}}, s => { - if (hook == onDelete) delete s.a.b.c - else s.a.b.c = 1 - }) - expect(hook).toHaveBeenCalledTimes(hook == onDelete ? 1 : 3) - }) - } + function useSharedTests(getHook) { + it("is called before the parent is frozen", () => { + const hook = getHook() + hook.mockImplementation(s => { + // Parent object must not be frozen. + expect(Object.isFrozen(s.base)).toBeFalsy() + }) + produce({a: {b: {c: 0}}}, s => { + if (hook == onDelete) delete s.a.b.c + else s.a.b.c = 1 + }) + expect(hook).toHaveBeenCalledTimes(hook == onDelete ? 1 : 3) + }) + } } // Produce a snapshot of the hook arguments (minus any draft state). function expectCalls(hook) { - expect( - hook.mock.calls.map(call => { - return call.slice(1) - }) - ).toMatchSnapshot() + expect( + hook.mock.calls.map(call => { + return call.slice(1) + }) + ).toMatchSnapshot() } // For defusing draft proxies. function defuseProxies(fn) { - return Object.assign((...args) => { - expect(args[0].finalized).toBeTruthy() - args[0].draft = args[0].drafts = null - fn(...args) - }, fn) + return Object.assign((...args) => { + expect(args[0].finalized).toBeTruthy() + args[0].draft = args[0].drafts = null + fn(...args) + }, fn) } expect.extend({ - toShallowEqual(received, expected) { - const match = matchers.toBe(received, expected) - return match.pass || !received || typeof received !== "object" - ? match - : !Array.isArray(expected) || - (Array.isArray(received) && received.length === expected.length) - ? matchers.toEqual(received, expected) - : match - } + toShallowEqual(received, expected) { + const match = matchers.toBe(received, expected) + return match.pass || !received || typeof received !== "object" + ? match + : !Array.isArray(expected) || + (Array.isArray(received) && received.length === expected.length) + ? matchers.toEqual(received, expected) + : match + } }) diff --git a/__tests__/immutable.ts b/__tests__/immutable.ts index 8df0982e..3ac9b7eb 100644 --- a/__tests__/immutable.ts +++ b/__tests__/immutable.ts @@ -3,54 +3,54 @@ import {Immutable} from "../dist/immer.js" // array in tuple { - let val = _ as Immutable<[string[], 1]> - assert(val, _ as readonly [ReadonlyArray, 1]) + let val = _ as Immutable<[string[], 1]> + assert(val, _ as readonly [ReadonlyArray, 1]) } // tuple in array { - let val = _ as Immutable<[string, 1][]> - assert(val, _ as ReadonlyArray) + let val = _ as Immutable<[string, 1][]> + assert(val, _ as ReadonlyArray) } // tuple in tuple { - let val = _ as Immutable<[[string, 1], 1]> - assert(val, _ as readonly [readonly [string, 1], 1]) + let val = _ as Immutable<[[string, 1], 1]> + assert(val, _ as readonly [readonly [string, 1], 1]) } // array in array { - let val = _ as Immutable - assert(val, _ as ReadonlyArray>) + let val = _ as Immutable + assert(val, _ as ReadonlyArray>) } // tuple in object { - let val = _ as Immutable<{a: [string, 1]}> - assert(val, _ as {readonly a: readonly [string, 1]}) + let val = _ as Immutable<{a: [string, 1]}> + assert(val, _ as {readonly a: readonly [string, 1]}) } // object in tuple { - let val = _ as Immutable<[{a: string}, 1]> - assert(val, _ as readonly [{readonly a: string}, 1]) + let val = _ as Immutable<[{a: string}, 1]> + assert(val, _ as readonly [{readonly a: string}, 1]) } // array in object { - let val = _ as Immutable<{a: string[]}> - assert(val, _ as {readonly a: ReadonlyArray}) + let val = _ as Immutable<{a: string[]}> + assert(val, _ as {readonly a: ReadonlyArray}) } // object in array { - let val = _ as Immutable> - assert(val, _ as ReadonlyArray<{readonly a: string}>) + let val = _ as Immutable> + assert(val, _ as ReadonlyArray<{readonly a: string}>) } // object in object { - let val = _ as Immutable<{a: {b: string}}> - assert(val, _ as {readonly a: {readonly b: string}}) + let val = _ as Immutable<{a: {b: string}}> + assert(val, _ as {readonly a: {readonly b: string}}) } diff --git a/__tests__/manual.js b/__tests__/manual.js index ae27917b..52a504ca 100644 --- a/__tests__/manual.js +++ b/__tests__/manual.js @@ -1,136 +1,136 @@ "use strict" import { - setUseProxies, - createDraft, - finishDraft, - produce, - isDraft + setUseProxies, + createDraft, + finishDraft, + produce, + isDraft } from "../src/index" runTests("proxy", true) runTests("es5", false) function runTests(name, useProxies) { - describe("manual - " + name, () => { - setUseProxies(useProxies) - - it("should check arguments", () => { - expect(() => createDraft(3)).toThrowErrorMatchingSnapshot() - const buf = new Buffer([]) - expect(() => createDraft(buf)).toThrowErrorMatchingSnapshot() - expect(() => finishDraft({})).toThrowErrorMatchingSnapshot() - }) - - it("should support manual drafts", () => { - const state = [{}, {}, {}] - - const draft = createDraft(state) - draft.forEach((item, index) => { - item.index = index - }) - - const result = finishDraft(draft) - - expect(result).not.toBe(state) - expect(result).toEqual([{index: 0}, {index: 1}, {index: 2}]) - expect(state).toEqual([{}, {}, {}]) - }) - - it("cannot modify after finish", () => { - const state = {a: 1} - - const draft = createDraft(state) - draft.a = 2 - expect(finishDraft(draft)).toEqual({a: 2}) - expect(() => { - draft.a = 3 - }).toThrowErrorMatchingSnapshot() - }) - - it("should support patches drafts", () => { - const state = {a: 1} - - const draft = createDraft(state) - draft.a = 2 - draft.b = 3 - - const listener = jest.fn() - const result = finishDraft(draft, listener) - - expect(result).not.toBe(state) - expect(result).toEqual({a: 2, b: 3}) - expect(listener.mock.calls).toMatchSnapshot() - }) - - it("should handle multiple create draft calls", () => { - const state = {a: 1} - - const draft = createDraft(state) - draft.a = 2 - - const draft2 = createDraft(state) - draft2.b = 3 - - const result = finishDraft(draft) - - expect(result).not.toBe(state) - expect(result).toEqual({a: 2}) - - draft2.a = 4 - const result2 = finishDraft(draft2) - expect(result2).not.toBe(result) - expect(result2).toEqual({a: 4, b: 3}) - }) - - it("combines with produce - 1", () => { - const state = {a: 1} - - const draft = createDraft(state) - draft.a = 2 - const res1 = produce(draft, d => { - d.b = 3 - }) - draft.b = 4 - const res2 = finishDraft(draft) - expect(res1).toEqual({a: 2, b: 3}) - expect(res2).toEqual({a: 2, b: 4}) - }) - - it("combines with produce - 2", () => { - const state = {a: 1} - - const res1 = produce(state, draft => { - draft.b = 3 - const draft2 = createDraft(draft) - draft.c = 4 - draft2.d = 5 - const res2 = finishDraft(draft2) - expect(res2).toEqual({ - a: 1, - b: 3, - d: 5 - }) - draft.d = 2 - }) - expect(res1).toEqual({ - a: 1, - b: 3, - c: 4, - d: 2 - }) - }) - - it("should not finish drafts from produce", () => { - produce({x: 1}, draft => { - expect(() => finishDraft(draft)).toThrowErrorMatchingSnapshot() - }) - }) - - it("should not finish twice", () => { - const draft = createDraft({a: 1}) - draft.a++ - finishDraft(draft) - expect(() => finishDraft(draft)).toThrowErrorMatchingSnapshot() - }) - }) + describe("manual - " + name, () => { + setUseProxies(useProxies) + + it("should check arguments", () => { + expect(() => createDraft(3)).toThrowErrorMatchingSnapshot() + const buf = new Buffer([]) + expect(() => createDraft(buf)).toThrowErrorMatchingSnapshot() + expect(() => finishDraft({})).toThrowErrorMatchingSnapshot() + }) + + it("should support manual drafts", () => { + const state = [{}, {}, {}] + + const draft = createDraft(state) + draft.forEach((item, index) => { + item.index = index + }) + + const result = finishDraft(draft) + + expect(result).not.toBe(state) + expect(result).toEqual([{index: 0}, {index: 1}, {index: 2}]) + expect(state).toEqual([{}, {}, {}]) + }) + + it("cannot modify after finish", () => { + const state = {a: 1} + + const draft = createDraft(state) + draft.a = 2 + expect(finishDraft(draft)).toEqual({a: 2}) + expect(() => { + draft.a = 3 + }).toThrowErrorMatchingSnapshot() + }) + + it("should support patches drafts", () => { + const state = {a: 1} + + const draft = createDraft(state) + draft.a = 2 + draft.b = 3 + + const listener = jest.fn() + const result = finishDraft(draft, listener) + + expect(result).not.toBe(state) + expect(result).toEqual({a: 2, b: 3}) + expect(listener.mock.calls).toMatchSnapshot() + }) + + it("should handle multiple create draft calls", () => { + const state = {a: 1} + + const draft = createDraft(state) + draft.a = 2 + + const draft2 = createDraft(state) + draft2.b = 3 + + const result = finishDraft(draft) + + expect(result).not.toBe(state) + expect(result).toEqual({a: 2}) + + draft2.a = 4 + const result2 = finishDraft(draft2) + expect(result2).not.toBe(result) + expect(result2).toEqual({a: 4, b: 3}) + }) + + it("combines with produce - 1", () => { + const state = {a: 1} + + const draft = createDraft(state) + draft.a = 2 + const res1 = produce(draft, d => { + d.b = 3 + }) + draft.b = 4 + const res2 = finishDraft(draft) + expect(res1).toEqual({a: 2, b: 3}) + expect(res2).toEqual({a: 2, b: 4}) + }) + + it("combines with produce - 2", () => { + const state = {a: 1} + + const res1 = produce(state, draft => { + draft.b = 3 + const draft2 = createDraft(draft) + draft.c = 4 + draft2.d = 5 + const res2 = finishDraft(draft2) + expect(res2).toEqual({ + a: 1, + b: 3, + d: 5 + }) + draft.d = 2 + }) + expect(res1).toEqual({ + a: 1, + b: 3, + c: 4, + d: 2 + }) + }) + + it("should not finish drafts from produce", () => { + produce({x: 1}, draft => { + expect(() => finishDraft(draft)).toThrowErrorMatchingSnapshot() + }) + }) + + it("should not finish twice", () => { + const draft = createDraft({a: 1}) + draft.a++ + finishDraft(draft) + expect(() => finishDraft(draft)).toThrowErrorMatchingSnapshot() + }) + }) } diff --git a/__tests__/null-base.js b/__tests__/null-base.js index 752f61f1..8749d331 100644 --- a/__tests__/null-base.js +++ b/__tests__/null-base.js @@ -2,10 +2,10 @@ import produce from "../src/index" describe("null functionality", () => { - const baseState = null + const baseState = null - it("should return the original without modifications", () => { - const nextState = produce(baseState, () => {}) - expect(nextState).toBe(baseState) - }) + it("should return the original without modifications", () => { + const nextState = produce(baseState, () => {}) + expect(nextState).toBe(baseState) + }) }) diff --git a/__tests__/original.js b/__tests__/original.js index 5de7abc0..dac51f92 100644 --- a/__tests__/original.js +++ b/__tests__/original.js @@ -2,48 +2,48 @@ import produce, {original, setUseProxies} from "../src/index" describe("original", () => { - const baseState = { - a: [], - b: {} - } - - it("should return the original from the draft", () => { - setUseProxies(true) - - produce(baseState, draftState => { - expect(original(draftState)).toBe(baseState) - expect(original(draftState.a)).toBe(baseState.a) - expect(original(draftState.b)).toBe(baseState.b) - }) - - setUseProxies(false) - - produce(baseState, draftState => { - expect(original(draftState)).toBe(baseState) - expect(original(draftState.a)).toBe(baseState.a) - expect(original(draftState.b)).toBe(baseState.b) - }) - }) - - it("should return the original from the proxy", () => { - produce(baseState, draftState => { - expect(original(draftState)).toBe(baseState) - expect(original(draftState.a)).toBe(baseState.a) - expect(original(draftState.b)).toBe(baseState.b) - }) - }) - - it("should return 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() - }) - }) - - it("should return undefined for an object that is not proxied", () => { - expect(original({})).toBeUndefined() - expect(original(3)).toBeUndefined() - }) + const baseState = { + a: [], + b: {} + } + + it("should return the original from the draft", () => { + setUseProxies(true) + + produce(baseState, draftState => { + expect(original(draftState)).toBe(baseState) + expect(original(draftState.a)).toBe(baseState.a) + expect(original(draftState.b)).toBe(baseState.b) + }) + + setUseProxies(false) + + produce(baseState, draftState => { + expect(original(draftState)).toBe(baseState) + expect(original(draftState.a)).toBe(baseState.a) + expect(original(draftState.b)).toBe(baseState.b) + }) + }) + + it("should return the original from the proxy", () => { + produce(baseState, draftState => { + expect(original(draftState)).toBe(baseState) + expect(original(draftState.a)).toBe(baseState.a) + expect(original(draftState.b)).toBe(baseState.b) + }) + }) + + it("should return 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() + }) + }) + + it("should return undefined for an object that is not proxied", () => { + expect(original({})).toBeUndefined() + expect(original(3)).toBeUndefined() + }) }) diff --git a/__tests__/patch.js b/__tests__/patch.js index ecc457f3..fa38724a 100644 --- a/__tests__/patch.js +++ b/__tests__/patch.js @@ -4,419 +4,413 @@ import produce, {setUseProxies, applyPatches} from "../src/index" jest.setTimeout(1000) function runPatchTest(base, producer, patches, inversePathes) { - function runPatchTestHelper() { - let recordedPatches - let recordedInversePatches - const res = produce(base, producer, (p, i) => { - recordedPatches = p - recordedInversePatches = i - }) - - test("produces the correct patches", () => { - expect(recordedPatches).toEqual(patches) - if (inversePathes) - expect(recordedInversePatches).toEqual(inversePathes) - }) - - test("patches are replayable", () => { - expect(applyPatches(base, recordedPatches)).toEqual(res) - }) - - test("patches can be reversed", () => { - expect(applyPatches(res, recordedInversePatches)).toEqual(base) - }) - } - - describe(`proxy`, () => { - setUseProxies(true) - runPatchTestHelper() - }) - - describe(`es5`, () => { - setUseProxies(false) - runPatchTestHelper() - }) + function runPatchTestHelper() { + let recordedPatches + let recordedInversePatches + const res = produce(base, producer, (p, i) => { + recordedPatches = p + recordedInversePatches = i + }) + + test("produces the correct patches", () => { + expect(recordedPatches).toEqual(patches) + if (inversePathes) expect(recordedInversePatches).toEqual(inversePathes) + }) + + test("patches are replayable", () => { + expect(applyPatches(base, recordedPatches)).toEqual(res) + }) + + test("patches can be reversed", () => { + expect(applyPatches(res, recordedInversePatches)).toEqual(base) + }) + } + + describe(`proxy`, () => { + setUseProxies(true) + runPatchTestHelper() + }) + + describe(`es5`, () => { + setUseProxies(false) + runPatchTestHelper() + }) } describe("applyPatches", () => { - it("mutates the base state when it is a draft", () => { - produce({a: 1}, draft => { - const result = applyPatches(draft, [ - {op: "replace", path: ["a"], value: 2} - ]) - expect(result).toBe(draft) - expect(draft.a).toBe(2) - }) - }) - it("produces a copy of the base state when not a draft", () => { - const base = {a: 1} - const result = applyPatches(base, [ - {op: "replace", path: ["a"], value: 2} - ]) - expect(result).not.toBe(base) - expect(result.a).toBe(2) - expect(base.a).toBe(1) - }) - it('throws when `op` is not "add", "replace", nor "remove"', () => { - expect(() => { - const patch = {op: "copy", from: [0], path: [1]} - applyPatches([2], [patch]) - }).toThrowErrorMatchingSnapshot() - }) - it("throws when `path` cannot be resolved", () => { - // missing parent - expect(() => { - const patch = {op: "add", path: ["a", "b"], value: 1} - applyPatches({}, [patch]) - }).toThrowErrorMatchingSnapshot() - - // missing grand-parent - expect(() => { - const patch = {op: "add", path: ["a", "b", "c"], value: 1} - applyPatches({}, [patch]) - }).toThrowErrorMatchingSnapshot() - }) + it("mutates the base state when it is a draft", () => { + produce({a: 1}, draft => { + const result = applyPatches(draft, [ + {op: "replace", path: ["a"], value: 2} + ]) + expect(result).toBe(draft) + expect(draft.a).toBe(2) + }) + }) + it("produces a copy of the base state when not a draft", () => { + const base = {a: 1} + const result = applyPatches(base, [{op: "replace", path: ["a"], value: 2}]) + expect(result).not.toBe(base) + expect(result.a).toBe(2) + expect(base.a).toBe(1) + }) + it('throws when `op` is not "add", "replace", nor "remove"', () => { + expect(() => { + const patch = {op: "copy", from: [0], path: [1]} + applyPatches([2], [patch]) + }).toThrowErrorMatchingSnapshot() + }) + it("throws when `path` cannot be resolved", () => { + // missing parent + expect(() => { + const patch = {op: "add", path: ["a", "b"], value: 1} + applyPatches({}, [patch]) + }).toThrowErrorMatchingSnapshot() + + // missing grand-parent + expect(() => { + const patch = {op: "add", path: ["a", "b", "c"], value: 1} + applyPatches({}, [patch]) + }).toThrowErrorMatchingSnapshot() + }) }) describe("simple assignment - 1", () => { - runPatchTest( - {x: 3}, - d => { - d.x++ - }, - [{op: "replace", path: ["x"], value: 4}] - ) + runPatchTest( + {x: 3}, + d => { + d.x++ + }, + [{op: "replace", path: ["x"], value: 4}] + ) }) describe("simple assignment - 2", () => { - runPatchTest( - {x: {y: 4}}, - d => { - d.x.y++ - }, - [{op: "replace", path: ["x", "y"], value: 5}] - ) + runPatchTest( + {x: {y: 4}}, + d => { + d.x.y++ + }, + [{op: "replace", path: ["x", "y"], value: 5}] + ) }) describe("simple assignment - 3", () => { - runPatchTest( - {x: [{y: 4}]}, - d => { - d.x[0].y++ - }, - [{op: "replace", path: ["x", 0, "y"], value: 5}] - ) + runPatchTest( + {x: [{y: 4}]}, + d => { + d.x[0].y++ + }, + [{op: "replace", path: ["x", 0, "y"], value: 5}] + ) }) describe("delete 1", () => { - runPatchTest( - {x: {y: 4}}, - d => { - delete d.x - }, - [{op: "remove", path: ["x"]}] - ) + runPatchTest( + {x: {y: 4}}, + d => { + delete d.x + }, + [{op: "remove", path: ["x"]}] + ) }) describe("renaming properties", () => { - describe("nested object (no changes)", () => { - runPatchTest( - {a: {b: 1}}, - d => { - d.x = d.a - delete d.a - }, - [ - {op: "add", path: ["x"], value: {b: 1}}, - {op: "remove", path: ["a"]} - ] - ) - }) - - describe("nested object (with changes)", () => { - runPatchTest( - {a: {b: 1, c: 1}}, - d => { - let a = d.a - a.b = 2 // change - delete a.c // delete - a.y = 2 // add - - // rename - d.x = a - delete d.a - }, - [ - {op: "add", path: ["x"], value: {b: 2, y: 2}}, - {op: "remove", path: ["a"]} - ] - ) - }) - - describe("deeply nested object (with changes)", () => { - runPatchTest( - {a: {b: {c: 1, d: 1}}}, - d => { - let b = d.a.b - b.c = 2 // change - delete b.d // delete - b.y = 2 // add - - // rename - d.a.x = b - delete d.a.b - }, - [ - {op: "add", path: ["a", "x"], value: {c: 2, y: 2}}, - {op: "remove", path: ["a", "b"]} - ] - ) - }) + describe("nested object (no changes)", () => { + runPatchTest( + {a: {b: 1}}, + d => { + d.x = d.a + delete d.a + }, + [{op: "add", path: ["x"], value: {b: 1}}, {op: "remove", path: ["a"]}] + ) + }) + + describe("nested object (with changes)", () => { + runPatchTest( + {a: {b: 1, c: 1}}, + d => { + let a = d.a + a.b = 2 // change + delete a.c // delete + a.y = 2 // add + + // rename + d.x = a + delete d.a + }, + [ + {op: "add", path: ["x"], value: {b: 2, y: 2}}, + {op: "remove", path: ["a"]} + ] + ) + }) + + describe("deeply nested object (with changes)", () => { + runPatchTest( + {a: {b: {c: 1, d: 1}}}, + d => { + let b = d.a.b + b.c = 2 // change + delete b.d // delete + b.y = 2 // add + + // rename + d.a.x = b + delete d.a.b + }, + [ + {op: "add", path: ["a", "x"], value: {c: 2, y: 2}}, + {op: "remove", path: ["a", "b"]} + ] + ) + }) }) describe("minimum amount of changes", () => { - runPatchTest( - {x: 3, y: {a: 4}, z: 3}, - d => { - d.y.a = 4 - d.y.b = 5 - Object.assign(d, {x: 4, y: {a: 2}}) - }, - [ - {op: "replace", path: ["x"], value: 4}, - {op: "replace", path: ["y"], value: {a: 2}} - ] - ) + runPatchTest( + {x: 3, y: {a: 4}, z: 3}, + d => { + d.y.a = 4 + d.y.b = 5 + Object.assign(d, {x: 4, y: {a: 2}}) + }, + [ + {op: "replace", path: ["x"], value: 4}, + {op: "replace", path: ["y"], value: {a: 2}} + ] + ) }) describe("arrays - prepend", () => { - runPatchTest( - {x: [1, 2, 3]}, - d => { - d.x.unshift(4) - }, - [{op: "add", path: ["x", 0], value: 4}] - ) + runPatchTest( + {x: [1, 2, 3]}, + d => { + d.x.unshift(4) + }, + [{op: "add", path: ["x", 0], value: 4}] + ) }) describe("arrays - multiple prepend", () => { - runPatchTest( - {x: [1, 2, 3]}, - d => { - d.x.unshift(4) - d.x.unshift(5) - }, - [ - {op: "add", path: ["x", 0], value: 5}, - {op: "add", path: ["x", 1], value: 4} - ] - ) + runPatchTest( + {x: [1, 2, 3]}, + d => { + d.x.unshift(4) + d.x.unshift(5) + }, + [ + {op: "add", path: ["x", 0], value: 5}, + {op: "add", path: ["x", 1], value: 4} + ] + ) }) describe("arrays - splice middle", () => { - runPatchTest( - {x: [1, 2, 3]}, - d => { - d.x.splice(1, 1) - }, - [{op: "remove", path: ["x", 1]}] - ) + runPatchTest( + {x: [1, 2, 3]}, + d => { + d.x.splice(1, 1) + }, + [{op: "remove", path: ["x", 1]}] + ) }) describe("arrays - multiple splice", () => { - runPatchTest( - [0, 1, 2, 3, 4, 5, 0], - d => { - d.splice(4, 2, 3) - d.splice(1, 2, 3) - }, - [ - {op: "replace", path: [1], value: 3}, - {op: "replace", path: [2], value: 3}, - {op: "remove", path: [5]}, - {op: "remove", path: [4]} - ] - ) + runPatchTest( + [0, 1, 2, 3, 4, 5, 0], + d => { + d.splice(4, 2, 3) + d.splice(1, 2, 3) + }, + [ + {op: "replace", path: [1], value: 3}, + {op: "replace", path: [2], value: 3}, + {op: "remove", path: [5]}, + {op: "remove", path: [4]} + ] + ) }) describe("arrays - modify and shrink", () => { - runPatchTest( - {x: [1, 2, 3]}, - d => { - d.x[0] = 4 - d.x.length = 2 - }, - [ - {op: "replace", path: ["x", 0], value: 4}, - {op: "replace", path: ["x", "length"], value: 2} - ] - ) + runPatchTest( + {x: [1, 2, 3]}, + d => { + d.x[0] = 4 + d.x.length = 2 + }, + [ + {op: "replace", path: ["x", 0], value: 4}, + {op: "replace", path: ["x", "length"], value: 2} + ] + ) }) describe("arrays - prepend then splice middle", () => { - runPatchTest( - {x: [1, 2, 3]}, - d => { - d.x.unshift(4) - d.x.splice(2, 1) - }, - [ - {op: "replace", path: ["x", 0], value: 4}, - {op: "replace", path: ["x", 1], value: 1} - ] - ) + runPatchTest( + {x: [1, 2, 3]}, + d => { + d.x.unshift(4) + d.x.splice(2, 1) + }, + [ + {op: "replace", path: ["x", 0], value: 4}, + {op: "replace", path: ["x", 1], value: 1} + ] + ) }) describe("arrays - splice middle then prepend", () => { - runPatchTest( - {x: [1, 2, 3]}, - d => { - d.x.splice(1, 1) - d.x.unshift(4) - }, - [ - {op: "replace", path: ["x", 0], value: 4}, - {op: "replace", path: ["x", 1], value: 1} - ] - ) + runPatchTest( + {x: [1, 2, 3]}, + d => { + d.x.splice(1, 1) + d.x.unshift(4) + }, + [ + {op: "replace", path: ["x", 0], value: 4}, + {op: "replace", path: ["x", 1], value: 1} + ] + ) }) describe("arrays - truncate", () => { - runPatchTest( - {x: [1, 2, 3]}, - d => { - d.x.length -= 2 - }, - [{op: "replace", path: ["x", "length"], value: 1}], - [ - {op: "add", path: ["x", 1], value: 2}, - {op: "add", path: ["x", 2], value: 3} - ] - ) + runPatchTest( + {x: [1, 2, 3]}, + d => { + d.x.length -= 2 + }, + [{op: "replace", path: ["x", "length"], value: 1}], + [ + {op: "add", path: ["x", 1], value: 2}, + {op: "add", path: ["x", 2], value: 3} + ] + ) }) describe("arrays - pop twice", () => { - runPatchTest( - {x: [1, 2, 3]}, - d => { - d.x.pop() - d.x.pop() - }, - [{op: "replace", path: ["x", "length"], value: 1}] - ) + runPatchTest( + {x: [1, 2, 3]}, + d => { + d.x.pop() + d.x.pop() + }, + [{op: "replace", path: ["x", "length"], value: 1}] + ) }) describe("arrays - push multiple", () => { - runPatchTest( - {x: [1, 2, 3]}, - d => { - d.x.push(4, 5) - }, - [ - {op: "add", path: ["x", 3], value: 4}, - {op: "add", path: ["x", 4], value: 5} - ], - [{op: "replace", path: ["x", "length"], value: 3}] - ) + runPatchTest( + {x: [1, 2, 3]}, + d => { + d.x.push(4, 5) + }, + [ + {op: "add", path: ["x", 3], value: 4}, + {op: "add", path: ["x", 4], value: 5} + ], + [{op: "replace", path: ["x", "length"], value: 3}] + ) }) describe("arrays - splice (expand)", () => { - runPatchTest( - {x: [1, 2, 3]}, - d => { - d.x.splice(1, 1, 4, 5, 6) - }, - [ - {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", 1], value: 2}, - {op: "remove", path: ["x", 3]}, - {op: "remove", path: ["x", 2]} - ] - ) + runPatchTest( + {x: [1, 2, 3]}, + d => { + d.x.splice(1, 1, 4, 5, 6) + }, + [ + {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", 1], value: 2}, + {op: "remove", path: ["x", 3]}, + {op: "remove", path: ["x", 2]} + ] + ) }) describe("arrays - splice (shrink)", () => { - runPatchTest( - {x: [1, 2, 3, 4, 5]}, - d => { - d.x.splice(1, 3, 6) - }, - [ - {op: "replace", path: ["x", 1], value: 6}, - {op: "remove", path: ["x", 3]}, - {op: "remove", path: ["x", 2]} - ], - [ - {op: "replace", path: ["x", 1], value: 2}, - {op: "add", path: ["x", 2], value: 3}, - {op: "add", path: ["x", 3], value: 4} - ] - ) + runPatchTest( + {x: [1, 2, 3, 4, 5]}, + d => { + d.x.splice(1, 3, 6) + }, + [ + {op: "replace", path: ["x", 1], value: 6}, + {op: "remove", path: ["x", 3]}, + {op: "remove", path: ["x", 2]} + ], + [ + {op: "replace", path: ["x", 1], value: 2}, + {op: "add", path: ["x", 2], value: 3}, + {op: "add", path: ["x", 3], value: 4} + ] + ) }) describe("simple replacement", () => { - runPatchTest({x: 3}, _d => 4, [{op: "replace", path: [], value: 4}]) + runPatchTest({x: 3}, _d => 4, [{op: "replace", path: [], value: 4}]) }) describe("same value replacement - 1", () => { - runPatchTest( - {x: {y: 3}}, - d => { - const a = d.x - d.x = a - }, - [] - ) + runPatchTest( + {x: {y: 3}}, + d => { + const a = d.x + d.x = a + }, + [] + ) }) describe("same value replacement - 2", () => { - runPatchTest( - {x: {y: 3}}, - d => { - const a = d.x - d.x = 4 - d.x = a - }, - [] - ) + runPatchTest( + {x: {y: 3}}, + d => { + const a = d.x + d.x = 4 + d.x = a + }, + [] + ) }) describe("same value replacement - 3", () => { - runPatchTest( - {x: 3}, - d => { - d.x = 3 - }, - [] - ) + runPatchTest( + {x: 3}, + d => { + d.x = 3 + }, + [] + ) }) describe("same value replacement - 4", () => { - runPatchTest( - {x: 3}, - d => { - d.x = 4 - d.x = 3 - }, - [] - ) + runPatchTest( + {x: 3}, + d => { + d.x = 4 + d.x = 3 + }, + [] + ) }) describe("simple delete", () => { - runPatchTest( - {x: 2}, - d => { - delete d.x - }, - [ - { - op: "remove", - path: ["x"] - } - ] - ) + runPatchTest( + {x: 2}, + d => { + delete d.x + }, + [ + { + op: "remove", + path: ["x"] + } + ] + ) }) diff --git a/__tests__/polyfills.js b/__tests__/polyfills.js index 3c47ce63..849a0d92 100644 --- a/__tests__/polyfills.js +++ b/__tests__/polyfills.js @@ -15,56 +15,56 @@ Object.assign = assign Reflect.ownKeys = ownKeys describe("Symbol", () => { - test("NOTHING", () => { - const value = common.NOTHING - expect(value).toBeTruthy() - expect(typeof value).toBe("object") - }) - test("DRAFTABLE", () => { - const value = common.DRAFTABLE - expect(typeof value).toBe("string") - }) - test("DRAFT_STATE", () => { - const value = common.DRAFT_STATE - expect(typeof value).toBe("string") - }) + test("NOTHING", () => { + const value = common.NOTHING + expect(value).toBeTruthy() + expect(typeof value).toBe("object") + }) + test("DRAFTABLE", () => { + const value = common.DRAFTABLE + expect(typeof value).toBe("string") + }) + test("DRAFT_STATE", () => { + const value = common.DRAFT_STATE + expect(typeof value).toBe("string") + }) }) describe("Object.assign", () => { - const {assign} = common + const {assign} = common - it("only copies enumerable keys", () => { - const src = {a: 1} - Object.defineProperty(src, "b", {value: 1}) - const dest = {} - assign(dest, src) - expect(dest.a).toBe(1) - expect(dest.b).toBeUndefined() - }) + it("only copies enumerable keys", () => { + const src = {a: 1} + Object.defineProperty(src, "b", {value: 1}) + const dest = {} + assign(dest, src) + expect(dest.a).toBe(1) + expect(dest.b).toBeUndefined() + }) - it("only copies own properties", () => { - const src = Object.create({a: 1}) - src.b = 1 - const dest = {} - assign(dest, src) - expect(dest.a).toBeUndefined() - expect(dest.b).toBe(1) - }) + it("only copies own properties", () => { + const src = Object.create({a: 1}) + src.b = 1 + const dest = {} + assign(dest, src) + expect(dest.a).toBeUndefined() + expect(dest.b).toBe(1) + }) }) describe("Reflect.ownKeys", () => { - const {ownKeys} = common + const {ownKeys} = common - // Symbol keys are always last. - it("includes symbol keys", () => { - const s = SymbolConstructor() - const obj = {[s]: 1, b: 1} - expect(ownKeys(obj)).toEqual(["b", s]) - }) + // Symbol keys are always last. + it("includes symbol keys", () => { + const s = SymbolConstructor() + const obj = {[s]: 1, b: 1} + expect(ownKeys(obj)).toEqual(["b", s]) + }) - it("includes non-enumerable keys", () => { - const obj = {a: 1} - Object.defineProperty(obj, "b", {value: 1}) - expect(ownKeys(obj)).toEqual(["a", "b"]) - }) + it("includes non-enumerable keys", () => { + const obj = {a: 1} + Object.defineProperty(obj, "b", {value: 1}) + expect(ownKeys(obj)).toEqual(["a", "b"]) + }) }) diff --git a/__tests__/produce.ts b/__tests__/produce.ts index 5ba34354..fa48b4f9 100644 --- a/__tests__/produce.ts +++ b/__tests__/produce.ts @@ -1,418 +1,418 @@ import {assert, _} from "spec.ts" import produce, { - produce as produce2, - applyPatches, - Patch, - nothing, - Draft, - Immutable + produce as produce2, + applyPatches, + Patch, + nothing, + Draft, + Immutable } from "../dist/immer.js" interface State { - readonly num: number - readonly foo?: string - bar: string - readonly baz: { - readonly x: number - readonly y: number - } - readonly arr: ReadonlyArray<{readonly value: string}> - readonly arr2: {readonly value: string}[] + readonly num: number + readonly foo?: string + bar: string + readonly baz: { + readonly x: number + readonly y: number + } + readonly arr: ReadonlyArray<{readonly value: string}> + readonly arr2: {readonly value: string}[] } const state: State = { - num: 0, - bar: "foo", - baz: { - x: 1, - y: 2 - }, - arr: [{value: "asdf"}], - arr2: [{value: "asdf"}] + num: 0, + bar: "foo", + baz: { + x: 1, + y: 2 + }, + arr: [{value: "asdf"}], + arr2: [{value: "asdf"}] } const expectedState: State = { - num: 1, - foo: "bar", - bar: "foo", - baz: { - x: 2, - y: 3 - }, - arr: [{value: "foo"}, {value: "asf"}], - arr2: [{value: "foo"}, {value: "asf"}] + num: 1, + foo: "bar", + bar: "foo", + baz: { + x: 2, + y: 3 + }, + arr: [{value: "foo"}, {value: "asf"}], + arr2: [{value: "foo"}, {value: "asf"}] } it("can update readonly state via standard api", () => { - const newState = produce(state, draft => { - draft.num++ - draft.foo = "bar" - draft.bar = "foo" - draft.baz.x++ - draft.baz.y++ - draft.arr[0].value = "foo" - draft.arr.push({value: "asf"}) - draft.arr2[0].value = "foo" - draft.arr2.push({value: "asf"}) - }) - assert(newState, state) + const newState = produce(state, draft => { + draft.num++ + draft.foo = "bar" + draft.bar = "foo" + draft.baz.x++ + draft.baz.y++ + draft.arr[0].value = "foo" + draft.arr.push({value: "asf"}) + draft.arr2[0].value = "foo" + draft.arr2.push({value: "asf"}) + }) + assert(newState, state) }) // NOTE: only when the function type is inferred it("can infer state type from default state", () => { - type State = {readonly a: number} - type Recipe = (state?: S | undefined) => S + type State = {readonly a: number} + type Recipe = (state?: S | undefined) => S - let foo = produce((_: any) => {}, _ as State) - assert(foo, _ as Recipe) + let foo = produce((_: any) => {}, _ as State) + assert(foo, _ as Recipe) }) it("can infer state type from recipe function", () => { - type State = {readonly a: string} | {readonly b: string} - type Recipe = (state: S) => S + type State = {readonly a: string} | {readonly b: string} + type Recipe = (state: S) => S - let foo = produce((_: Draft) => {}) - assert(foo, _ as Recipe) + let foo = produce((_: Draft) => {}) + assert(foo, _ as Recipe) }) it("can infer state type from recipe function with arguments", () => { - type State = {readonly a: string} | {readonly b: string} - type Recipe = (state: S, x: number) => S + type State = {readonly a: string} | {readonly b: string} + type Recipe = (state: S, x: number) => S - let foo = produce((draft: Draft, x: number) => {}) - assert(foo, _ as Recipe) + let foo = produce((draft: Draft, x: number) => {}) + assert(foo, _ as Recipe) }) it("can infer state type from recipe function with arguments and initial state", () => { - type State = {readonly a: string} | {readonly b: string} - type Recipe = (state: S | undefined, x: number) => S + type State = {readonly a: string} | {readonly b: string} + type Recipe = (state: S | undefined, x: number) => S - let foo = produce((draft: Draft, x: number) => {}, _ as State) - assert(foo, _ as Recipe) + let foo = produce((draft: Draft, x: number) => {}, _ as State) + assert(foo, _ as Recipe) }) it("cannot infer state type when the function type and default state are missing", () => { - type Recipe = (state: S) => S - const foo = produce((_: any) => {}) - assert(foo, _ as Recipe) + type Recipe = (state: S) => S + const foo = produce((_: any) => {}) + assert(foo, _ as Recipe) }) it("can update readonly state via curried api", () => { - const newState = produce((draft: Draft) => { - draft.num++ - draft.foo = "bar" - draft.bar = "foo" - draft.baz.x++ - draft.baz.y++ - draft.arr[0].value = "foo" - draft.arr.push({value: "asf"}) - draft.arr2[0].value = "foo" - draft.arr2.push({value: "asf"}) - })(state) - expect(newState).not.toBe(state) - expect(newState).toEqual(expectedState) + const newState = produce((draft: Draft) => { + draft.num++ + draft.foo = "bar" + draft.bar = "foo" + draft.baz.x++ + draft.baz.y++ + draft.arr[0].value = "foo" + draft.arr.push({value: "asf"}) + draft.arr2[0].value = "foo" + draft.arr2.push({value: "asf"}) + })(state) + expect(newState).not.toBe(state) + expect(newState).toEqual(expectedState) }) it("can update use the non-default export", () => { - const newState = produce2((draft: Draft) => { - draft.num++ - draft.foo = "bar" - draft.bar = "foo" - draft.baz.x++ - draft.baz.y++ - draft.arr[0].value = "foo" - draft.arr.push({value: "asf"}) - draft.arr2[0].value = "foo" - draft.arr2.push({value: "asf"}) - })(state) - expect(newState).not.toBe(state) - expect(newState).toEqual(expectedState) + const newState = produce2((draft: Draft) => { + draft.num++ + draft.foo = "bar" + draft.bar = "foo" + draft.baz.x++ + draft.baz.y++ + draft.arr[0].value = "foo" + draft.arr.push({value: "asf"}) + draft.arr2[0].value = "foo" + draft.arr2.push({value: "asf"}) + })(state) + expect(newState).not.toBe(state) + expect(newState).toEqual(expectedState) }) it("can apply patches", () => { - let patches: Patch[] = [] - produce( - {x: 3}, - d => { - d.x++ - }, - p => { - patches = p - } - ) - - expect(applyPatches({}, patches)).toEqual({x: 4}) + let patches: Patch[] = [] + produce( + {x: 3}, + d => { + d.x++ + }, + p => { + patches = p + } + ) + + expect(applyPatches({}, patches)).toEqual({x: 4}) }) describe("curried producer", () => { - it("supports rest parameters", () => { - type State = {readonly a: 1} - - // No initial state: - { - type Recipe = (state: S, a: number, b: number) => S - let foo = produce((s: State, a: number, b: number) => {}) - assert(foo, _ as Recipe) - foo(_ as State, 1, 2) - } - - // Using argument parameters: - { - type Recipe = (state: S, ...rest: number[]) => S - let woo = produce((state: Draft, ...args: number[]) => {}) - assert(woo, _ as Recipe) - woo(_ as State, 1, 2) - } - - // With initial state: - { - type Recipe = ( - state?: S | undefined, - ...rest: number[] - ) => S - let bar = produce( - (state: Draft, ...args: number[]) => {}, - _ as State - ) - assert(bar, _ as Recipe) - bar(_ as State, 1, 2) - bar(_ as State) - bar() - } - - // When args is a tuple: - { - type Recipe = ( - state: S | undefined, - first: string, - ...rest: number[] - ) => S - let tup = produce( - (state: Draft, ...args: [string, ...number[]]) => {}, - _ as State - ) - assert(tup, _ as Recipe) - tup({a: 1}, "", 2) - tup(undefined, "", 2) - } - }) - - it("can be passed a readonly array", () => { - // No initial state: - { - let foo = produce((state: string[]) => {}) - assert(foo, _ as (state: S) => S) - foo([] as ReadonlyArray) - } - - // With initial state: - { - let bar = produce(() => {}, [] as ReadonlyArray) - assert(bar, _ as ( - state?: S | undefined - ) => S) - bar([] as ReadonlyArray) - bar(undefined) - bar() - } - }) + it("supports rest parameters", () => { + type State = {readonly a: 1} + + // No initial state: + { + type Recipe = (state: S, a: number, b: number) => S + let foo = produce((s: State, a: number, b: number) => {}) + assert(foo, _ as Recipe) + foo(_ as State, 1, 2) + } + + // Using argument parameters: + { + type Recipe = (state: S, ...rest: number[]) => S + let woo = produce((state: Draft, ...args: number[]) => {}) + assert(woo, _ as Recipe) + woo(_ as State, 1, 2) + } + + // With initial state: + { + type Recipe = ( + state?: S | undefined, + ...rest: number[] + ) => S + let bar = produce( + (state: Draft, ...args: number[]) => {}, + _ as State + ) + assert(bar, _ as Recipe) + bar(_ as State, 1, 2) + bar(_ as State) + bar() + } + + // When args is a tuple: + { + type Recipe = ( + state: S | undefined, + first: string, + ...rest: number[] + ) => S + let tup = produce( + (state: Draft, ...args: [string, ...number[]]) => {}, + _ as State + ) + assert(tup, _ as Recipe) + tup({a: 1}, "", 2) + tup(undefined, "", 2) + } + }) + + it("can be passed a readonly array", () => { + // No initial state: + { + let foo = produce((state: string[]) => {}) + assert(foo, _ as (state: S) => S) + foo([] as ReadonlyArray) + } + + // With initial state: + { + let bar = produce(() => {}, [] as ReadonlyArray) + assert(bar, _ as ( + state?: S | undefined + ) => S) + bar([] as ReadonlyArray) + bar(undefined) + bar() + } + }) }) it("works with return type of: number", () => { - let base = _ as {a: number} - let result = produce(base, () => 1) - assert(result, _ as number) + let base = _ as {a: number} + let result = produce(base, () => 1) + assert(result, _ as number) }) it("works with return type of: number | undefined", () => { - let base = _ as {a: number} - let result = produce(base, draft => { - return draft.a < 0 ? 0 : undefined - }) - assert(result, _ as {a: number} | number) + let base = _ as {a: number} + let result = produce(base, draft => { + return draft.a < 0 ? 0 : undefined + }) + assert(result, _ as {a: number} | number) }) it("can return an object type that is identical to the base type", () => { - let base = _ as {a: number} - let result = produce(base, draft => { - return draft.a < 0 ? {a: 0} : undefined - }) - // TODO: Can we resolve the weird union of identical object types? - assert(result, _ as {a: number} | {a: number}) + let base = _ as {a: number} + let result = produce(base, draft => { + return draft.a < 0 ? {a: 0} : undefined + }) + // TODO: Can we resolve the weird union of identical object types? + assert(result, _ as {a: number} | {a: number}) }) it("can return an object type that is _not_ assignable to the base type", () => { - let base = _ as {a: number} - let result = produce(base, draft => { - return draft.a < 0 ? {a: true} : undefined - }) - assert(result, _ as {a: number} | {a: boolean}) + let base = _ as {a: number} + let result = produce(base, draft => { + return draft.a < 0 ? {a: true} : undefined + }) + assert(result, _ as {a: number} | {a: boolean}) }) it("does not enforce immutability at the type level", () => { - let result = produce([] as any[], draft => { - draft.push(1) - }) - assert(result, _ as any[]) + let result = produce([] as any[], draft => { + draft.push(1) + }) + assert(result, _ as any[]) }) it("can produce an undefined value", () => { - let base = _ as {readonly a: number} + let base = _ as {readonly a: number} - // Return only nothing. - let result = produce(base, _ => nothing) - assert(result, undefined) + // Return only nothing. + let result = produce(base, _ => nothing) + assert(result, undefined) - // Return maybe nothing. - let result2 = produce(base, draft => { - if (draft.a > 0) return nothing - }) - assert(result2, _ as typeof base | undefined) + // Return maybe nothing. + let result2 = produce(base, draft => { + if (draft.a > 0) return nothing + }) + assert(result2, _ as typeof base | undefined) }) it("can return the draft itself", () => { - let base = _ as {readonly a: number} - let result = produce(base, draft => draft) + let base = _ as {readonly a: number} + let result = produce(base, draft => draft) - // Currently, the `readonly` modifier is lost. - assert(result, _ as {a: number}) + // Currently, the `readonly` modifier is lost. + assert(result, _ as {a: number}) }) it("can return a promise", () => { - type Base = {readonly a: number} - let base = _ as Base - - // Return a promise only. - let res1 = produce(base, draft => { - return Promise.resolve(draft.a > 0 ? null : undefined) - }) - assert(res1, _ as Promise) - - // Return a promise or undefined. - let res2 = produce(base, draft => { - if (draft.a > 0) return Promise.resolve() - }) - assert(res2, _ as Base | Promise) + type Base = {readonly a: number} + let base = _ as Base + + // Return a promise only. + let res1 = produce(base, draft => { + return Promise.resolve(draft.a > 0 ? null : undefined) + }) + assert(res1, _ as Promise) + + // Return a promise or undefined. + let res2 = produce(base, draft => { + if (draft.a > 0) return Promise.resolve() + }) + assert(res2, _ as Base | Promise) }) it("works with `void` hack", () => { - let base = _ as {readonly a: number} - let copy = produce(base, s => void s.a++) - assert(copy, base) + let base = _ as {readonly a: number} + let copy = produce(base, s => void s.a++) + assert(copy, base) }) it("works with generic parameters", () => { - let insert = (array: readonly T[], index: number, elem: T) => { - // Need explicit cast on draft as T[] is wider than readonly T[] - return produce(array, (draft: T[]) => { - draft.push(elem) - draft.splice(index, 0, elem) - draft.concat([elem]) - }) - } - let val: {readonly a: ReadonlyArray} = 0 as any - let arr: ReadonlyArray = 0 as any - insert(arr, 0, val) + let insert = (array: readonly T[], index: number, elem: T) => { + // Need explicit cast on draft as T[] is wider than readonly T[] + return produce(array, (draft: T[]) => { + draft.push(elem) + draft.splice(index, 0, elem) + draft.concat([elem]) + }) + } + let val: {readonly a: ReadonlyArray} = 0 as any + let arr: ReadonlyArray = 0 as any + insert(arr, 0, val) }) it("can work with non-readonly base types", () => { - const state = { - price: 10, - todos: [ - { - title: "test", - done: false - } - ] - } - type State = typeof state - - const newState: State = produce(state, draft => { - draft.price += 5 - draft.todos.push({ - title: "hi", - done: true - }) - }) - - const reducer = (draft: State) => { - draft.price += 5 - draft.todos.push({ - title: "hi", - done: true - }) - } - - // base case for with-initial-state - const newState4 = produce(reducer, state)(state) - assert(newState4, _ as State) - // no argument case, in that case, immutable version recipe first arg will be inferred - const newState5 = produce(reducer, state)() - assert(newState5, _ as Immutable) - // we can force the return type of the reducer by passing the generic argument - const newState3 = produce(reducer, state)() - assert(newState3, _ as State) + const state = { + price: 10, + todos: [ + { + title: "test", + done: false + } + ] + } + type State = typeof state + + const newState: State = produce(state, draft => { + draft.price += 5 + draft.todos.push({ + title: "hi", + done: true + }) + }) + + const reducer = (draft: State) => { + draft.price += 5 + draft.todos.push({ + title: "hi", + done: true + }) + } + + // base case for with-initial-state + const newState4 = produce(reducer, state)(state) + assert(newState4, _ as State) + // no argument case, in that case, immutable version recipe first arg will be inferred + const newState5 = produce(reducer, state)() + assert(newState5, _ as Immutable) + // we can force the return type of the reducer by passing the generic argument + const newState3 = produce(reducer, state)() + assert(newState3, _ as State) }) it("can work with readonly base types", () => { - type State = { - readonly price: number - readonly todos: readonly { - readonly title: string - readonly done: boolean - }[] - } - - const state: State = { - price: 10, - todos: [ - { - title: "test", - done: false - } - ] - } - - const newState: State = produce(state, draft => { - draft.price + 5 - draft.todos.push({ - title: "hi", - done: true - }) - }) - - const reducer = (draft: Draft) => { - draft.price += 5 - draft.todos.push({ - title: "hi", - done: true - }) - } - const newState2: State = produce(reducer)(state) - assert(newState2, _ as State) - - // base case for with-initial-state - const newState4 = produce(reducer, state)(state) - assert(newState4, _ as State) - // no argument case, in that case, immutable version recipe first arg will be inferred - const newState5 = produce(reducer, state)() - assert(newState5, _ as Immutable) - // we can force the return type of the reducer by passing the generic argument - const newState3 = produce(reducer, state)() - assert(newState3, _ as State) + type State = { + readonly price: number + readonly todos: readonly { + readonly title: string + readonly done: boolean + }[] + } + + const state: State = { + price: 10, + todos: [ + { + title: "test", + done: false + } + ] + } + + const newState: State = produce(state, draft => { + draft.price + 5 + draft.todos.push({ + title: "hi", + done: true + }) + }) + + const reducer = (draft: Draft) => { + draft.price += 5 + draft.todos.push({ + title: "hi", + done: true + }) + } + const newState2: State = produce(reducer)(state) + assert(newState2, _ as State) + + // base case for with-initial-state + const newState4 = produce(reducer, state)(state) + assert(newState4, _ as State) + // no argument case, in that case, immutable version recipe first arg will be inferred + const newState5 = produce(reducer, state)() + assert(newState5, _ as Immutable) + // we can force the return type of the reducer by passing the generic argument + const newState3 = produce(reducer, state)() + assert(newState3, _ as State) }) it("works with generic array", () => { - const append = (queue: T[], item: T) => - // T[] is needed here v. Too bad. - produce(queue, (queueDraft: T[]) => { - queueDraft.push(item) - }) + const append = (queue: T[], item: T) => + // T[] is needed here v. Too bad. + produce(queue, (queueDraft: T[]) => { + queueDraft.push(item) + }) - const queueBefore = [1, 2, 3] + const queueBefore = [1, 2, 3] - const queueAfter = append(queueBefore, 4) + const queueAfter = append(queueBefore, 4) - expect(queueAfter).toEqual([1, 2, 3, 4]) - expect(queueBefore).toEqual([1, 2, 3]) + expect(queueAfter).toEqual([1, 2, 3, 4]) + expect(queueBefore).toEqual([1, 2, 3]) }) diff --git a/__tests__/readme.js b/__tests__/readme.js index 31306fa9..3b90f25c 100644 --- a/__tests__/readme.js +++ b/__tests__/readme.js @@ -2,170 +2,170 @@ import produce, {applyPatches, immerable} from "../src/index" describe("readme example", () => { - it("works", () => { - const baseState = [ - { - todo: "Learn typescript", - done: true - }, - { - todo: "Try immer", - done: false - } - ] - - const nextState = produce(baseState, draft => { - draft.push({todo: "Tweet about it"}) - draft[1].done = true - }) - - // the new item is only added to the next state, - // base state is unmodified - expect(baseState.length).toBe(2) - expect(nextState.length).toBe(3) - - // same for the changed 'done' prop - expect(baseState[1].done).toBe(false) - expect(nextState[1].done).toBe(true) - - // unchanged data is structurally shared - expect(nextState[0]).toBe(baseState[0]) - // changed data not (dûh) - expect(nextState[1]).not.toBe(baseState[1]) - }) - - it("patches", () => { - let state = { - name: "Micheal", - age: 32 - } - - // Let's assume the user is in a wizard, and we don't know whether - // his changes should be updated - let fork = state - // all the changes the user made in the wizard - let changes = [] - // all the inverse patches - let inverseChanges = [] - - fork = produce( - fork, - draft => { - draft.age = 33 - }, - // The third argument to produce is a callback to which the patches will be fed - (patches, inversePatches) => { - changes.push(...patches) - inverseChanges.push(...inversePatches) - } - ) - - // In the mean time, our original state is updated as well, as changes come in from the server - state = produce(state, draft => { - draft.name = "Michel" - }) - - // When the wizard finishes (successfully) we can replay the changes made in the fork onto the *new* state! - state = applyPatches(state, changes) - - // state now contains the changes from both code paths! - expect(state).toEqual({ - name: "Michel", - age: 33 - }) - - // Even after finishing the wizard, the user might change his mind... - state = applyPatches(state, inverseChanges) - expect(state).toEqual({ - name: "Michel", - age: 32 - }) - }) - - it("can update set", () => { - const state = { - title: "hello", - tokenSet: new Set() - } - - const nextState = produce(state, draft => { - draft.title = draft.title.toUpperCase() // let immer do it's job - // don't use the operations onSet, as that mutates the instance! - // draft.tokenSet.add("c1342") - - // instead: clone the set (once!) - const newSet = new Set(draft.tokenSet) - // mutate it once - newSet.add("c1342") - // update the draft with the new set - draft.tokenSet = newSet - }) - - expect(state).toEqual({title: "hello", tokenSet: new Set()}) - expect(nextState).toEqual({ - title: "HELLO", - tokenSet: new Set(["c1342"]) - }) - }) - - it("can deep udpate map", () => { - const state = { - users: new Map([["michel", {name: "miche"}]]) - } - - const nextState = produce(state, draft => { - const newUsers = new Map(draft.users) - // mutate the new map and set a _new_ user object - // but leverage produce again to deeply update it's contents - newUsers.set( - "michel", - produce(draft.users.get("michel"), draft => { - draft.name = "michel" - }) - ) - draft.users = newUsers - }) - - expect(state).toEqual({users: new Map([["michel", {name: "miche"}]])}) - expect(nextState).toEqual({ - users: new Map([["michel", {name: "michel"}]]) - }) - }) - - it("supports immerable", () => { - class Clock { - constructor(hours = 0, minutes = 0) { - this.hours = hours - this.minutes = minutes - } - - increment(hours, minutes = 0) { - return produce(this, d => { - d.hours += hours - d.minutes += minutes - }) - } - - toString() { - return `${("" + this.hours).padStart(2, 0)}:${( - "" + this.minutes - ).padStart(2, 0)}` - } - } - Clock[immerable] = true - - const midnight = new Clock() - const lunch = midnight.increment(12, 30) - - expect(midnight).not.toBe(lunch) - expect(lunch).toBeInstanceOf(Clock) - expect(midnight.toString()).toBe("00:00") - expect(lunch.toString()).toBe("12:30") - - const diner = lunch.increment(6) - - expect(diner).not.toBe(lunch) - expect(lunch).toBeInstanceOf(Clock) - expect(diner.toString()).toBe("18:30") - }) + it("works", () => { + const baseState = [ + { + todo: "Learn typescript", + done: true + }, + { + todo: "Try immer", + done: false + } + ] + + const nextState = produce(baseState, draft => { + draft.push({todo: "Tweet about it"}) + draft[1].done = true + }) + + // the new item is only added to the next state, + // base state is unmodified + expect(baseState.length).toBe(2) + expect(nextState.length).toBe(3) + + // same for the changed 'done' prop + expect(baseState[1].done).toBe(false) + expect(nextState[1].done).toBe(true) + + // unchanged data is structurally shared + expect(nextState[0]).toBe(baseState[0]) + // changed data not (dûh) + expect(nextState[1]).not.toBe(baseState[1]) + }) + + it("patches", () => { + let state = { + name: "Micheal", + age: 32 + } + + // Let's assume the user is in a wizard, and we don't know whether + // his changes should be updated + let fork = state + // all the changes the user made in the wizard + let changes = [] + // all the inverse patches + let inverseChanges = [] + + fork = produce( + fork, + draft => { + draft.age = 33 + }, + // The third argument to produce is a callback to which the patches will be fed + (patches, inversePatches) => { + changes.push(...patches) + inverseChanges.push(...inversePatches) + } + ) + + // In the mean time, our original state is updated as well, as changes come in from the server + state = produce(state, draft => { + draft.name = "Michel" + }) + + // When the wizard finishes (successfully) we can replay the changes made in the fork onto the *new* state! + state = applyPatches(state, changes) + + // state now contains the changes from both code paths! + expect(state).toEqual({ + name: "Michel", + age: 33 + }) + + // Even after finishing the wizard, the user might change his mind... + state = applyPatches(state, inverseChanges) + expect(state).toEqual({ + name: "Michel", + age: 32 + }) + }) + + it("can update set", () => { + const state = { + title: "hello", + tokenSet: new Set() + } + + const nextState = produce(state, draft => { + draft.title = draft.title.toUpperCase() // let immer do it's job + // don't use the operations onSet, as that mutates the instance! + // draft.tokenSet.add("c1342") + + // instead: clone the set (once!) + const newSet = new Set(draft.tokenSet) + // mutate it once + newSet.add("c1342") + // update the draft with the new set + draft.tokenSet = newSet + }) + + expect(state).toEqual({title: "hello", tokenSet: new Set()}) + expect(nextState).toEqual({ + title: "HELLO", + tokenSet: new Set(["c1342"]) + }) + }) + + it("can deep udpate map", () => { + const state = { + users: new Map([["michel", {name: "miche"}]]) + } + + const nextState = produce(state, draft => { + const newUsers = new Map(draft.users) + // mutate the new map and set a _new_ user object + // but leverage produce again to deeply update it's contents + newUsers.set( + "michel", + produce(draft.users.get("michel"), draft => { + draft.name = "michel" + }) + ) + draft.users = newUsers + }) + + expect(state).toEqual({users: new Map([["michel", {name: "miche"}]])}) + expect(nextState).toEqual({ + users: new Map([["michel", {name: "michel"}]]) + }) + }) + + it("supports immerable", () => { + class Clock { + constructor(hours = 0, minutes = 0) { + this.hours = hours + this.minutes = minutes + } + + increment(hours, minutes = 0) { + return produce(this, d => { + d.hours += hours + d.minutes += minutes + }) + } + + toString() { + return `${("" + this.hours).padStart(2, 0)}:${( + "" + this.minutes + ).padStart(2, 0)}` + } + } + Clock[immerable] = true + + const midnight = new Clock() + const lunch = midnight.increment(12, 30) + + expect(midnight).not.toBe(lunch) + expect(lunch).toBeInstanceOf(Clock) + expect(midnight.toString()).toBe("00:00") + expect(lunch.toString()).toBe("12:30") + + const diner = lunch.increment(6) + + expect(diner).not.toBe(lunch) + expect(lunch).toBeInstanceOf(Clock) + expect(diner.toString()).toBe("18:30") + }) }) diff --git a/__tests__/test-data.json b/__tests__/test-data.json index 4b29730a..ceca62d6 100644 --- a/__tests__/test-data.json +++ b/__tests__/test-data.json @@ -1,56 +1,45 @@ - { - "_id": "5a5e59f1bc9132a90101f521", - "index": 0, - "guid": "8dc96fdb-0526-4bc4-a0ef-fb08e6c06e77", - "isActive": true, - "balance": "$3,359.66", - "picture": "http://placehold.it/32x32", - "age": 40, - "eyeColor": "blue", - "name": "Samantha Richardson", - "gender": "female", - "company": "COWTOWN", - "email": "samantharichardson@cowtown.com", - "phone": "+1 (847) 556-2336", - "address": "419 Bergen Street, Emison, Nebraska, 554", - "about": "Enim magna ut do esse voluptate et commodo anim laborum proident aute. Nulla non sit in incididunt minim velit. Laboris et tempor enim cillum ex adipisicing excepteur. Dolore consectetur eiusmod eu ad eu. Veniam qui sunt est reprehenderit et ut occaecat eiusmod pariatur aliquip officia ipsum.\r\n", - "registered": "2017-07-09T02:30:48 -02:00", - "latitude": -38.375287, - "longitude": -27.090184, - "tags": [ - "ex", - "cillum", - "cillum", - "Lorem", - "fugiat", - "dolore", - "nulla", - "anim", - "aliqua", - "sint" - ], - "friends": [ - { - "id": 0, - "name": "Adrian Dunlap" - }, - { - "id": 1, - "name": "Salinas Copeland" - }, - { - "id": 2, - "name": "Doreen Emerson" - }, - { - "id": 3, - "name": "Willis Long" - }, - { - "id": 4, - "name": "Josefina Garza" - } - ], - "greeting": "Hello, Samantha Richardson! You have 8 unread messages.", - "favoriteFruit": "banana" - } \ No newline at end of file +{ + "_id": "5a5e59f1bc9132a90101f521", + "index": 0, + "guid": "8dc96fdb-0526-4bc4-a0ef-fb08e6c06e77", + "isActive": true, + "balance": "$3,359.66", + "picture": "http://placehold.it/32x32", + "age": 40, + "eyeColor": "blue", + "name": "Samantha Richardson", + "gender": "female", + "company": "COWTOWN", + "email": "samantharichardson@cowtown.com", + "phone": "+1 (847) 556-2336", + "address": "419 Bergen Street, Emison, Nebraska, 554", + "about": "Enim magna ut do esse voluptate et commodo anim laborum proident aute. Nulla non sit in incididunt minim velit. Laboris et tempor enim cillum ex adipisicing excepteur. Dolore consectetur eiusmod eu ad eu. Veniam qui sunt est reprehenderit et ut occaecat eiusmod pariatur aliquip officia ipsum.\r\n", + "registered": "2017-07-09T02:30:48 -02:00", + "latitude": -38.375287, + "longitude": -27.090184, + "tags": ["ex", "cillum", "cillum", "Lorem", "fugiat", "dolore", "nulla", "anim", "aliqua", "sint"], + "friends": [ + { + "id": 0, + "name": "Adrian Dunlap" + }, + { + "id": 1, + "name": "Salinas Copeland" + }, + { + "id": 2, + "name": "Doreen Emerson" + }, + { + "id": 3, + "name": "Willis Long" + }, + { + "id": 4, + "name": "Josefina Garza" + } + ], + "greeting": "Hello, Samantha Richardson! You have 8 unread messages.", + "favoriteFruit": "banana" +} diff --git a/__tests__/tsconfig.json b/__tests__/tsconfig.json index 8e92c580..7d7ca1ab 100644 --- a/__tests__/tsconfig.json +++ b/__tests__/tsconfig.json @@ -1,7 +1,7 @@ { - "include": ["*", "../dist"], - "compilerOptions": { - "lib": ["es2015"], - "strict": true - } + "include": ["*", "../dist"], + "compilerOptions": { + "lib": ["es2015"], + "strict": true + } } diff --git a/src/common.js b/src/common.js index 94999bba..c8a475ea 100644 --- a/src/common.js +++ b/src/common.js @@ -1,108 +1,108 @@ export const NOTHING = - typeof Symbol !== "undefined" - ? Symbol("immer-nothing") - : {["immer-nothing"]: true} + typeof Symbol !== "undefined" + ? Symbol("immer-nothing") + : {["immer-nothing"]: true} export const DRAFTABLE = - typeof Symbol !== "undefined" && Symbol.for - ? Symbol.for("immer-draftable") - : "__$immer_draftable" + typeof Symbol !== "undefined" && Symbol.for + ? Symbol.for("immer-draftable") + : "__$immer_draftable" export const DRAFT_STATE = - typeof Symbol !== "undefined" && Symbol.for - ? Symbol.for("immer-state") - : "__$immer_state" + typeof Symbol !== "undefined" && Symbol.for + ? Symbol.for("immer-state") + : "__$immer_state" export function isDraft(value) { - return !!value && !!value[DRAFT_STATE] + return !!value && !!value[DRAFT_STATE] } export function isDraftable(value) { - if (!value || typeof value !== "object") return false - if (Array.isArray(value)) return true - const proto = Object.getPrototypeOf(value) - if (!proto || proto === Object.prototype) return true - return !!value[DRAFTABLE] || !!value.constructor[DRAFTABLE] + if (!value || typeof value !== "object") return false + if (Array.isArray(value)) return true + const proto = Object.getPrototypeOf(value) + if (!proto || proto === Object.prototype) return true + return !!value[DRAFTABLE] || !!value.constructor[DRAFTABLE] } export function original(value) { - if (value && value[DRAFT_STATE]) { - return value[DRAFT_STATE].base - } - // otherwise return undefined + if (value && value[DRAFT_STATE]) { + return value[DRAFT_STATE].base + } + // otherwise return undefined } export const assign = - Object.assign || - function assign(target, value) { - for (let key in value) { - if (has(value, key)) { - target[key] = value[key] - } - } - return target - } + Object.assign || + function assign(target, value) { + for (let key in value) { + if (has(value, key)) { + target[key] = value[key] + } + } + return target + } export const ownKeys = - typeof Reflect !== "undefined" && Reflect.ownKeys - ? Reflect.ownKeys - : typeof Object.getOwnPropertySymbols !== "undefined" - ? obj => - Object.getOwnPropertyNames(obj).concat( - Object.getOwnPropertySymbols(obj) - ) - : Object.getOwnPropertyNames + typeof Reflect !== "undefined" && Reflect.ownKeys + ? Reflect.ownKeys + : typeof Object.getOwnPropertySymbols !== "undefined" + ? obj => + Object.getOwnPropertyNames(obj).concat( + Object.getOwnPropertySymbols(obj) + ) + : Object.getOwnPropertyNames export function shallowCopy(base, invokeGetters = false) { - if (Array.isArray(base)) return base.slice() - const clone = Object.create(Object.getPrototypeOf(base)) - ownKeys(base).forEach(key => { - if (key === DRAFT_STATE) { - return // Never copy over draft state. - } - const desc = Object.getOwnPropertyDescriptor(base, key) - let {value} = desc - if (desc.get) { - if (!invokeGetters) { - throw new Error("Immer drafts cannot have computed properties") - } - value = desc.get.call(base) - } - if (desc.enumerable) { - clone[key] = value - } else { - Object.defineProperty(clone, key, { - value, - writable: true, - configurable: true - }) - } - }) - return clone + if (Array.isArray(base)) return base.slice() + const clone = Object.create(Object.getPrototypeOf(base)) + ownKeys(base).forEach(key => { + if (key === DRAFT_STATE) { + return // Never copy over draft state. + } + const desc = Object.getOwnPropertyDescriptor(base, key) + let {value} = desc + if (desc.get) { + if (!invokeGetters) { + throw new Error("Immer drafts cannot have computed properties") + } + value = desc.get.call(base) + } + if (desc.enumerable) { + clone[key] = value + } else { + Object.defineProperty(clone, key, { + value, + writable: true, + configurable: true + }) + } + }) + return clone } export function each(value, cb) { - if (Array.isArray(value)) { - for (let i = 0; i < value.length; i++) cb(i, value[i], value) - } else { - ownKeys(value).forEach(key => cb(key, value[key], value)) - } + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) cb(i, value[i], value) + } else { + ownKeys(value).forEach(key => cb(key, value[key], value)) + } } export function isEnumerable(base, prop) { - const desc = Object.getOwnPropertyDescriptor(base, prop) - return !!desc && desc.enumerable + const desc = Object.getOwnPropertyDescriptor(base, prop) + return !!desc && desc.enumerable } export function has(thing, prop) { - return Object.prototype.hasOwnProperty.call(thing, prop) + return Object.prototype.hasOwnProperty.call(thing, prop) } export function is(x, y) { - // From: https://github.com/facebook/fbjs/blob/c69904a511b900266935168223063dd8772dfc40/packages/fbjs/src/core/shallowEqual.js - if (x === y) { - return x !== 0 || 1 / x === 1 / y - } else { - return x !== x && y !== y - } + // From: https://github.com/facebook/fbjs/blob/c69904a511b900266935168223063dd8772dfc40/packages/fbjs/src/core/shallowEqual.js + if (x === y) { + return x !== 0 || 1 / x === 1 / y + } else { + return x !== x && y !== y + } } diff --git a/src/es5.js b/src/es5.js index c6a6f5da..1608ed87 100644 --- a/src/es5.js +++ b/src/es5.js @@ -1,13 +1,13 @@ "use strict" import { - each, - has, - is, - isDraft, - isDraftable, - isEnumerable, - shallowCopy, - DRAFT_STATE + each, + has, + is, + isDraft, + isDraftable, + isEnumerable, + shallowCopy, + DRAFT_STATE } from "./common" import {ImmerScope} from "./scope" @@ -16,248 +16,248 @@ import {ImmerScope} from "./scope" const descriptors = {} export function willFinalize(scope, result, isReplaced) { - scope.drafts.forEach(draft => { - draft[DRAFT_STATE].finalizing = true - }) - if (!isReplaced) { - if (scope.patches) { - markChangesRecursively(scope.drafts[0]) - } - // This is faster when we don't care about which attributes changed. - markChangesSweep(scope.drafts) - } - // When a child draft is returned, look for changes. - else if (isDraft(result) && result[DRAFT_STATE].scope === scope) { - markChangesSweep(scope.drafts) - } + scope.drafts.forEach(draft => { + draft[DRAFT_STATE].finalizing = true + }) + if (!isReplaced) { + if (scope.patches) { + markChangesRecursively(scope.drafts[0]) + } + // This is faster when we don't care about which attributes changed. + markChangesSweep(scope.drafts) + } + // When a child draft is returned, look for changes. + else if (isDraft(result) && result[DRAFT_STATE].scope === scope) { + markChangesSweep(scope.drafts) + } } export function createProxy(base, parent) { - const isArray = Array.isArray(base) - const draft = clonePotentialDraft(base) - each(draft, prop => { - proxyProperty(draft, prop, isArray || isEnumerable(base, prop)) - }) + const isArray = Array.isArray(base) + const draft = clonePotentialDraft(base) + each(draft, prop => { + proxyProperty(draft, prop, isArray || isEnumerable(base, prop)) + }) - // See "proxy.js" for property documentation. - const scope = parent ? parent.scope : ImmerScope.current - const state = { - scope, - modified: false, - finalizing: false, // es5 only - finalized: false, - assigned: {}, - parent, - base, - draft, - copy: null, - revoke, - revoked: false // es5 only - } + // See "proxy.js" for property documentation. + const scope = parent ? parent.scope : ImmerScope.current + const state = { + scope, + modified: false, + finalizing: false, // es5 only + finalized: false, + assigned: {}, + parent, + base, + draft, + copy: null, + revoke, + revoked: false // es5 only + } - createHiddenProperty(draft, DRAFT_STATE, state) - scope.drafts.push(draft) - return draft + createHiddenProperty(draft, DRAFT_STATE, state) + scope.drafts.push(draft) + return draft } function revoke() { - this.revoked = true + this.revoked = true } function source(state) { - return state.copy || state.base + return state.copy || state.base } // Access a property without creating an Immer draft. function peek(draft, prop) { - const state = draft[DRAFT_STATE] - if (state && !state.finalizing) { - state.finalizing = true - const value = draft[prop] - state.finalizing = false - return value - } - return draft[prop] + const state = 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, prop) { - assertUnrevoked(state) - const value = peek(source(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) - return (state.copy[prop] = createProxy(value, state)) - } - return value + assertUnrevoked(state) + const value = peek(source(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) + return (state.copy[prop] = createProxy(value, state)) + } + return value } function set(state, prop, value) { - assertUnrevoked(state) - state.assigned[prop] = true - if (!state.modified) { - if (is(value, peek(source(state), prop))) return - markChanged(state) - prepareCopy(state) - } - state.copy[prop] = value + assertUnrevoked(state) + state.assigned[prop] = true + if (!state.modified) { + if (is(value, peek(source(state), prop))) return + markChanged(state) + prepareCopy(state) + } + state.copy[prop] = value } function markChanged(state) { - if (!state.modified) { - state.modified = true - if (state.parent) markChanged(state.parent) - } + if (!state.modified) { + state.modified = true + if (state.parent) markChanged(state.parent) + } } function prepareCopy(state) { - if (!state.copy) state.copy = clonePotentialDraft(state.base) + if (!state.copy) state.copy = clonePotentialDraft(state.base) } function clonePotentialDraft(base) { - const state = base && base[DRAFT_STATE] - if (state) { - state.finalizing = true - const draft = shallowCopy(state.draft, true) - state.finalizing = false - return draft - } - return shallowCopy(base) + const state = base && base[DRAFT_STATE] + if (state) { + state.finalizing = true + const draft = shallowCopy(state.draft, true) + state.finalizing = false + return draft + } + return shallowCopy(base) } function proxyProperty(draft, prop, enumerable) { - let desc = descriptors[prop] - if (desc) { - desc.enumerable = enumerable - } else { - descriptors[prop] = desc = { - configurable: true, - enumerable, - get() { - return get(this[DRAFT_STATE], prop) - }, - set(value) { - set(this[DRAFT_STATE], prop, value) - } - } - } - Object.defineProperty(draft, prop, desc) + let desc = descriptors[prop] + if (desc) { + desc.enumerable = enumerable + } else { + descriptors[prop] = desc = { + configurable: true, + enumerable, + get() { + return get(this[DRAFT_STATE], prop) + }, + set(value) { + set(this[DRAFT_STATE], prop, value) + } + } + } + Object.defineProperty(draft, prop, desc) } function assertUnrevoked(state) { - if (state.revoked === true) - throw new Error( - "Cannot use a proxy that has been revoked. Did you pass an object from inside an immer function to an async process? " + - JSON.stringify(source(state)) - ) + if (state.revoked === true) + throw new Error( + "Cannot use a proxy that has been revoked. Did you pass an object from inside an immer function to an async process? " + + JSON.stringify(source(state)) + ) } // This looks expensive, but only proxies are visited, and only objects without known changes are scanned. function markChangesSweep(drafts) { - // The natural order of drafts in the `scope` array is based on when they - // were accessed. By processing drafts in reverse natural order, we have a - // better chance of processing leaf nodes first. When a leaf node is known to - // have changed, we can avoid any traversal of its ancestor nodes. - for (let i = drafts.length - 1; i >= 0; i--) { - const state = drafts[i][DRAFT_STATE] - if (!state.modified) { - if (Array.isArray(state.base)) { - if (hasArrayChanges(state)) markChanged(state) - } else if (hasObjectChanges(state)) markChanged(state) - } - } + // The natural order of drafts in the `scope` array is based on when they + // were accessed. By processing drafts in reverse natural order, we have a + // better chance of processing leaf nodes first. When a leaf node is known to + // have changed, we can avoid any traversal of its ancestor nodes. + for (let i = drafts.length - 1; i >= 0; i--) { + const state = drafts[i][DRAFT_STATE] + if (!state.modified) { + if (Array.isArray(state.base)) { + if (hasArrayChanges(state)) markChanged(state) + } else if (hasObjectChanges(state)) markChanged(state) + } + } } function markChangesRecursively(object) { - if (!object || typeof object !== "object") return - const state = object[DRAFT_STATE] - if (!state) return - const {base, draft, assigned} = state - if (!Array.isArray(object)) { - // Look for added keys. - Object.keys(draft).forEach(key => { - // The `undefined` check is a fast path for pre-existing keys. - if (base[key] === undefined && !has(base, key)) { - assigned[key] = true - markChanged(state) - } else if (!assigned[key]) { - // Only untouched properties trigger recursion. - markChangesRecursively(draft[key]) - } - }) - // Look for removed keys. - Object.keys(base).forEach(key => { - // The `undefined` check is a fast path for pre-existing keys. - if (draft[key] === undefined && !has(draft, key)) { - assigned[key] = false - markChanged(state) - } - }) - } else if (hasArrayChanges(state)) { - markChanged(state) - assigned.length = true - if (draft.length < base.length) { - for (let i = draft.length; i < base.length; i++) assigned[i] = false - } else { - for (let i = base.length; i < draft.length; i++) assigned[i] = true - } - for (let i = 0; i < draft.length; i++) { - // Only untouched indices trigger recursion. - if (assigned[i] === undefined) markChangesRecursively(draft[i]) - } - } + if (!object || typeof object !== "object") return + const state = object[DRAFT_STATE] + if (!state) return + const {base, draft, assigned} = state + if (!Array.isArray(object)) { + // Look for added keys. + Object.keys(draft).forEach(key => { + // The `undefined` check is a fast path for pre-existing keys. + if (base[key] === undefined && !has(base, key)) { + assigned[key] = true + markChanged(state) + } else if (!assigned[key]) { + // Only untouched properties trigger recursion. + markChangesRecursively(draft[key]) + } + }) + // Look for removed keys. + Object.keys(base).forEach(key => { + // The `undefined` check is a fast path for pre-existing keys. + if (draft[key] === undefined && !has(draft, key)) { + assigned[key] = false + markChanged(state) + } + }) + } else if (hasArrayChanges(state)) { + markChanged(state) + assigned.length = true + if (draft.length < base.length) { + for (let i = draft.length; i < base.length; i++) assigned[i] = false + } else { + for (let i = base.length; i < draft.length; i++) assigned[i] = true + } + for (let i = 0; i < draft.length; i++) { + // Only untouched indices trigger recursion. + if (assigned[i] === undefined) markChangesRecursively(draft[i]) + } + } } function hasObjectChanges(state) { - const {base, draft} = state + const {base, draft} = state - // Search for added keys and changed keys. Start at the back, because - // non-numeric keys are ordered by time of definition on the object. - const keys = Object.keys(draft) - for (let i = keys.length - 1; i >= 0; i--) { - const key = keys[i] - const baseValue = base[key] - // The `undefined` check is a fast path for pre-existing keys. - if (baseValue === undefined && !has(base, key)) { - return true - } - // Once a base key is deleted, future changes go undetected, because its - // descriptor is erased. This branch detects any missed changes. - else { - const value = draft[key] - const state = value && value[DRAFT_STATE] - if (state ? state.base !== baseValue : !is(value, baseValue)) { - return true - } - } - } + // Search for added keys and changed keys. Start at the back, because + // non-numeric keys are ordered by time of definition on the object. + const keys = Object.keys(draft) + for (let i = keys.length - 1; i >= 0; i--) { + const key = keys[i] + const baseValue = base[key] + // The `undefined` check is a fast path for pre-existing keys. + if (baseValue === undefined && !has(base, key)) { + return true + } + // Once a base key is deleted, future changes go undetected, because its + // descriptor is erased. This branch detects any missed changes. + else { + const value = draft[key] + const state = value && value[DRAFT_STATE] + if (state ? state.base !== baseValue : !is(value, baseValue)) { + return true + } + } + } - // At this point, no keys were added or changed. - // Compare key count to determine if keys were deleted. - return keys.length !== Object.keys(base).length + // At this point, no keys were added or changed. + // Compare key count to determine if keys were deleted. + return keys.length !== Object.keys(base).length } function hasArrayChanges(state) { - const {draft} = state - if (draft.length !== state.base.length) return true - // See #116 - // If we first shorten the length, our array interceptors will be removed. - // If after that new items are added, result in the same original length, - // those last items will have no intercepting property. - // So if there is no own descriptor on the last position, we know that items were removed and added - // N.B.: splice, unshift, etc only shift values around, but not prop descriptors, so we only have to check - // the last one - const descriptor = Object.getOwnPropertyDescriptor(draft, draft.length - 1) - // descriptor can be null, but only for newly created sparse arrays, eg. new Array(10) - if (descriptor && !descriptor.get) return true - // For all other cases, we don't have to compare, as they would have been picked up by the index setters - return false + const {draft} = state + if (draft.length !== state.base.length) return true + // See #116 + // If we first shorten the length, our array interceptors will be removed. + // If after that new items are added, result in the same original length, + // those last items will have no intercepting property. + // So if there is no own descriptor on the last position, we know that items were removed and added + // N.B.: splice, unshift, etc only shift values around, but not prop descriptors, so we only have to check + // the last one + const descriptor = Object.getOwnPropertyDescriptor(draft, draft.length - 1) + // descriptor can be null, but only for newly created sparse arrays, eg. new Array(10) + if (descriptor && !descriptor.get) return true + // For all other cases, we don't have to compare, as they would have been picked up by the index setters + return false } function createHiddenProperty(target, prop, value) { - Object.defineProperty(target, prop, { - value: value, - enumerable: false, - writable: true - }) + Object.defineProperty(target, prop, { + value: value, + enumerable: false, + writable: true + }) } diff --git a/src/immer.d.ts b/src/immer.d.ts index 4940449f..a3f5ffff 100644 --- a/src/immer.d.ts +++ b/src/immer.d.ts @@ -1,41 +1,41 @@ type Tail = ((...t: T) => any) extends (( - _: any, - ...tail: infer TT + _: any, + ...tail: infer TT ) => any) - ? TT - : [] + ? TT + : [] /** Object types that should never be mapped */ type AtomicObject = - | Function - | Map - | WeakMap - | Set - | WeakSet - | Promise - | Date - | RegExp - | Boolean - | Number - | String + | Function + | Map + | WeakMap + | Set + | WeakSet + | Promise + | Date + | RegExp + | Boolean + | Number + | String export type Draft = T extends AtomicObject - ? T - : T extends object - ? {-readonly [K in keyof T]: Draft} - : T + ? T + : T extends object + ? {-readonly [K in keyof T]: Draft} + : T /** Convert a mutable type into a readonly type */ export type Immutable = T extends AtomicObject - ? T - : T extends object - ? {readonly [K in keyof T]: Immutable} - : T + ? T + : T extends object + ? {readonly [K in keyof T]: Immutable} + : T 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 @@ -45,10 +45,10 @@ type FromNothing = T extends Nothing ? undefined : T /** The inferred return type of `produce` */ export type Produced = Return extends void - ? Base - : Return extends Promise - ? Promise> - : FromNothing + ? Base + : Return extends Promise + ? Promise> + : FromNothing /** * The `produce` function takes a value and a "recipe function" (whose @@ -70,40 +70,40 @@ export type Produced = Return extends void * @returns {any} a new state, or the initial state if nothing was modified */ export interface IProduce { - /** Curried producer */ - < - Recipe extends (...args: any[]) => any, - Params extends any[] = Parameters, - T = Params[0] - >( - recipe: Recipe - ): >( - base: Base, - ...rest: Tail - ) => Produced> - // ^ by making the returned type generic, the actual type of the passed in object is preferred - // over the type used in the recipe. However, it does have to satisfy the immutable version used in the recipe - // Note: the type of S is the widened version of T, so it can have more props than T, but that is technically actually correct! - - /** Curried producer with initial state */ - < - Recipe extends (...args: any[]) => any, - Params extends any[] = Parameters, - T = Params[0] - >( - recipe: Recipe, - initialState: Immutable - ): >( - base?: Base, - ...rest: Tail - ) => Produced> - - /** Normal producer */ - , Return = void>( - base: Base, - recipe: (draft: D) => Return, - listener?: PatchListener - ): Produced + /** Curried producer */ + < + Recipe extends (...args: any[]) => any, + Params extends any[] = Parameters, + T = Params[0] + >( + recipe: Recipe + ): >( + base: Base, + ...rest: Tail + ) => Produced> + // ^ by making the returned type generic, the actual type of the passed in object is preferred + // over the type used in the recipe. However, it does have to satisfy the immutable version used in the recipe + // Note: the type of S is the widened version of T, so it can have more props than T, but that is technically actually correct! + + /** Curried producer with initial state */ + < + Recipe extends (...args: any[]) => any, + Params extends any[] = Parameters, + T = Params[0] + >( + recipe: Recipe, + initialState: Immutable + ): >( + base?: Base, + ...rest: Tail + ) => Produced> + + /** Normal producer */ + , Return = void>( + base: Base, + recipe: (draft: D) => Return, + listener?: PatchListener + ): Produced } export const produce: IProduce @@ -111,8 +111,8 @@ export default produce /** Use a class type for `nothing` so its type is unique */ declare class Nothing { - // This lets us do `Exclude` - private _: any + // This lets us do `Exclude` + private _: any } /** @@ -178,71 +178,71 @@ export function isDraft(value: any): boolean export function isDraftable(value: any): boolean export class Immer { - constructor(config: { - useProxies?: boolean - autoFreeze?: boolean - onAssign?: ( - state: ImmerState, - prop: string | number, - value: unknown - ) => void - onDelete?: (state: ImmerState, prop: string | number) => void - onCopy?: (state: ImmerState) => void - }) - /** - * The `produce` function takes a value and a "recipe function" (whose - * return value often depends on the base state). The recipe function is - * free to mutate its first argument however it wants. All mutations are - * only ever applied to a __copy__ of the base state. - * - * Pass only a function to create a "curried producer" which relieves you - * from passing the recipe function every time. - * - * Only plain objects and arrays are made mutable. All other objects are - * considered uncopyable. - * - * Note: This function is __bound__ to its `Immer` instance. - * - * @param {any} base - the initial state - * @param {Function} producer - function that receives a proxy of the base state as first argument and which can be freely modified - * @param {Function} patchListener - optional function that will be called with all the patches produced here - * @returns {any} a new state, or the initial state if nothing was modified - */ - produce: IProduce - /** - * When true, `produce` will freeze the copies it creates. - */ - readonly autoFreeze: boolean - /** - * When true, drafts are ES2015 proxies. - */ - readonly useProxies: boolean - /** - * Pass true to automatically freeze all copies created by Immer. - * - * By default, auto-freezing is disabled in production. - */ - setAutoFreeze(autoFreeze: boolean): void - /** - * Pass true to use the ES2015 `Proxy` class when creating drafts, which is - * always faster than using ES5 proxies. - * - * By default, feature detection is used, so calling this is rarely necessary. - */ - setUseProxies(useProxies: boolean): void + constructor(config: { + useProxies?: boolean + autoFreeze?: boolean + onAssign?: ( + state: ImmerState, + prop: string | number, + value: unknown + ) => void + onDelete?: (state: ImmerState, prop: string | number) => void + onCopy?: (state: ImmerState) => void + }) + /** + * The `produce` function takes a value and a "recipe function" (whose + * return value often depends on the base state). The recipe function is + * free to mutate its first argument however it wants. All mutations are + * only ever applied to a __copy__ of the base state. + * + * Pass only a function to create a "curried producer" which relieves you + * from passing the recipe function every time. + * + * Only plain objects and arrays are made mutable. All other objects are + * considered uncopyable. + * + * Note: This function is __bound__ to its `Immer` instance. + * + * @param {any} base - the initial state + * @param {Function} producer - function that receives a proxy of the base state as first argument and which can be freely modified + * @param {Function} patchListener - optional function that will be called with all the patches produced here + * @returns {any} a new state, or the initial state if nothing was modified + */ + produce: IProduce + /** + * When true, `produce` will freeze the copies it creates. + */ + readonly autoFreeze: boolean + /** + * When true, drafts are ES2015 proxies. + */ + readonly useProxies: boolean + /** + * Pass true to automatically freeze all copies created by Immer. + * + * By default, auto-freezing is disabled in production. + */ + setAutoFreeze(autoFreeze: boolean): void + /** + * Pass true to use the ES2015 `Proxy` class when creating drafts, which is + * always faster than using ES5 proxies. + * + * By default, feature detection is used, so calling this is rarely necessary. + */ + setUseProxies(useProxies: boolean): void } export interface ImmerState { - parent?: ImmerState - base: T - copy: T - assigned: {[prop: string]: boolean; [index: number]: boolean} + parent?: ImmerState + base: T + copy: T + assigned: {[prop: string]: boolean; [index: number]: boolean} } // Backward compatibility with --target es5 declare global { - interface Set {} - interface Map {} - interface WeakSet {} - interface WeakMap {} + interface Set {} + interface Map {} + interface WeakSet {} + interface WeakMap {} } diff --git a/src/immer.js b/src/immer.js index 37eb140c..d60c78bc 100644 --- a/src/immer.js +++ b/src/immer.js @@ -2,287 +2,282 @@ import * as legacyProxy from "./es5" import * as modernProxy from "./proxy" import {applyPatches, generatePatches} from "./patches" import { - assign, - each, - has, - is, - isDraft, - isDraftable, - isEnumerable, - shallowCopy, - DRAFT_STATE, - NOTHING + assign, + each, + has, + is, + isDraft, + isDraftable, + isEnumerable, + shallowCopy, + DRAFT_STATE, + NOTHING } from "./common" import {ImmerScope} from "./scope" function verifyMinified() {} const configDefaults = { - useProxies: typeof Proxy !== "undefined" && typeof Reflect !== "undefined", - autoFreeze: - typeof process !== "undefined" - ? process.env.NODE_ENV !== "production" - : verifyMinified.name === "verifyMinified", - onAssign: null, - onDelete: null, - onCopy: null + useProxies: typeof Proxy !== "undefined" && typeof Reflect !== "undefined", + autoFreeze: + typeof process !== "undefined" + ? process.env.NODE_ENV !== "production" + : verifyMinified.name === "verifyMinified", + onAssign: null, + onDelete: null, + onCopy: null } export class Immer { - constructor(config) { - assign(this, configDefaults, config) - this.setUseProxies(this.useProxies) - this.produce = this.produce.bind(this) - } - produce(base, recipe, patchListener) { - // curried invocation - if (typeof base === "function" && typeof recipe !== "function") { - const defaultBase = recipe - recipe = base + constructor(config) { + assign(this, configDefaults, config) + this.setUseProxies(this.useProxies) + this.produce = this.produce.bind(this) + } + produce(base, recipe, patchListener) { + // curried invocation + if (typeof base === "function" && typeof recipe !== "function") { + const defaultBase = recipe + recipe = base - const self = this - return function curriedProduce(base = defaultBase, ...args) { - return self.produce(base, draft => recipe.call(this, draft, ...args)) // prettier-ignore - } - } + const self = this + return function curriedProduce(base = defaultBase, ...args) { + return self.produce(base, draft => recipe.call(this, draft, ...args)) // prettier-ignore + } + } - // prettier-ignore - { - if (typeof recipe !== "function") { - throw new Error("The first or second argument to `produce` must be a function") - } - if (patchListener !== undefined && typeof patchListener !== "function") { - throw new Error("The third argument to `produce` must be a function or undefined") - } - } + // prettier-ignore + { + if (typeof recipe !== "function") { + throw new Error("The first or second argument to `produce` must be a function") + } + if (patchListener !== undefined && typeof patchListener !== "function") { + throw new Error("The third argument to `produce` must be a function or undefined") + } + } - let result + let result - // Only plain objects, arrays, and "immerable classes" are drafted. - if (isDraftable(base)) { - const scope = ImmerScope.enter() - const proxy = this.createProxy(base) - let hasError = true - try { - result = recipe(proxy) - hasError = false - } finally { - // finally instead of catch + rethrow better preserves original stack - if (hasError) scope.revoke() - else scope.leave() - } - if (result instanceof Promise) { - return result.then( - result => { - scope.usePatches(patchListener) - return this.processResult(result, scope) - }, - error => { - scope.revoke() - throw error - } - ) - } - scope.usePatches(patchListener) - return this.processResult(result, scope) - } else { - result = recipe(base) - if (result === undefined) return base - return result !== NOTHING ? result : undefined - } - } - createDraft(base) { - if (!isDraftable(base)) { - throw new Error("First argument to `createDraft` must be a plain object, an array, or an immerable object") // prettier-ignore - } - const scope = ImmerScope.enter() - const proxy = this.createProxy(base) - proxy[DRAFT_STATE].isManual = true - scope.leave() - return proxy - } - finishDraft(draft, patchListener) { - const state = draft && draft[DRAFT_STATE] - if (!state || !state.isManual) { - throw new Error("First argument to `finishDraft` must be a draft returned by `createDraft`") // prettier-ignore - } - if (state.finalized) { - throw new Error("The given draft is already finalized") // prettier-ignore - } - const {scope} = state - scope.usePatches(patchListener) - return this.processResult(undefined, scope) - } - setAutoFreeze(value) { - this.autoFreeze = value - } - setUseProxies(value) { - this.useProxies = value - assign(this, value ? modernProxy : legacyProxy) - } - applyPatches(base, patches) { - // Mutate the base state when a draft is passed. - if (isDraft(base)) { - return applyPatches(base, patches) - } - // Otherwise, produce a copy of the base state. - return this.produce(base, draft => applyPatches(draft, patches)) - } - /** @internal */ - processResult(result, scope) { - const baseDraft = scope.drafts[0] - const isReplaced = result !== undefined && result !== baseDraft - this.willFinalize(scope, result, isReplaced) - if (isReplaced) { - if (baseDraft[DRAFT_STATE].modified) { - scope.revoke() - throw new Error("An immer producer returned a new value *and* modified its draft. Either return a new value *or* modify the draft.") // prettier-ignore - } - if (isDraftable(result)) { - // Finalize the result in case it contains (or is) a subset of the draft. - result = this.finalize(result, null, scope) - } - if (scope.patches) { - scope.patches.push({ - op: "replace", - path: [], - value: result - }) - scope.inversePatches.push({ - op: "replace", - path: [], - value: baseDraft[DRAFT_STATE].base - }) - } - } else { - // Finalize the base draft. - result = this.finalize(baseDraft, [], scope) - } - scope.revoke() - if (scope.patches) { - scope.patchListener(scope.patches, scope.inversePatches) - } - return result !== NOTHING ? result : undefined - } - /** - * @internal - * Finalize a draft, returning either the unmodified base state or a modified - * copy of the base state. - */ - finalize(draft, path, scope) { - const state = draft[DRAFT_STATE] - if (!state) { - if (Object.isFrozen(draft)) return draft - return this.finalizeTree(draft, null, scope) - } - // Never finalize drafts owned by another scope. - if (state.scope !== scope) { - return draft - } - if (!state.modified) { - return state.base - } - if (!state.finalized) { - state.finalized = true - this.finalizeTree(state.draft, path, scope) + // Only plain objects, arrays, and "immerable classes" are drafted. + if (isDraftable(base)) { + const scope = ImmerScope.enter() + const proxy = this.createProxy(base) + let hasError = true + try { + result = recipe(proxy) + hasError = false + } finally { + // finally instead of catch + rethrow better preserves original stack + if (hasError) scope.revoke() + else scope.leave() + } + if (result instanceof Promise) { + return result.then( + result => { + scope.usePatches(patchListener) + return this.processResult(result, scope) + }, + error => { + scope.revoke() + throw error + } + ) + } + scope.usePatches(patchListener) + return this.processResult(result, scope) + } else { + result = recipe(base) + if (result === undefined) return base + return result !== NOTHING ? result : undefined + } + } + createDraft(base) { + if (!isDraftable(base)) { + throw new Error("First argument to `createDraft` must be a plain object, an array, or an immerable object") // prettier-ignore + } + const scope = ImmerScope.enter() + const proxy = this.createProxy(base) + proxy[DRAFT_STATE].isManual = true + scope.leave() + return proxy + } + finishDraft(draft, patchListener) { + const state = draft && draft[DRAFT_STATE] + if (!state || !state.isManual) { + throw new Error("First argument to `finishDraft` must be a draft returned by `createDraft`") // prettier-ignore + } + if (state.finalized) { + throw new Error("The given draft is already finalized") // prettier-ignore + } + const {scope} = state + scope.usePatches(patchListener) + return this.processResult(undefined, scope) + } + setAutoFreeze(value) { + this.autoFreeze = value + } + setUseProxies(value) { + this.useProxies = value + assign(this, value ? modernProxy : legacyProxy) + } + applyPatches(base, patches) { + // Mutate the base state when a draft is passed. + if (isDraft(base)) { + return applyPatches(base, patches) + } + // Otherwise, produce a copy of the base state. + return this.produce(base, draft => applyPatches(draft, patches)) + } + /** @internal */ + processResult(result, scope) { + const baseDraft = scope.drafts[0] + const isReplaced = result !== undefined && result !== baseDraft + this.willFinalize(scope, result, isReplaced) + if (isReplaced) { + if (baseDraft[DRAFT_STATE].modified) { + scope.revoke() + throw new Error("An immer producer returned a new value *and* modified its draft. Either return a new value *or* modify the draft.") // prettier-ignore + } + if (isDraftable(result)) { + // Finalize the result in case it contains (or is) a subset of the draft. + result = this.finalize(result, null, scope) + } + if (scope.patches) { + scope.patches.push({ + op: "replace", + path: [], + value: result + }) + scope.inversePatches.push({ + op: "replace", + path: [], + value: baseDraft[DRAFT_STATE].base + }) + } + } else { + // Finalize the base draft. + result = this.finalize(baseDraft, [], scope) + } + scope.revoke() + if (scope.patches) { + scope.patchListener(scope.patches, scope.inversePatches) + } + return result !== NOTHING ? result : undefined + } + /** + * @internal + * Finalize a draft, returning either the unmodified base state or a modified + * copy of the base state. + */ + finalize(draft, path, scope) { + const state = draft[DRAFT_STATE] + if (!state) { + if (Object.isFrozen(draft)) return draft + return this.finalizeTree(draft, null, scope) + } + // Never finalize drafts owned by another scope. + if (state.scope !== scope) { + return draft + } + if (!state.modified) { + return state.base + } + if (!state.finalized) { + state.finalized = true + this.finalizeTree(state.draft, path, scope) - if (this.onDelete) { - // The `assigned` object is unreliable with ES5 drafts. - if (this.useProxies) { - const {assigned} = state - for (const prop in assigned) { - if (!assigned[prop]) this.onDelete(state, prop) - } - } else { - const {base, copy} = state - each(base, prop => { - if (!has(copy, prop)) this.onDelete(state, prop) - }) - } - } - if (this.onCopy) { - this.onCopy(state) - } + if (this.onDelete) { + // The `assigned` object is unreliable with ES5 drafts. + if (this.useProxies) { + const {assigned} = state + for (const prop in assigned) { + if (!assigned[prop]) this.onDelete(state, prop) + } + } else { + const {base, copy} = state + each(base, prop => { + if (!has(copy, prop)) this.onDelete(state, prop) + }) + } + } + if (this.onCopy) { + this.onCopy(state) + } - // At this point, all descendants of `state.copy` have been finalized, - // so we can be sure that `scope.canAutoFreeze` is accurate. - if (this.autoFreeze && scope.canAutoFreeze) { - Object.freeze(state.copy) - } + // At this point, all descendants of `state.copy` have been finalized, + // so we can be sure that `scope.canAutoFreeze` is accurate. + if (this.autoFreeze && scope.canAutoFreeze) { + Object.freeze(state.copy) + } - if (path && scope.patches) { - generatePatches( - state, - path, - scope.patches, - scope.inversePatches - ) - } - } - return state.copy - } - /** - * @internal - * Finalize all drafts in the given state tree. - */ - finalizeTree(root, rootPath, scope) { - const state = root[DRAFT_STATE] - if (state) { - if (!this.useProxies) { - // Create the final copy, with added keys and without deleted keys. - state.copy = shallowCopy(state.draft, true) - } - root = state.copy - } + if (path && scope.patches) { + generatePatches(state, path, scope.patches, scope.inversePatches) + } + } + return state.copy + } + /** + * @internal + * Finalize all drafts in the given state tree. + */ + finalizeTree(root, rootPath, scope) { + const state = root[DRAFT_STATE] + if (state) { + if (!this.useProxies) { + // Create the final copy, with added keys and without deleted keys. + state.copy = shallowCopy(state.draft, true) + } + root = state.copy + } - const needPatches = !!rootPath && !!scope.patches - const finalizeProperty = (prop, value, parent) => { - if (value === parent) { - throw Error("Immer forbids circular references") - } + const needPatches = !!rootPath && !!scope.patches + const finalizeProperty = (prop, value, parent) => { + if (value === parent) { + throw Error("Immer forbids circular references") + } - // In the `finalizeTree` method, only the `root` object may be a draft. - const isDraftProp = !!state && parent === root + // In the `finalizeTree` method, only the `root` object may be a draft. + const isDraftProp = !!state && parent === root - if (isDraft(value)) { - const path = - isDraftProp && needPatches && !state.assigned[prop] - ? rootPath.concat(prop) - : null + if (isDraft(value)) { + const path = + isDraftProp && needPatches && !state.assigned[prop] + ? rootPath.concat(prop) + : null - // Drafts owned by `scope` are finalized here. - value = this.finalize(value, path, scope) + // Drafts owned by `scope` are finalized here. + value = this.finalize(value, path, scope) - // Drafts from another scope must prevent auto-freezing. - if (isDraft(value)) { - scope.canAutoFreeze = false - } + // Drafts from another scope must prevent auto-freezing. + if (isDraft(value)) { + scope.canAutoFreeze = false + } - // Preserve non-enumerable properties. - if (Array.isArray(parent) || isEnumerable(parent, prop)) { - parent[prop] = value - } else { - Object.defineProperty(parent, prop, {value}) - } + // Preserve non-enumerable properties. + if (Array.isArray(parent) || isEnumerable(parent, prop)) { + parent[prop] = value + } else { + Object.defineProperty(parent, prop, {value}) + } - // Unchanged drafts are never passed to the `onAssign` hook. - if (isDraftProp && value === state.base[prop]) return - } - // Unchanged draft properties are ignored. - else if (isDraftProp && is(value, state.base[prop])) { - return - } - // Search new objects for unfinalized drafts. Frozen objects should never contain drafts. - else if (isDraftable(value) && !Object.isFrozen(value)) { - each(value, finalizeProperty) - } + // Unchanged drafts are never passed to the `onAssign` hook. + if (isDraftProp && value === state.base[prop]) return + } + // Unchanged draft properties are ignored. + else if (isDraftProp && is(value, state.base[prop])) { + return + } + // Search new objects for unfinalized drafts. Frozen objects should never contain drafts. + else if (isDraftable(value) && !Object.isFrozen(value)) { + each(value, finalizeProperty) + } - if (isDraftProp && this.onAssign) { - this.onAssign(state, prop, value) - } - } + if (isDraftProp && this.onAssign) { + this.onAssign(state, prop, value) + } + } - each(root, finalizeProperty) - return root - } + each(root, finalizeProperty) + return root + } } diff --git a/src/index.js b/src/index.js index 3d354b59..0d170ff7 100644 --- a/src/index.js +++ b/src/index.js @@ -63,11 +63,11 @@ export const createDraft = immer.createDraft.bind(immer) export const finishDraft = immer.finishDraft.bind(immer) export { - original, - isDraft, - isDraftable, - NOTHING as nothing, - DRAFTABLE as immerable + original, + isDraft, + isDraftable, + NOTHING as nothing, + DRAFTABLE as immerable } from "./common" export {Immer} diff --git a/src/patches.js b/src/patches.js index 889d51f1..4463b7b2 100644 --- a/src/patches.js +++ b/src/patches.js @@ -1,136 +1,136 @@ import {each} from "./common" export function generatePatches(state, basePath, patches, inversePatches) { - Array.isArray(state.base) - ? generateArrayPatches(state, basePath, patches, inversePatches) - : generateObjectPatches(state, basePath, patches, inversePatches) + Array.isArray(state.base) + ? generateArrayPatches(state, basePath, patches, inversePatches) + : generateObjectPatches(state, basePath, patches, inversePatches) } function generateArrayPatches(state, basePath, patches, inversePatches) { - let {base, copy, assigned} = state + let {base, copy, assigned} = state - // Reduce complexity by ensuring `base` is never longer. - if (copy.length < base.length) { - ;[base, copy] = [copy, base] - ;[patches, inversePatches] = [inversePatches, patches] - } + // Reduce complexity by ensuring `base` is never longer. + if (copy.length < base.length) { + ;[base, copy] = [copy, base] + ;[patches, inversePatches] = [inversePatches, patches] + } - const delta = copy.length - base.length + 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 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 - } + // 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) { - if (assigned[i] && copy[i] !== base[i]) { - const path = basePath.concat([i]) - patches.push({ - op: "replace", - path, - value: copy[i] - }) - inversePatches.push({ - op: "replace", - path, - value: base[i] - }) - } - } + // Process replaced indices. + for (let i = start; i < end; ++i) { + if (assigned[i] && copy[i] !== base[i]) { + const path = basePath.concat([i]) + patches.push({ + op: "replace", + path, + value: copy[i] + }) + inversePatches.push({ + op: "replace", + path, + value: base[i] + }) + } + } - const useRemove = end != base.length - const replaceCount = patches.length + const useRemove = end != base.length + const replaceCount = patches.length - // Process added indices. - for (let i = end + delta - 1; i >= end; --i) { - const path = basePath.concat([i]) - patches[replaceCount + i - end] = { - op: "add", - path, - value: copy[i] - } - if (useRemove) { - inversePatches.push({ - op: "remove", - path - }) - } - } + // Process added indices. + for (let i = end + delta - 1; i >= end; --i) { + const path = basePath.concat([i]) + patches[replaceCount + i - end] = { + op: "add", + path, + value: copy[i] + } + if (useRemove) { + inversePatches.push({ + op: "remove", + path + }) + } + } - // One "replace" patch reverses all non-splicing "add" patches. - if (!useRemove) { - inversePatches.push({ - op: "replace", - path: basePath.concat(["length"]), - value: base.length - }) - } + // One "replace" patch reverses all non-splicing "add" patches. + if (!useRemove) { + inversePatches.push({ + op: "replace", + path: basePath.concat(["length"]), + value: base.length + }) + } } function generateObjectPatches(state, basePath, patches, inversePatches) { - const {base, copy} = state - each(state.assigned, (key, assignedValue) => { - const origValue = base[key] - const value = copy[key] - const op = !assignedValue ? "remove" : key in base ? "replace" : "add" - if (origValue === value && op === "replace") return - const path = basePath.concat(key) - patches.push(op === "remove" ? {op, path} : {op, path, value}) - inversePatches.push( - op === "add" - ? {op: "remove", path} - : op === "remove" - ? {op: "add", path, value: origValue} - : {op: "replace", path, value: origValue} - ) - }) + const {base, copy} = state + each(state.assigned, (key, assignedValue) => { + const origValue = base[key] + const value = copy[key] + const op = !assignedValue ? "remove" : key in base ? "replace" : "add" + if (origValue === value && op === "replace") return + const path = basePath.concat(key) + patches.push(op === "remove" ? {op, path} : {op, path, value}) + inversePatches.push( + op === "add" + ? {op: "remove", path} + : op === "remove" + ? {op: "add", path, value: origValue} + : {op: "replace", path, value: origValue} + ) + }) } export function applyPatches(draft, patches) { - for (let i = 0; i < patches.length; i++) { - const patch = patches[i] - const {path} = patch - if (path.length === 0 && patch.op === "replace") { - draft = patch.value - } else { - let base = draft - for (let i = 0; i < path.length - 1; i++) { - base = base[path[i]] - if (!base || typeof base !== "object") - throw new Error("Cannot apply patch, path doesn't resolve: " + path.join("/")) // prettier-ignore - } - const key = path[path.length - 1] - switch (patch.op) { - case "replace": - base[key] = patch.value - break - case "add": - if (Array.isArray(base)) { - // TODO: support "foo/-" paths for appending to an array - base.splice(key, 0, patch.value) - } else { - base[key] = patch.value - } - break - case "remove": - if (Array.isArray(base)) { - base.splice(key, 1) - } else { - delete base[key] - } - break - default: - throw new Error("Unsupported patch operation: " + patch.op) - } - } - } - return draft + for (let i = 0; i < patches.length; i++) { + const patch = patches[i] + const {path} = patch + if (path.length === 0 && patch.op === "replace") { + draft = patch.value + } else { + let base = draft + for (let i = 0; i < path.length - 1; i++) { + base = base[path[i]] + if (!base || typeof base !== "object") + throw new Error("Cannot apply patch, path doesn't resolve: " + path.join("/")) // prettier-ignore + } + const key = path[path.length - 1] + switch (patch.op) { + case "replace": + base[key] = patch.value + break + case "add": + if (Array.isArray(base)) { + // TODO: support "foo/-" paths for appending to an array + base.splice(key, 0, patch.value) + } else { + base[key] = patch.value + } + break + case "remove": + if (Array.isArray(base)) { + base.splice(key, 1) + } else { + delete base[key] + } + break + default: + throw new Error("Unsupported patch operation: " + patch.op) + } + } + } + return draft } diff --git a/src/proxy.js b/src/proxy.js index da67e579..f4caefde 100644 --- a/src/proxy.js +++ b/src/proxy.js @@ -1,13 +1,13 @@ "use strict" import { - assign, - each, - has, - is, - isDraftable, - isDraft, - shallowCopy, - DRAFT_STATE + assign, + each, + has, + is, + isDraftable, + isDraft, + shallowCopy, + DRAFT_STATE } from "./common" import {ImmerScope} from "./scope" @@ -15,169 +15,169 @@ import {ImmerScope} from "./scope" export function willFinalize() {} export function createProxy(base, parent) { - const scope = parent ? parent.scope : ImmerScope.current - const state = { - // Track which produce call this is associated with. - scope, - // True for both shallow and deep changes. - modified: false, - // Used during finalization. - finalized: false, - // Track which properties have been assigned (true) or deleted (false). - assigned: {}, - // The parent draft state. - parent, - // The base state. - base, - // The base proxy. - draft: null, - // Any property proxies. - drafts: {}, - // The base copy with any updated values. - copy: null, - // Called by the `produce` function. - revoke: null - } - - const {revoke, proxy} = Array.isArray(base) - ? // [state] is used for arrays, to make sure the proxy is array-ish and not violate invariants, - // although state itself is an object - Proxy.revocable([state], arrayTraps) - : Proxy.revocable(state, objectTraps) - - state.draft = proxy - state.revoke = revoke - - scope.drafts.push(proxy) - return proxy + const scope = parent ? parent.scope : ImmerScope.current + const state = { + // Track which produce call this is associated with. + scope, + // True for both shallow and deep changes. + modified: false, + // Used during finalization. + finalized: false, + // Track which properties have been assigned (true) or deleted (false). + assigned: {}, + // The parent draft state. + parent, + // The base state. + base, + // The base proxy. + draft: null, + // Any property proxies. + drafts: {}, + // The base copy with any updated values. + copy: null, + // Called by the `produce` function. + revoke: null + } + + const {revoke, proxy} = Array.isArray(base) + ? // [state] is used for arrays, to make sure the proxy is array-ish and not violate invariants, + // although state itself is an object + Proxy.revocable([state], arrayTraps) + : Proxy.revocable(state, objectTraps) + + state.draft = proxy + state.revoke = revoke + + scope.drafts.push(proxy) + return proxy } const objectTraps = { - get, - has(target, prop) { - return prop in source(target) - }, - ownKeys(target) { - return Reflect.ownKeys(source(target)) - }, - set, - deleteProperty, - getOwnPropertyDescriptor, - defineProperty() { - throw new Error("Object.defineProperty() cannot be used on an Immer draft") // prettier-ignore - }, - getPrototypeOf(target) { - return Object.getPrototypeOf(target.base) - }, - setPrototypeOf() { - throw new Error("Object.setPrototypeOf() cannot be used on an Immer draft") // prettier-ignore - } + get, + has(target, prop) { + return prop in source(target) + }, + ownKeys(target) { + return Reflect.ownKeys(source(target)) + }, + set, + deleteProperty, + getOwnPropertyDescriptor, + defineProperty() { + throw new Error("Object.defineProperty() cannot be used on an Immer draft") // prettier-ignore + }, + getPrototypeOf(target) { + return Object.getPrototypeOf(target.base) + }, + setPrototypeOf() { + throw new Error("Object.setPrototypeOf() cannot be used on an Immer draft") // prettier-ignore + } } const arrayTraps = {} each(objectTraps, (key, fn) => { - arrayTraps[key] = function() { - arguments[0] = arguments[0][0] - return fn.apply(this, arguments) - } + arrayTraps[key] = function() { + arguments[0] = arguments[0][0] + return fn.apply(this, arguments) + } }) arrayTraps.deleteProperty = function(state, prop) { - if (isNaN(parseInt(prop))) { - throw new Error("Immer only supports deleting array indices") // prettier-ignore - } - return objectTraps.deleteProperty.call(this, state[0], prop) + if (isNaN(parseInt(prop))) { + throw new Error("Immer only supports deleting array indices") // prettier-ignore + } + return objectTraps.deleteProperty.call(this, state[0], prop) } arrayTraps.set = function(state, prop, value) { - if (prop !== "length" && isNaN(parseInt(prop))) { - throw new Error("Immer only supports setting array indices and the 'length' property") // prettier-ignore - } - return objectTraps.set.call(this, state[0], prop, value) + if (prop !== "length" && isNaN(parseInt(prop))) { + throw new Error("Immer only supports setting array indices and the 'length' property") // prettier-ignore + } + return objectTraps.set.call(this, state[0], prop, value) } // returns the object we should be reading the current value from, which is base, until some change has been made function source(state) { - return state.copy || state.base + return state.copy || state.base } // Access a property without creating an Immer draft. function peek(draft, prop) { - const state = draft[DRAFT_STATE] - const desc = Reflect.getOwnPropertyDescriptor( - state ? source(state) : draft, - prop - ) - return desc && desc.value + const state = draft[DRAFT_STATE] + const desc = Reflect.getOwnPropertyDescriptor( + state ? source(state) : draft, + prop + ) + return desc && desc.value } function get(state, prop) { - if (prop === DRAFT_STATE) return state - let {drafts} = state - - // Check for existing draft in unmodified state. - if (!state.modified && has(drafts, prop)) { - return drafts[prop] - } - - const value = source(state)[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). - drafts = state.copy - } - - return (drafts[prop] = createProxy(value, state)) + if (prop === DRAFT_STATE) return state + let {drafts} = state + + // Check for existing draft in unmodified state. + if (!state.modified && has(drafts, prop)) { + return drafts[prop] + } + + const value = source(state)[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). + drafts = state.copy + } + + return (drafts[prop] = createProxy(value, state)) } function set(state, prop, value) { - 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 - markChanged(state) - } - state.assigned[prop] = true - state.copy[prop] = value - return 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 + markChanged(state) + } + state.assigned[prop] = true + state.copy[prop] = value + return true } function deleteProperty(state, prop) { - // The `undefined` check is a fast path for pre-existing keys. - if (peek(state.base, prop) !== undefined || prop in state.base) { - state.assigned[prop] = false - markChanged(state) - } - if (state.copy) delete state.copy[prop] - return true + // The `undefined` check is a fast path for pre-existing keys. + if (peek(state.base, prop) !== undefined || prop in state.base) { + state.assigned[prop] = false + markChanged(state) + } + if (state.copy) delete state.copy[prop] + return true } // Note: We never coerce `desc.value` into an Immer draft, because we can't make // the same guarantee in ES5 mode. function getOwnPropertyDescriptor(state, prop) { - const owner = source(state) - const desc = Reflect.getOwnPropertyDescriptor(owner, prop) - if (desc) { - desc.writable = true - desc.configurable = !Array.isArray(owner) || prop !== "length" - } - return desc + const owner = source(state) + const desc = Reflect.getOwnPropertyDescriptor(owner, prop) + if (desc) { + desc.writable = true + desc.configurable = !Array.isArray(owner) || prop !== "length" + } + return desc } function markChanged(state) { - if (!state.modified) { - state.modified = true - state.copy = assign(shallowCopy(state.base), state.drafts) - state.drafts = null - if (state.parent) markChanged(state.parent) - } + if (!state.modified) { + state.modified = true + state.copy = assign(shallowCopy(state.base), state.drafts) + state.drafts = null + if (state.parent) markChanged(state.parent) + } } diff --git a/src/scope.js b/src/scope.js index cf9b1c95..c1f30026 100644 --- a/src/scope.js +++ b/src/scope.js @@ -2,41 +2,41 @@ import {DRAFT_STATE} from "./common" /** Each scope represents a `produce` call. */ export class ImmerScope { - constructor(parent) { - this.drafts = [] - this.parent = parent + constructor(parent) { + this.drafts = [] + this.parent = parent - // Whenever the modified draft contains a draft from another scope, we - // need to prevent auto-freezing so the unowned draft can be finalized. - this.canAutoFreeze = true + // Whenever the modified draft contains a draft from another scope, we + // need to prevent auto-freezing so the unowned draft can be finalized. + this.canAutoFreeze = true - // To avoid prototype lookups: - this.patches = null - } - usePatches(patchListener) { - if (patchListener) { - this.patches = [] - this.inversePatches = [] - this.patchListener = patchListener - } - } - revoke() { - this.leave() - this.drafts.forEach(revoke) - this.drafts = null // Make draft-related methods throw. - } - leave() { - if (this === ImmerScope.current) { - ImmerScope.current = this.parent - } - } + // To avoid prototype lookups: + this.patches = null + } + usePatches(patchListener) { + if (patchListener) { + this.patches = [] + this.inversePatches = [] + this.patchListener = patchListener + } + } + revoke() { + this.leave() + this.drafts.forEach(revoke) + this.drafts = null // Make draft-related methods throw. + } + leave() { + if (this === ImmerScope.current) { + ImmerScope.current = this.parent + } + } } ImmerScope.current = null ImmerScope.enter = function() { - return (this.current = new ImmerScope(this.current)) + return (this.current = new ImmerScope(this.current)) } function revoke(draft) { - draft[DRAFT_STATE].revoke() + draft[DRAFT_STATE].revoke() }