diff --git a/__tests__/base.js b/__tests__/base.js index 34ad5ac4..c490618f 100644 --- a/__tests__/base.js +++ b/__tests__/base.js @@ -1,6 +1,6 @@ "use strict" import {Immer, nothing, original, isDraft, immerable} from "../src/index" -import {each, shallowCopy, isEnumerable, DRAFT_STATE} from "../src/common" +import {each, shallowCopy, isEnumerable} from "../src/common" import deepFreeze from "deep-freeze" import cloneDeep from "lodash.clonedeep" import * as lodash from "lodash" @@ -299,207 +299,6 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) { } }) - if (useProxies) { - describe("map drafts", () => { - it("supports key access", () => { - const value = baseState.aMap.get("jedi") - const nextState = produce(baseState, s => { - expect(s.aMap.get("jedi")).toEqual(value) - }) - expect(nextState).toBe(baseState) - }) - - it("supports iteration", () => { - const base = new Map([ - ["first", {id: 1, a: 1}], - ["second", {id: 2, a: 1}] - ]) - const findById = (map, id) => { - for (const [, item] of map) { - 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).not.toBe(base) - expect(result.get("first").a).toEqual(2) - expect(result.get("second").a).toEqual(2) - }) - - it("supports 'entries'", () => { - const base = new Map([ - ["first", {id: 1, a: 1}], - ["second", {id: 2, a: 1}] - ]) - const findById = (map, id) => { - for (const [, item] of map.entries()) { - 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).not.toBe(base) - expect(result.get("first").a).toEqual(2) - expect(result.get("second").a).toEqual(2) - }) - - it("supports 'values'", () => { - const base = new Map([ - ["first", {id: 1, a: 1}], - ["second", {id: 2, a: 1}] - ]) - const findById = (map, id) => { - for (const item of map.values()) { - 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).not.toBe(base) - expect(result.get("first").a).toEqual(2) - expect(result.get("second").a).toEqual(2) - }) - - it("supports 'keys", () => { - const base = new Map([ - ["first", Symbol()], - ["second", Symbol()] - ]) - const result = produce(base, draft => { - expect([...draft.keys()]).toEqual(["first", "second"]) - draft.set("third", Symbol()) - expect([...draft.keys()]).toEqual([ - "first", - "second", - "third" - ]) - }) - }) - - it("supports forEach", () => { - const base = new Map([ - ["first", {id: 1, a: 1}], - ["second", {id: 2, a: 1}] - ]) - const result = produce(base, draft => { - let sum1 = 0 - draft.forEach(({a}) => { - sum1 += a - }) - expect(sum1).toBe(2) - let sum2 = 0 - draft.get("first").a = 10 - draft.get("second").a = 20 - draft.forEach(({a}) => { - sum2 += a - }) - expect(sum2).toBe(30) - }) - }) - - it("supports forEach mutation", () => { - const base = new Map([ - ["first", {id: 1, a: 1}], - ["second", {id: 2, a: 1}] - ]) - const result = produce(base, draft => { - draft.forEach(item => { - item.a = 100 - }) - }) - expect(result).not.toBe(base) - expect(result.get("first").a).toEqual(100) - expect(result.get("second").a).toEqual(100) - }) - - it("can assign by key", () => { - const nextState = produce(baseState, s => { - // Map.prototype.set should return the Map itself - const res = s.aMap.set("force", true) - expect(res).toBe(s.aMap[DRAFT_STATE].draft) - }) - expect(nextState).not.toBe(baseState) - expect(nextState.aMap).not.toBe(baseState.aMap) - expect(nextState.aMap.get("force")).toEqual(true) - }) - - it("returns 'size'", () => { - const nextState = produce(baseState, s => { - s.aMap.set("newKey", true) - expect(s.aMap.size).toBe(baseState.aMap.size + 1) - }) - expect(nextState).not.toBe(baseState) - expect(nextState.aMap).not.toBe(baseState.aMap) - expect(nextState.aMap.get("newKey")).toEqual(true) - expect(nextState.aMap.size).toEqual(baseState.aMap.size + 1) - }) - - it("can use 'delete' to remove items", () => { - const nextState = produce(baseState, s => { - expect(s.aMap.has("jedi")).toBe(true) - s.aMap.delete("jedi") - expect(s.aMap.has("jedi")).toBe(false) - }) - expect(nextState.aMap).not.toBe(baseState.aMap) - expect(nextState.aMap.size).toBe(baseState.aMap.size - 1) - expect(baseState.aMap.has("jedi")).toBe(true) - expect(nextState.aMap.has("jedi")).toBe(false) - }) - - it("can use 'clear' to remove items", () => { - const nextState = produce(baseState, s => { - expect(s.aMap.size).not.toBe(0) - s.aMap.clear() - expect(s.aMap.size).toBe(0) - }) - expect(nextState.aMap).not.toBe(baseState.aMap) - expect(nextState.aMap.size).toBe(0) - }) - - it("support 'has'", () => { - const nextState = produce(baseState, s => { - expect(s.aMap.has("newKey")).toBe(false) - s.aMap.set("newKey", true) - expect(s.aMap.has("newKey")).toBe(true) - }) - expect(nextState).not.toBe(baseState) - expect(nextState.aMap).not.toBe(baseState.aMap) - expect(nextState.aMap.has("newKey")).toEqual(true) - }) - - it("supports nested maps", () => { - const base = new Map([ - ["first", new Map([["second", {prop: "test"}]])] - ]) - const result = produce(base, draft => { - draft.get("first").get("second").prop = "test1" - }) - expect(result).not.toBe(base) - expect(result.get("first")).not.toBe(base.get("first")) - expect(result.get("first").get("second")).not.toBe( - base.get("first").get("second") - ) - expect(base.get("first").get("second").prop).toBe("test") - expect(result.get("first").get("second").prop).toBe("test1") - }) - }) - } - it("supports `immerable` symbol on constructor", () => { class One {} One[immerable] = true @@ -1302,11 +1101,6 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) { const data = { anInstance: new Foo(), anArray: [3, 2, {c: 3}, 1], - aMap: new Map([ - ["jedi", {name: "Luke", skill: 10}], - ["jediTotal", 42], - ["force", "these aren't the droids you're looking for"] - ]), aProp: "hi", anObject: { nested: { diff --git a/__tests__/patch.js b/__tests__/patch.js index 8bc0a692..ecc457f3 100644 --- a/__tests__/patch.js +++ b/__tests__/patch.js @@ -3,13 +3,7 @@ import produce, {setUseProxies, applyPatches} from "../src/index" jest.setTimeout(1000) -function runPatchTest( - base, - producer, - patches, - inversePathes, - proxyOnly = false -) { +function runPatchTest(base, producer, patches, inversePathes) { function runPatchTestHelper() { let recordedPatches let recordedInversePatches @@ -34,15 +28,13 @@ function runPatchTest( } describe(`proxy`, () => { - beforeAll(() => setUseProxies(true)) + setUseProxies(true) runPatchTestHelper() }) describe(`es5`, () => { - if (!proxyOnly) { - beforeAll(() => setUseProxies(false)) - runPatchTestHelper() - } + setUseProxies(false) + runPatchTestHelper() }) } @@ -116,47 +108,6 @@ describe("simple assignment - 3", () => { ) }) -describe("simple assignment - 4", () => { - runPatchTest( - new Map([["x", {y: 4}]]), - d => { - d.get("x").y++ - }, - [{op: "replace", path: ["x", "y"], value: 5}], - [{op: "replace", path: ["x", "y"], value: 4}], - true - ) -}) - -describe("simple assignment - 5", () => { - runPatchTest( - {x: new Map([["y", 4]])}, - d => { - d.x.set("y", 5) - }, - [{op: "replace", path: ["x", "y"], value: 5}], - [{op: "replace", path: ["x", "y"], value: 4}], - true - ) -}) - -describe("simple assignment - 6", () => { - runPatchTest( - new Map([["x", 1]]), - d => { - // Map.prototype.set should return the Map itself - const res = d.set("x", 2) - res.set("y", 3) - }, - [ - {op: "replace", path: ["x"], value: 2}, - {op: "add", path: ["y"], value: 3} - ], - [{op: "replace", path: ["x"], value: 1}, {op: "remove", path: ["y"]}], - true - ) -}) - describe("delete 1", () => { runPatchTest( {x: {y: 4}}, @@ -167,30 +118,6 @@ describe("delete 1", () => { ) }) -describe("delete 2", () => { - runPatchTest( - new Map([["x", 1]]), - d => { - d.delete("x") - }, - [{op: "remove", path: ["x"]}], - [{op: "add", path: ["x"], value: 1}], - true - ) -}) - -describe("delete 3", () => { - runPatchTest( - {x: new Map([["y", 1]])}, - d => { - d.x.delete("y") - }, - [{op: "remove", path: ["x", "y"]}], - [{op: "add", path: ["x", "y"], value: 1}], - true - ) -}) - describe("renaming properties", () => { describe("nested object (no changes)", () => { runPatchTest( @@ -206,25 +133,6 @@ describe("renaming properties", () => { ) }) - describe("nested map (no changes)", () => { - runPatchTest( - new Map([["a", new Map([["b", 1]])]]), - d => { - d.set("x", d.get("a")) - d.delete("a") - }, - [ - {op: "add", path: ["x"], value: new Map([["b", 1]])}, - {op: "remove", path: ["a"]} - ], - [ - {op: "remove", path: ["x"]}, - {op: "add", path: ["a"], value: new Map([["b", 1]])} - ], - true - ) - }) - describe("nested object (with changes)", () => { runPatchTest( {a: {b: 1, c: 1}}, @@ -245,31 +153,6 @@ describe("renaming properties", () => { ) }) - describe("nested map (with changes)", () => { - runPatchTest( - new Map([["a", new Map([["b", 1], ["c", 1]])]]), - d => { - let a = d.get("a") - a.set("b", 2) // change - a.delete("c") // delete - a.set("y", 2) // add - - // rename - d.set("x", a) - d.delete("a") - }, - [ - {op: "add", path: ["x"], value: new Map([["b", 2], ["y", 2]])}, - {op: "remove", path: ["a"]} - ], - [ - {op: "remove", path: ["x"]}, - {op: "add", path: ["a"], value: new Map([["b", 1], ["c", 1]])} - ], - true - ) - }) - describe("deeply nested object (with changes)", () => { runPatchTest( {a: {b: {c: 1, d: 1}}}, @@ -289,39 +172,6 @@ describe("renaming properties", () => { ] ) }) - - describe("deeply nested map (with changes)", () => { - runPatchTest( - new Map([["a", new Map([["b", new Map([["c", 1], ["d", 1]])]])]]), - d => { - let b = d.get("a").get("b") - b.set("c", 2) // change - b.delete("d") // delete - b.set("y", 2) // add - - // rename - d.get("a").set("x", b) - d.get("a").delete("b") - }, - [ - { - op: "add", - path: ["a", "x"], - value: new Map([["c", 2], ["y", 2]]) - }, - {op: "remove", path: ["a", "b"]} - ], - [ - {op: "remove", path: ["a", "x"]}, - { - op: "add", - path: ["a", "b"], - value: new Map([["c", 1], ["d", 1]]) - } - ], - true - ) - }) }) describe("minimum amount of changes", () => { @@ -556,19 +406,6 @@ describe("same value replacement - 4", () => { ) }) -describe("same value replacement - 5", () => { - runPatchTest( - new Map([["x", 3]]), - d => { - d.set("x", 4) - d.set("x", 3) - }, - [], - [], - true - ) -}) - describe("simple delete", () => { runPatchTest( {x: 2}, diff --git a/__tests__/readme.js b/__tests__/readme.js index acfe3a4c..31306fa9 100644 --- a/__tests__/readme.js +++ b/__tests__/readme.js @@ -108,20 +108,27 @@ describe("readme example", () => { }) }) - it("can deep update map", () => { + it("can deep udpate map", () => { const state = { - users: new Map([["michel", {name: "miche", age: 27}]]) + users: new Map([["michel", {name: "miche"}]]) } const nextState = produce(state, draft => { - draft.users.get("michel").name = "michel" + 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", age: 27}]]) - }) + expect(state).toEqual({users: new Map([["michel", {name: "miche"}]])}) expect(nextState).toEqual({ - users: new Map([["michel", {name: "michel", age: 27}]]) + users: new Map([["michel", {name: "michel"}]]) }) }) diff --git a/readme.md b/readme.md index 3d89cfb1..2c0cc46e 100644 --- a/readme.md +++ b/readme.md @@ -528,7 +528,7 @@ import {produce as unleashTheMagic} from "immer" ## Supported object types -Plain objects, arrays, and `Map` objects are always drafted by Immer. +Plain objects and arrays are always drafted by Immer. Every other object must use the `immerable` symbol to mark itself as compatible with Immer. When one of these objects is mutated within a producer, its prototype is preserved between copies. @@ -550,11 +550,12 @@ For arrays, only numeric properties and the `length` property can be mutated. Cu When working with `Date` objects, you should always create a new `Date` instance instead of mutating an existing `Date` object. -`Set` objects cannot be drafted. As a workaround, you should clone them before mutating them in a producer: +Built-in classes like `Map` and `Set` are not supported. As a workaround, you should clone them before mutating them in a producer: ```js const state = { - set: new Set() + set: new Set(), + map: new Map() } const nextState = produce(state, draft => { // Don't use any Set methods, as that mutates the instance! @@ -568,6 +569,18 @@ const nextState = produce(state, draft => { // 3. Update the draft with the new set draft.set = newSet + + // Similarly, don't use any Map methods. + draft.map.set("foo", "bar") // ❌ + + // 1. Instead, clone the map (just once) + const newMap = new Map(draft.map) // ✅ + + // 2. Mutate it + newMap.set("foo", "bar") + + // 3. Update the draft + draft.map = newMap }) ``` diff --git a/src/common.js b/src/common.js index 34017c31..2a3463b5 100644 --- a/src/common.js +++ b/src/common.js @@ -20,7 +20,6 @@ export function isDraftable(value) { if (Array.isArray(value)) return true const proto = Object.getPrototypeOf(value) if (!proto || proto === Object.prototype) return true - if (isMap(value)) return true return !!value[DRAFTABLE] || !!value.constructor[DRAFTABLE] } @@ -31,35 +30,16 @@ export function original(value) { // otherwise return undefined } -function assignMap(target, overrides) { - overrides.forEach(function(override) { - for (let key in override) { - if (has(override, key)) { - target.set(key, override[key]) +export const assign = + Object.assign || + function assign(target, value) { + for (let key in value) { + if (has(value, key)) { + target[key] = value[key] } } - }) - return target -} -function assignObjectLegacy(target, overrides) { - overrides.forEach(function(override) { - for (let key in override) { - if (has(override, key)) { - target[key] = override[key] - } - } - }) - return target -} -export function assign(target, ...overrides) { - if (isMap(target)) { - return assignMap(target, overrides) + return target } - if (Object.assign) { - return Object.assign(target, ...overrides) - } - return assignObjectLegacy(target, overrides) -} export const ownKeys = typeof Reflect !== "undefined" && Reflect.ownKeys @@ -73,7 +53,6 @@ export const ownKeys = export function shallowCopy(base, invokeGetters = false) { if (Array.isArray(base)) return base.slice() - if (isMap(base)) return new Map(base) const clone = Object.create(Object.getPrototypeOf(base)) ownKeys(base).forEach(key => { if (key === DRAFT_STATE) { @@ -101,11 +80,11 @@ export function shallowCopy(base, invokeGetters = false) { } export function each(value, cb) { - if (Array.isArray(value) || isMap(value)) { - value.forEach((entry, index) => cb(index, entry, value)) - return + 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)) } - ownKeys(value).forEach(key => cb(key, value[key], value)) } export function isEnumerable(base, prop) { @@ -125,7 +104,3 @@ export function is(x, y) { return x !== x && y !== y } } - -export function isMap(target) { - return target instanceof Map -} diff --git a/src/immer.js b/src/immer.js index 2deb5038..37eb140c 100644 --- a/src/immer.js +++ b/src/immer.js @@ -9,7 +9,6 @@ import { isDraft, isDraftable, isEnumerable, - isMap, shallowCopy, DRAFT_STATE, NOTHING @@ -259,7 +258,12 @@ export class Immer { scope.canAutoFreeze = false } - setProperty(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 @@ -282,16 +286,3 @@ export class Immer { return root } } - -function setProperty(parent, prop, value) { - // Preserve non-enumerable properties. - if (Array.isArray(parent) || isEnumerable(parent, prop)) { - parent[prop] = value - return - } - if (isMap(parent)) { - parent.set(prop, value) - return - } - Object.defineProperty(parent, prop, {value}) -} diff --git a/src/patches.js b/src/patches.js index 748f5050..889d51f1 100644 --- a/src/patches.js +++ b/src/patches.js @@ -1,19 +1,9 @@ -import {each, isMap} from "./common" +import {each} from "./common" export function generatePatches(state, basePath, patches, inversePatches) { - const generatePatchesFn = Array.isArray(state.base) - ? generateArrayPatches - : isMap(state.base) - ? generatePatchesFromAssigned( - (map, key) => map.get(key), - (map, key) => map.has(key) - ) - : generatePatchesFromAssigned( - (obj, key) => obj[key], - (obj, key) => key in obj - ) - - generatePatchesFn(state, basePath, patches, inversePatches) + Array.isArray(state.base) + ? generateArrayPatches(state, basePath, patches, inversePatches) + : generateObjectPatches(state, basePath, patches, inversePatches) } function generateArrayPatches(state, basePath, patches, inversePatches) { @@ -85,29 +75,23 @@ function generateArrayPatches(state, basePath, patches, inversePatches) { } } -function generatePatchesFromAssigned(getValueByKey, hasKey) { - return function(state, basePath, patches, inversePatches) { - const {base, copy} = state - each(state.assigned, (key, assignedValue) => { - const origValue = getValueByKey(base, key) - const value = getValueByKey(copy, key) - const op = !assignedValue - ? "remove" - : hasKey(base, key) - ? "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} - ) - }) - } +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} + ) + }) } export function applyPatches(draft, patches) { @@ -119,40 +103,29 @@ export function applyPatches(draft, patches) { } else { let base = draft for (let i = 0; i < path.length - 1; i++) { - if (isMap(base.base)) { - base = base.get(path[i]) - } else { - base = base[path[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] - - const replace = (key, value) => - isMap(base.base) ? base.set(key, value) : (base[key] = value) - const add = (key, value) => - Array.isArray(base) - ? base.splice(key, 0, value) - : isMap(base.base) - ? base.set(key, value) - : (base[key] = value) - const remove = key => - Array.isArray(base) - ? base.splice(key, 1) - : isMap(base.base) - ? base.delete(key) - : delete base[key] - switch (patch.op) { case "replace": - replace(key, patch.value) + base[key] = patch.value break case "add": - add(key, patch.value) + 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": - remove(key) + if (Array.isArray(base)) { + base.splice(key, 1) + } else { + delete base[key] + } break default: throw new Error("Unsupported patch operation: " + patch.op) diff --git a/src/proxy.js b/src/proxy.js index c27c25c3..da67e579 100644 --- a/src/proxy.js +++ b/src/proxy.js @@ -6,7 +6,6 @@ import { is, isDraftable, isDraft, - isMap, shallowCopy, DRAFT_STATE } from "./common" @@ -15,11 +14,6 @@ import {ImmerScope} from "./scope" // Do nothing before being finalized. export function willFinalize() {} -/** - * Returns a new draft of the `base` object. - * - * The second argument is the parent draft-state (used internally). - */ export function createProxy(base, parent) { const scope = parent ? parent.scope : ImmerScope.current const state = { @@ -45,16 +39,11 @@ export function createProxy(base, parent) { revoke: null } - let proxyTarget = state - let traps = objectTraps - if (Array.isArray(base)) { - proxyTarget = [state] - traps = arrayTraps - } else if (isMap(base)) { - traps = mapTraps - } - - const {revoke, proxy} = Proxy.revocable(proxyTarget, traps) + 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 @@ -63,92 +52,28 @@ export function createProxy(base, parent) { return proxy } -/** - * Object drafts - */ - const objectTraps = { - 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)) - }, - has(state, prop) { - return prop in source(state) + get, + has(target, prop) { + return prop in source(target) }, - ownKeys(state) { - return Reflect.ownKeys(source(state)) - }, - 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 - }, - 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 - }, - // Note: We never coerce `desc.value` into an Immer draft, because we can't make - // the same guarantee in ES5 mode. - 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 + 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(state) { - return Object.getPrototypeOf(state.base) + getPrototypeOf(target) { + return Object.getPrototypeOf(target.base) }, setPrototypeOf() { throw new Error("Object.setPrototypeOf() cannot be used on an Immer draft") // prettier-ignore } } -/** - * Array drafts - */ - const arrayTraps = {} each(objectTraps, (key, fn) => { arrayTraps[key] = function() { @@ -169,116 +94,83 @@ arrayTraps.set = function(state, prop, value) { return objectTraps.set.call(this, state[0], prop, value) } -/** - * Map drafts - */ - -const mapTraps = { - get(state, prop, receiver) { - const getter = mapGetters[prop] - return getter - ? getter(state, prop, receiver) - : Reflect.get(state, prop, receiver) - } +// 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 } -const mapGetters = { - [DRAFT_STATE]: state => state, - size: state => source(state).size, - has(state, prop) { - state = source(state) - return state.has.bind(state) - }, - set: state => (key, value) => { - markChanged(state) - state.assigned[key] = true - state.copy.set(key, value) - return state.draft - }, - delete: state => key => { - markChanged(state) - state.assigned[key] = false - return state.copy.delete(key) - }, - clear: state => () => { - markChanged(state) - state.assigned = {} - for (const key of source(state).keys()) { - state.assigned[key] = false - } - return state.copy.clear() - }, - forEach: (state, _, receiver) => (cb, thisArg) => - source(state).forEach((_, key, map) => { - const value = receiver.get(key) - cb.call(thisArg, value, key, map) - }), - get: state => key => { - if (!state.modified && has(state.drafts, key)) { - return state.drafts[key] - } - if (state.modified && state.copy.has(key)) { - return state.copy.get(key) - } +// 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 value = source(state).get(key) +function get(state, prop) { + if (prop === DRAFT_STATE) return state + let {drafts} = state - if (state.finalized || !isDraftable(value)) { - return value - } + // Check for existing draft in unmodified state. + if (!state.modified && has(drafts, prop)) { + return drafts[prop] + } - const draft = createProxy(value, state) + const value = source(state)[prop] + if (state.finalized || !isDraftable(value)) { + return value + } - if (!state.modified) { - state.drafts[key] = draft - } else { - state.copy.set(key, draft) - } + // 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 draft - }, - keys: state => () => source(state).keys(), - values: iterateMapValues, - entries: iterateMapValues, - [Symbol.iterator]: iterateMapValues + return (drafts[prop] = createProxy(value, state)) } -/** Map.prototype.values _-or-_ Map.prototype.entries */ -function iterateMapValues(state, prop, receiver) { - const getYieldable = - prop === "values" ? (key, value) => value : (key, value) => [key, value] - - return () => { - const iterator = source(state)[Symbol.iterator]() - return makeIterable(() => { - const result = iterator.next() - if (!result.done) { - const [key] = result.value - const value = receiver.get(key) - result.value = getYieldable(key, value) - } - return result - }) +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 } -/** - * Helpers - */ - -// 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 +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 } -// 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 +// 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 } function markChanged(state) { @@ -289,11 +181,3 @@ function markChanged(state) { if (state.parent) markChanged(state.parent) } } - -function makeIterable(next) { - let self - return (self = { - [Symbol.iterator]: () => self, - next - }) -}