diff --git a/__tests__/base.js b/__tests__/base.js index eb10df2a..cb61bfbd 100644 --- a/__tests__/base.js +++ b/__tests__/base.js @@ -188,7 +188,10 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) { }) it("supports iteration", () => { - const base = [{id: 1, a: 1}, {id: 2, a: 1}] + 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 @@ -386,7 +389,10 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) { }) it("supports 'keys", () => { - const base = new Map([["first", Symbol()], ["second", Symbol()]]) + const base = new Map([ + ["first", Symbol()], + ["second", Symbol()] + ]) const result = produce(base, draft => { expect([...draft.keys()]).toEqual(["first", "second"]) draft.set("third", Symbol()) @@ -490,7 +496,7 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) { 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.delete("jedi")).toBe(true) expect(s.aMap.has("jedi")).toBe(false) }) expect(nextState.aMap).not.toBe(baseState.aMap) @@ -535,11 +541,55 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) { expect(base.get("first").get("second").prop).toBe("test") expect(result.get("first").get("second").prop).toBe("test1") }) + + it("treats void deletes as no-op", () => { + const base = new Map([["x", 1]]) + const next = produce(base, d => { + expect(d.delete("y")).toBe(false) + }) + expect(next).toBe(base) + }) + + it("revokes map proxies", () => { + let m + produce(baseState, s => { + m = s.aMap + }) + expect(() => m.get("x")).toThrow( + "Cannot use a proxy that has been revoked" + ) + expect(() => m.set("x", 3)).toThrow( + "Cannot use a proxy that has been revoked" + ) + }) + + it("does not draft map keys", () => { + // anything else would be terribly confusing + const key = {a: 1} + const map = new Map([[key, 2]]) + const next = produce(map, d => { + const dKey = Array.from(d.keys())[0] + expect(isDraft(dKey)).toBe(false) + expect(dKey).toBe(key) + dKey.a += 1 + d.set(dKey, d.get(dKey) + 1) + d.set(key, d.get(key) + 1) + expect(d.get(key)).toBe(4) + expect(key.a).toBe(2) + }) + const entries = Array.from(next.entries()) + expect(entries).toEqual([[key, 4]]) + expect(entries[0][0]).toBe(key) + expect(entries[0][0].a).toBe(2) + }) }) describe("set drafts", () => { it("supports iteration", () => { - const base = new Set([{id: 1, a: 1}, {id: 2, a: 1}]) + const base = new Set([ + {id: 1, a: 1}, + {id: 2, a: 1} + ]) const findById = (set, id) => { for (const item of set) { if (item.id === id) return item @@ -553,12 +603,25 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) { obj2.a = 2 }) expect(result).not.toBe(base) - expect(base).toEqual(new Set([{id: 1, a: 1}, {id: 2, a: 1}])) - expect(result).toEqual(new Set([{id: 1, a: 2}, {id: 2, a: 2}])) + expect(base).toEqual( + new Set([ + {id: 1, a: 1}, + {id: 2, a: 1} + ]) + ) + expect(result).toEqual( + new Set([ + {id: 1, a: 2}, + {id: 2, a: 2} + ]) + ) }) it("supports 'entries'", () => { - const base = new Set([{id: 1, a: 1}, {id: 2, a: 1}]) + const base = new Set([ + {id: 1, a: 1}, + {id: 2, a: 1} + ]) const findById = (set, id) => { for (const [item1, item2] of set.entries()) { expect(item1).toBe(item2) @@ -573,12 +636,25 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) { obj2.a = 2 }) expect(result).not.toBe(base) - expect(base).toEqual(new Set([{id: 1, a: 1}, {id: 2, a: 1}])) - expect(result).toEqual(new Set([{id: 1, a: 2}, {id: 2, a: 2}])) + expect(base).toEqual( + new Set([ + {id: 1, a: 1}, + {id: 2, a: 1} + ]) + ) + expect(result).toEqual( + new Set([ + {id: 1, a: 2}, + {id: 2, a: 2} + ]) + ) }) it("supports 'values'", () => { - const base = new Set([{id: 1, a: 1}, {id: 2, a: 1}]) + const base = new Set([ + {id: 1, a: 1}, + {id: 2, a: 1} + ]) const findById = (set, id) => { for (const item of set.values()) { if (item.id === id) return item @@ -592,12 +668,25 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) { obj2.a = 2 }) expect(result).not.toBe(base) - expect(base).toEqual(new Set([{id: 1, a: 1}, {id: 2, a: 1}])) - expect(result).toEqual(new Set([{id: 1, a: 2}, {id: 2, a: 2}])) + expect(base).toEqual( + new Set([ + {id: 1, a: 1}, + {id: 2, a: 1} + ]) + ) + expect(result).toEqual( + new Set([ + {id: 1, a: 2}, + {id: 2, a: 2} + ]) + ) }) it("supports 'keys'", () => { - const base = new Set([{id: 1, a: 1}, {id: 2, a: 1}]) + const base = new Set([ + {id: 1, a: 1}, + {id: 2, a: 1} + ]) const findById = (set, id) => { for (const item of set.keys()) { if (item.id === id) return item @@ -611,12 +700,25 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) { obj2.a = 2 }) expect(result).not.toBe(base) - expect(base).toEqual(new Set([{id: 1, a: 1}, {id: 2, a: 1}])) - expect(result).toEqual(new Set([{id: 1, a: 2}, {id: 2, a: 2}])) + expect(base).toEqual( + new Set([ + {id: 1, a: 1}, + {id: 2, a: 1} + ]) + ) + expect(result).toEqual( + new Set([ + {id: 1, a: 2}, + {id: 2, a: 2} + ]) + ) }) it("supports forEach with mutation after reads", () => { - const base = new Set([{id: 1, a: 1}, {id: 2, a: 2}]) + const base = new Set([ + {id: 1, a: 1}, + {id: 2, a: 2} + ]) const result = produce(base, draft => { let sum1 = 0 draft.forEach(({a}) => { @@ -631,8 +733,18 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) { expect(sum2).toBe(23) }) expect(result).not.toBe(base) - expect(base).toEqual(new Set([{id: 1, a: 1}, {id: 2, a: 2}])) - expect(result).toEqual(new Set([{id: 1, a: 11}, {id: 2, a: 12}])) + expect(base).toEqual( + new Set([ + {id: 1, a: 1}, + {id: 2, a: 2} + ]) + ) + expect(result).toEqual( + new Set([ + {id: 1, a: 11}, + {id: 2, a: 12} + ]) + ) }) it("state stays the same if the same item is added", () => { @@ -669,13 +781,14 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) { it("can use 'delete' to remove items", () => { const nextState = produce(baseState, s => { expect(s.aSet.has("Luke")).toBe(true) - s.aSet.delete("Luke") + expect(s.aSet.delete("Luke")).toBe(true) + expect(s.aSet.delete("Luke")).toBe(false) expect(s.aSet.has("Luke")).toBe(false) }) expect(nextState.aSet).not.toBe(baseState.aSet) - expect(nextState.aSet.size).toBe(baseState.aSet.size - 1) expect(baseState.aSet.has("Luke")).toBe(true) expect(nextState.aSet.has("Luke")).toBe(false) + expect(nextState.aSet.size).toBe(baseState.aSet.size - 1) }) it("can use 'clear' to remove items", () => { @@ -710,6 +823,32 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) { expect(base).toEqual(new Set([new Set(["Serenity"])])) expect(result).toEqual(new Set([new Set(["Serenity", "Firefly"])])) }) + + it("supports has / delete on elements from the original", () => { + const obj = {} + const set = new Set([obj]) + const next = produce(set, d => { + expect(d.has(obj)).toBe(true) + d.add(3) + expect(d.has(obj)).toBe(true) + d.delete(obj) + expect(d.has(obj)).toBe(false) + }) + expect(next).toEqual(new Set([3])) + }) + + it("revokes sets", () => { + let m + produce(baseState, s => { + m = s.aSet + }) + expect(() => m.has("x")).toThrow( + "Cannot use a proxy that has been revoked" + ) + expect(() => m.add("x")).toThrow( + "Cannot use a proxy that has been revoked" + ) + }) }) it("supports `immerable` symbol on constructor", () => { @@ -1236,6 +1375,20 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) { expect(result[0].a).toEqual(2) }) + it("does not draft external data", () => { + const externalData = {x: 3} + const base = {} + const next = produce(base, draft => { + // potentially, we *could* draft external data automatically, but only if those statements are not switched... + draft.y = externalData + draft.y.x += 1 + externalData.x += 1 + }) + expect(next).toEqual({y: {x: 5}}) + expect(externalData.x).toBe(5) + expect(next.y).toBe(externalData) + }) + autoFreeze && test("issue #469, state not frozen", () => { const project = produce( @@ -1598,7 +1751,10 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) { const c = {c: 3} const set1 = new Set([a, b]) const set2 = new Set([c]) - const map = new Map([["set1", set1], ["set2", set2]]) + const map = new Map([ + ["set1", set1], + ["set2", set2] + ]) const base = {map} function first(set) { diff --git a/__tests__/empty.ts b/__tests__/empty.ts index f9f5f47a..9b0fad1b 100644 --- a/__tests__/empty.ts +++ b/__tests__/empty.ts @@ -1,13 +1,141 @@ -import {produce, produceWithPatches} from "../src" +import {produce, produceWithPatches, setUseProxies} from "../src" +import {DRAFT_STATE} from "../src/common" test("empty stub test", () => { expect(true).toBe(true) }) -test("delete 7", () => { - debugger - const [_, p] = produceWithPatches(new Set(["y", 1]), d => { - d.delete("y") +describe("map set - es5", () => { + test("can assign set value", () => { + setUseProxies(false) + + const baseState = new Map([["x", 1]]) + const nextState = produce(baseState, s => { + s.set("x", 2) + }) + expect(baseState.get("x")).toEqual(1) + expect(nextState).not.toBe(baseState) + expect(nextState.get("x")).toEqual(2) + }) + + test("can assign by key", () => { + setUseProxies(false) + + const baseState = new Map([["x", {a: 1}]]) + const nextState = produce(baseState, s => { + s.get("x")!.a++ + }) + expect(nextState.get("x")!.a).toEqual(2) + expect(baseState.get("x")!.a).toEqual(1) + expect(nextState).not.toBe(baseState) + }) +}) + +describe("map set - proxy", () => { + test("can assign set value", () => { + setUseProxies(true) + + const baseState = new Map([["x", 1]]) + const nextState = produce(baseState, s => { + s.set("x", 2) + }) + expect(baseState.get("x")).toEqual(1) + expect(nextState).not.toBe(baseState) + expect(nextState.get("x")).toEqual(2) + }) + + test("can assign by key", () => { + setUseProxies(true) + + const baseState = new Map([["x", {a: 1}]]) + const nextState = produce(baseState, s => { + s.get("x")!.a++ + }) + expect(nextState.get("x")!.a).toEqual(2) + expect(baseState.get("x")!.a).toEqual(1) + expect(nextState).not.toBe(baseState) + }) + + test("deep change bubbles up", () => { + setUseProxies(true) + + const baseState = createBaseState() + 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 assign by key", () => { + setUseProxies(true) + const baseState = createBaseState() + + 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 as any)[DRAFT_STATE].draft) + }) + expect(nextState).not.toBe(baseState) + expect(nextState.aMap).not.toBe(baseState.aMap) + expect(nextState.aMap.get("force")).toEqual(true) + }) + + it("can use 'delete' to remove items", () => { + const baseState = createBaseState() + + const nextState = produce(baseState, s => { + expect(s.aMap.has("jedi")).toBe(true) + expect(s.aMap.delete("jedi")).toBe(true) + 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("support 'has'", () => { + const baseState = createBaseState() + + 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(baseState.aMap.has("newKey")).toBe(false) + expect(nextState.aMap.has("newKey")).toBe(true) }) - expect(p.length).toBe(1) }) + +function createBaseState() { + const data = { + anInstance: new (class {})(), + 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"] + ] as any), + aSet: new Set([ + "Luke", + 42, + { + jedi: "Yoda" + } + ]), + aProp: "hi", + anObject: { + nested: { + yummie: true + }, + coffee: false + } + } + return data +} diff --git a/__tests__/map-set.js b/__tests__/map-set.js new file mode 100644 index 00000000..3f7b6876 --- /dev/null +++ b/__tests__/map-set.js @@ -0,0 +1,197 @@ +"use strict" +import {Immer, nothing, original, isDraft, immerable} from "../src/index" +import {each, shallowCopy, isEnumerable, DRAFT_STATE} from "../src/common" + +jest.setTimeout(1000) + +runBaseTest("proxy (no freeze)", true, false) +// runBaseTest("proxy (autofreeze)", true, true) +// runBaseTest("proxy (autofreeze)(patch listener)", true, true, true) +// runBaseTest("es5 (no freeze)", false, false) +// 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, produceWithPatches} = 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 = function(...args) { + return typeof args[1] === "function" && args.length < 3 + ? produce(...args, listener) + : produce(...args) + } + + return immer + } + + describe("map issues " + name, () => { + test("#472 ", () => { + const project = produce(new Map(), draft => { + draft.set("bar1", {blocked: false}) + draft.set("bar2", {blocked: false}) + }) + + // Read before write -- no error + produce(project, draft => { + const bar1 = draft.get("bar1") + const bar2 = draft.get("bar2") + bar1.blocked = true + bar2.blocked = true + }) + + // Read/write interleaved -- error + produce(project, draft => { + const bar1 = draft.get("bar1") + bar1.blocked = true + const bar2 = draft.get("bar2") + bar2.blocked = true // TypeError: "blocked" is read-only + }) + }) + + test("#466 - setNoPatches", () => { + const obj = { + set: new Set() + } + + const result = produceWithPatches(obj, draft => { + draft.set.add("abc") + }) + expect(result).toEqual([ + {set: new Set(["abc"])}, + [{op: "add", path: ["set", 0], value: "abc"}], + [{op: "remove", path: ["set", 0], value: "abc"}] + ]) + }) + + test("#466 - mapChangeBug ", () => { + const obj = { + map: new Map([ + ["a", new Map([["b", true], ["c", true], ["d", true]])], + ["b", new Map([["a", true]])], + ["c", new Map([["a", true]])], + ["d", new Map([["a", true]])] + ]) + } + const result = produceWithPatches(obj, draft => { + const aMap = draft.map.get("a") + aMap.forEach((_, other) => { + const otherMap = draft.map.get(other) + otherMap.delete("a") + }) + }) + expect(result).toEqual([ + { + map: new Map([ + ["a", new Map([["b", true], ["c", true], ["d", true]])], + ["b", new Map()], + ["c", new Map()], + ["d", new Map()] + ]) + }, + [ + { + op: "remove", + path: ["map", "b", "a"] + }, + { + op: "remove", + path: ["map", "c", "a"] + }, + { + op: "remove", + path: ["map", "d", "a"] + } + ], + [ + { + op: "add", + path: ["map", "b", "a"], + value: true + }, + { + op: "add", + path: ["map", "c", "a"], + value: true + }, + { + op: "add", + path: ["map", "d", "a"], + value: true + } + ] + ]) + }) + + test("#466 - mapChangeBug2 ", () => { + const obj = { + map: new Map([ + ["a", new Map([["b", true], ["c", true], ["d", true]])], + ["b", new Map([["a", true]])], + ["c", new Map([["a", true]])], + ["d", new Map([["a", true]])] + ]) + } + const obj1 = produce(obj, draft => {}) + const [result, p, ip] = produceWithPatches(obj1, draft => { + const aMap = draft.map.get("a") + aMap.forEach((_, other) => { + const otherMap = draft.map.get(other) + otherMap.delete("a") + }) + }) + expect(result).toEqual( + { + map: new Map([ + ["a", new Map([["b", true], ["c", true], ["d", true]])], + ["b", new Map([])], + ["c", new Map([])], + ["d", new Map([])] + ]) + } + ) + expect(p).toEqual( + [ + { + op: "remove", + path: ["map", "b", "a"] + }, + { + op: "remove", + path: ["map", "c", "a"] + }, + { + op: "remove", + path: ["map", "d", "a"] + } + ]) + expect(ip).toEqual( + [ + { + op: "add", + path: ["map", "b", "a"], + value: true + }, + { + op: "add", + path: ["map", "c", "a"], + value: true + }, + { + op: "add", + path: ["map", "d", "a"], + value: true + } + ] + ) + }) + }) +} diff --git a/__tests__/polyfills.js b/__tests__/polyfills.js index 0a4a39ac..53a1415c 100644 --- a/__tests__/polyfills.js +++ b/__tests__/polyfills.js @@ -30,33 +30,6 @@ describe("Symbol", () => { }) }) -describe("Object.assign", () => { - 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 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("can handle null", () => { - const res = assign({a: 1, b: 2}, null, {a: 2, c: 2}) - expect(res).toEqual({a: 2, b: 2, c: 2}) - }) -}) - describe("Reflect.ownKeys", () => { const {ownKeys} = common diff --git a/__tests__/tsconfig.json b/__tests__/tsconfig.json index 8e92c580..19c0864b 100644 --- a/__tests__/tsconfig.json +++ b/__tests__/tsconfig.json @@ -2,6 +2,7 @@ "include": ["*", "../dist"], "compilerOptions": { "lib": ["es2015"], - "strict": true + "strict": true, + "noUnusedLocals": false } } diff --git a/docs/complex-objects.md b/docs/complex-objects.md index e5dc5a30..79fb26d8 100644 --- a/docs/complex-objects.md +++ b/docs/complex-objects.md @@ -72,3 +72,5 @@ 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. Maps and Sets that are produced by Immer will be made artificially immutable. This means that they will throw an exception when trying mutative methods like `set`, `clear` etc. outside a producer. + +_Note: The **keys** of a map are never drafted! This is done to avoid confusing semantics and keep keys always referentially equal_ diff --git a/docs/pitfalls.md b/docs/pitfalls.md index d5a2610d..04e74976 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -10,3 +10,20 @@ title: Pitfalls 1. Since Immer uses proxies, reading huge amounts of data from state comes with an overhead (especially in the ES5 implementation). If this ever becomes an issue (measure before you optimize!), do the current state analysis before entering the producer function or read from the `currentState` rather than the `draftState`. Also, realize that immer is opt-in everywhere, so it is perfectly fine to manually write super performance critical reducers, and use immer for all the normal ones. Also note that `original` can be used to get the original state of an object, which is cheaper to read. 1. Always try to pull `produce` 'up', for example `for (let x of y) produce(base, d => d.push(x))` is exponentially slower than `produce(base, d => { for (let x of y) d.push(x)})` 1. It is possible to return values from producers, except, it is not possible to return `undefined` that way, as it is indistinguishable from not updating the draft at all! If you want to replace the draft with `undefined`, just return `nothing` from the producer. +1. Note that data that comes from the closure, and not from the base state, will never be drafted, even when the data has become part of the new draft: + +```javascript +function onReceiveTodo(todo) { + const nextTodos = produce(todos, draft => { + draft.todos[todo.id] = todo + // Note, because 'todo' is coming from external, and not from the 'draft', + // it isn't draft so the following modification affects the original todo! + draft.todos[todo.id].done = true + + // The reason for this, is that it means that the behavior of the 2 lines above + // is equivalent to code, making this whole process more consistent + todo.done = true + draft.todos[todo.id] = todo + }) +} +``` diff --git a/package.json b/package.json index d0a9bf5e..30950002 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "jest": "^24.7.1", "lodash": "^4.17.4", "lodash.clonedeep": "^4.5.0", - "prettier": "1.17.0", + "prettier": "1.19.1", "pretty-quick": "^1.8.0", "regenerator-runtime": "^0.11.1", "rimraf": "^2.6.2", @@ -91,6 +91,13 @@ "\\.js$": "babel-jest", "\\.ts$": "ts-jest" }, - "preset": "ts-jest/presets/js-with-babel" + "preset": "ts-jest/presets/js-with-babel", + "globals": { + "ts-jest": { + "tsConfig": { + "noUnusedLocals": false + } + } + } } } diff --git a/src/common.ts b/src/common.ts index 0bc7ed9b..370384c1 100644 --- a/src/common.ts +++ b/src/common.ts @@ -1,19 +1,32 @@ -import {Objectish, ObjectishNoSet, ImmerState} from "./types" +import { + Objectish, + Drafted, + AnyObject, + AnyArray, + AnyMap, + AnySet, + ImmerState, + ProxyType, + Archtype +} from "./types" /** Use a class type for `nothing` so its type is unique */ export class Nothing { // This lets us do `Exclude` - // TODO: do this better, use unique symbol instead - private _: any + // @ts-ignore + private _!: unique symbol } +const hasSymbol = typeof Symbol !== "undefined" +export const hasMap = typeof Map !== "undefined" +export const hasSet = typeof Set !== "undefined" + /** * The sentinel value returned by producers to replace the draft with undefined. */ -export const NOTHING: Nothing = - typeof Symbol !== "undefined" - ? Symbol("immer-nothing") - : ({["immer-nothing"]: true} as any) +export const NOTHING: Nothing = hasSymbol + ? Symbol("immer-nothing") + : ({["immer-nothing"]: true} as any) /** * To let Immer treat your class instances as plain immutable objects @@ -23,15 +36,17 @@ export const NOTHING: Nothing = * Otherwise, your class instance will never be drafted, which means it won't be * safe to mutate in a produce callback. */ -export const DRAFTABLE: unique symbol = - typeof Symbol !== "undefined" && Symbol.for - ? Symbol.for("immer-draftable") - : ("__$immer_draftable" as any) +export const DRAFTABLE: unique symbol = hasSymbol + ? Symbol("immer-draftable") + : ("__$immer_draftable" as any) + +export const DRAFT_STATE: unique symbol = hasSymbol + ? Symbol("immer-state") + : ("__$immer_state" as any) -export const DRAFT_STATE: unique symbol = - typeof Symbol !== "undefined" && Symbol.for - ? Symbol.for("immer-state") - : ("__$immer_state" as any) +export const iteratorSymbol: typeof Symbol.iterator = hasSymbol + ? Symbol.iterator + : ("@@iterator" as any) /** Returns true if the given value is an Immer draft */ export function isDraft(value: any): boolean { @@ -43,6 +58,7 @@ export function isDraftable(value: any): boolean { if (!value) return false return ( isPlainObject(value) || + Array.isArray(value) || !!value[DRAFTABLE] || !!value.constructor[DRAFTABLE] || isMap(value) || @@ -50,123 +66,99 @@ export function isDraftable(value: any): boolean { ) } -export function isPlainObject(value): boolean { +export function isPlainObject(value: any): boolean { if (!value || typeof value !== "object") return false - if (Array.isArray(value)) return true const proto = Object.getPrototypeOf(value) return !proto || proto === Object.prototype } /** Get the underlying object that is represented by the given draft */ -export function original(value: T): T | undefined { +export function original(value: Drafted): T | undefined { if (value && value[DRAFT_STATE]) { - return value[DRAFT_STATE].base + return value[DRAFT_STATE].base as any } // otherwise return undefined } -// We use Maps as `drafts` for Sets, not Objects -// See proxy.js -export function assignSet(target: Map, override) { - override.forEach(value => { - // When we add new drafts we have to remove their originals if present - const prev = original(value) - if (prev) target.delete(prev) - // @ts-ignore TODO investigate - target.add(value) - }) - return target -} - -// We use Maps as `drafts` for Maps, not Objects -// See proxy.js -export function assignMap(target: Map, override: Map) { - override.forEach((value, key) => target.set(key, value)) - return target -} - -export const assign = - Object.assign || - ((target, ...overrides) => { - overrides.forEach(override => { - if (typeof override === "object" && override !== null) - Object.keys(override).forEach(key => (target[key] = override[key])) - }) - return target - }) - -export const ownKeys: (target) => PropertyKey[] = +export const ownKeys: (target: AnyObject) => PropertyKey[] = typeof Reflect !== "undefined" && Reflect.ownKeys ? Reflect.ownKeys : typeof Object.getOwnPropertySymbols !== "undefined" ? obj => - Object.getOwnPropertyNames(obj).concat(Object.getOwnPropertySymbols( - obj - ) as any) - : Object.getOwnPropertyNames - -export function shallowCopy( - base: T, - invokeGetters?: boolean -): T -export function shallowCopy(base, invokeGetters = false) { - if (Array.isArray(base)) return base.slice() - if (isMap(base)) return new Map(base) - if (isSet(base)) return new Set(base) - 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 -} + Object.getOwnPropertyNames(obj).concat( + Object.getOwnPropertySymbols(obj) as any + ) + : /* istanbul ignore next */ Object.getOwnPropertyNames export function each( obj: T, - iter: (key: PropertyKey, value: any, source: T) => void -) -export function each(obj, iter) { - if (Array.isArray(obj) || isMap(obj) || isSet(obj)) { - obj.forEach((entry, index) => iter(index, entry, obj)) - } else { + iter: (key: string | number, value: any, source: T) => void +): void +export function each(obj: any, iter: any) { + if (getArchtype(obj) === Archtype.Object) { ownKeys(obj).forEach(key => iter(key, obj[key], obj)) + } else { + obj.forEach((entry: any, index: any) => iter(index, entry, obj)) } } -export function isEnumerable(base: {}, prop: PropertyKey): boolean { +export function isEnumerable(base: AnyObject, prop: PropertyKey): boolean { const desc = Object.getOwnPropertyDescriptor(base, prop) return desc && desc.enumerable ? true : false } -export function has(thing: ObjectishNoSet, prop: PropertyKey): boolean { - return isMap(thing) +export function getArchtype(thing: any): Archtype { + /* istanbul ignore next */ + if (!thing) die() + if (thing[DRAFT_STATE]) { + switch ((thing as Drafted)[DRAFT_STATE].type) { + case ProxyType.ES5Object: + case ProxyType.ProxyObject: + return Archtype.Object + case ProxyType.ES5Array: + case ProxyType.ProxyArray: + return Archtype.Array + case ProxyType.Map: + return Archtype.Map + case ProxyType.Set: + return Archtype.Set + } + } + return Array.isArray(thing) + ? Archtype.Array + : isMap(thing) + ? Archtype.Map + : isSet(thing) + ? Archtype.Set + : Archtype.Object +} + +export function has(thing: any, prop: PropertyKey): boolean { + return getArchtype(thing) === Archtype.Map ? thing.has(prop) : Object.prototype.hasOwnProperty.call(thing, prop) } -export function get(thing: ObjectishNoSet, prop: PropertyKey) { - return isMap(thing) ? thing.get(prop) : thing[prop] +export function get(thing: AnyMap | AnyObject, prop: PropertyKey): any { + // @ts-ignore + return getArchtype(thing) === Archtype.Map ? thing.get(prop) : thing[prop] +} + +export function set(thing: any, propOrOldValue: PropertyKey, value: any) { + switch (getArchtype(thing)) { + case Archtype.Map: + thing.set(propOrOldValue, value) + break + case Archtype.Set: + thing.delete(propOrOldValue) + thing.add(value) + break + default: + thing[propOrOldValue] = value + } } -export function is(x, y): boolean { +export function is(x: any, y: any): boolean { // From: https://github.com/facebook/fbjs/blob/c69904a511b900266935168223063dd8772dfc40/packages/fbjs/src/core/shallowEqual.js if (x === y) { return x !== 0 || 1 / x === 1 / y @@ -175,103 +167,56 @@ export function is(x, y): boolean { } } -export const hasSymbol = typeof Symbol !== "undefined" - -export const hasMap = typeof Map !== "undefined" - -export function isMap(target): target is Map { +export function isMap(target: any): target is AnyMap { return hasMap && target instanceof Map } -export const hasSet = typeof Set !== "undefined" - -export function isSet(target): target is Set { +export function isSet(target: any): target is AnySet { return hasSet && target instanceof Set } -export function makeIterable(next: () => {done: boolean; value: any}) { - let self - return (self = { - [Symbol.iterator]: () => self, - next - }) -} - -/** Map.prototype.values _-or-_ Map.prototype.entries */ -export function iterateMapValues(state, prop, receiver) { - const isEntries = prop !== "values" - return () => { - const iterator = latest(state)[Symbol.iterator]() - return makeIterable(() => { - const result = iterator.next() - if (!result.done) { - const [key] = result.value - const value = receiver.get(key) - result.value = isEntries ? [key, value] : value - } - return result - }) - } +export function latest(state: ImmerState): any { + return state.copy || state.base } -export function makeIterateSetValues(createProxy) { - function iterateSetValues(state, prop?) { - const isEntries = prop === "entries" - return () => { - const iterator = latest(state)[Symbol.iterator]() - return makeIterable(() => { - const result = iterator.next() - if (!result.done) { - const value = wrapSetValue(state, result.value) - result.value = isEntries ? [value, value] : value - } - return result - }) +export function shallowCopy( + base: T, + invokeGetters?: boolean +): T +export function shallowCopy(base: any, 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. } - } - - function wrapSetValue(state, value) { - const key = original(value) || value - let draft = state.drafts.get(key) - if (!draft) { - if (state.finalized || !isDraftable(value) || state.finalizing) { - return value - } - draft = createProxy(value, state) - state.drafts.set(key, draft) - if (state.modified) { - state.copy.add(draft) + 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) } - return draft - } - - return iterateSetValues -} - -function latest(state: ImmerState): T { - return state.copy || state.base -} - -export function clone(obj: T): T -export function clone(obj) { - if (!isDraftable(obj)) return obj - if (Array.isArray(obj)) return obj.map(clone) - if (isMap(obj)) return new Map(obj) - if (isSet(obj)) return new Set(obj) - const cloned = Object.create(Object.getPrototypeOf(obj)) - for (const key in obj) cloned[key] = clone(obj[key]) - return cloned + if (desc.enumerable) { + clone[key] = value + } else { + Object.defineProperty(clone, key, { + value, + writable: true, + configurable: true + }) + } + }) + return clone } -export function freeze( - obj: T, - deep: boolean = false -): void { +export function freeze(obj: any, deep: boolean): void { if (!isDraftable(obj) || isDraft(obj) || Object.isFrozen(obj)) return - if (isSet(obj)) { + const type = getArchtype(obj) + if (type === Archtype.Set) { obj.add = obj.clear = obj.delete = dontMutateFrozenCollections as any - } else if (isMap(obj)) { + } else if (type === Archtype.Map) { obj.set = obj.clear = obj.delete = dontMutateFrozenCollections as any } Object.freeze(obj) @@ -281,3 +226,20 @@ export function freeze( function dontMutateFrozenCollections() { throw new Error("This object has been frozen and should not be mutated") } + +export function createHiddenProperty( + target: AnyObject, + prop: PropertyKey, + value: any +) { + Object.defineProperty(target, prop, { + value: value, + enumerable: false, + writable: true + }) +} + +/* istanbul ignore next */ +export function die(): never { + throw new Error("Illegal state, please file a bug") +} diff --git a/src/es5.ts b/src/es5.ts index 3f9f9152..f7ac428b 100644 --- a/src/es5.ts +++ b/src/es5.ts @@ -6,39 +6,49 @@ import { isDraft, isDraftable, isEnumerable, - isMap, - isSet, - hasSymbol, shallowCopy, DRAFT_STATE, - iterateMapValues, - makeIterable, - makeIterateSetValues + latest, + createHiddenProperty } from "./common" + import {ImmerScope} from "./scope" -import {ImmerState} from "./types" +import { + ImmerState, + Drafted, + AnyObject, + Objectish, + ImmerBaseState, + AnyArray, + ProxyType +} from "./types" +import {MapState} from "./map" +import {SetState} from "./set" + +interface ES5BaseState extends ImmerBaseState { + finalizing: boolean + assigned: {[key: string]: any} + parent?: ImmerState + revoked: boolean +} -interface ES5Draft { - [DRAFT_STATE]: ES5State +export interface ES5ObjectState extends ES5BaseState { + type: ProxyType.ES5Object + draft: Drafted + base: AnyObject + copy: AnyObject | null } -// TODO: merge with ImmerState? -interface ES5State { - scope: ImmerScope - modified: boolean - finalizing: boolean - finalized: boolean - assigned: Map | {[key: string]: any} - parent: ES5State - base: T - draft: T & ES5Draft - drafts: Map | null - copy: T | null - revoke() - revoked: boolean +export interface ES5ArrayState extends ES5BaseState { + type: ProxyType.ES5Array + draft: Drafted + base: AnyArray + copy: AnyArray | null } -export function willFinalize( +type ES5State = ES5ArrayState | ES5ObjectState + +export function willFinalizeES5( scope: ImmerScope, result: any, isReplaced: boolean @@ -59,52 +69,38 @@ export function willFinalize( } } -export function createProxy(base: T, parent: ES5State): ES5Draft { +export function createES5Proxy( + base: T, + parent?: ImmerState +): Drafted { const isArray = Array.isArray(base) const draft = clonePotentialDraft(base) - if (isMap(base)) { - proxyMap(draft) - } else if (isSet(base)) { - proxySet(draft) - } else { - each(draft, prop => { - proxyProperty(draft, prop, isArray || isEnumerable(base, prop)) - }) - } + 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: ES5State = { - scope, + const state: ES5ObjectState | ES5ArrayState = { + type: isArray ? ProxyType.ES5Array : (ProxyType.ES5Object as any), + scope: parent ? parent.scope : ImmerScope.current!, modified: false, - finalizing: false, // es5 only + finalizing: false, finalized: false, - assigned: isMap(base) ? new Map() : {}, + assigned: {}, parent, base, draft, - drafts: isSet(base) ? new Map() : null, copy: null, - revoke, - revoked: false // es5 only + revoked: false, + isManual: false } createHiddenProperty(draft, DRAFT_STATE, state) - scope.drafts!.push(draft) return draft } -function revoke(this: ES5State) { - this.revoked = true -} - -function latest(state) { - return state.copy || state.base -} - // Access a property without creating an Immer draft. -function peek(draft, prop) { +function peek(draft: Drafted, prop: PropertyKey) { const state = draft[DRAFT_STATE] if (state && !state.finalizing) { state.finalizing = true @@ -115,42 +111,44 @@ function peek(draft, prop) { return draft[prop] } -function get(state, prop) { +function get(state: ES5State, prop: string | number) { assertUnrevoked(state) const value = peek(latest(state), prop) if (state.finalizing) return value // Create a draft if the value is unmodified. if (value === peek(state.base, prop) && isDraftable(value)) { prepareCopy(state) - return (state.copy[prop] = createProxy(value, state)) + // @ts-ignore + return (state.copy![prop] = state.scope.immer.createProxy(value, state)) } return value } -function set(state, prop, value) { +function set(state: ES5State, prop: string | number, value: any) { assertUnrevoked(state) state.assigned[prop] = true if (!state.modified) { if (is(value, peek(latest(state), prop))) return - markChanged(state) + markChangedES5(state) prepareCopy(state) } - state.copy[prop] = value + // @ts-ignore + state.copy![prop] = value } -function markChanged(state) { +export function markChangedES5(state: ImmerState) { if (!state.modified) { state.modified = true - if (state.parent) markChanged(state.parent) + if (state.parent) markChangedES5(state.parent) } } -function prepareCopy(state) { +function prepareCopy(state: ES5State) { if (!state.copy) state.copy = clonePotentialDraft(state.base) } -function clonePotentialDraft(base) { - const state = base && base[DRAFT_STATE] +function clonePotentialDraft(base: Objectish) { + const state = base && (base as any)[DRAFT_STATE] if (state) { state.finalizing = true const draft = shallowCopy(state.draft, true) @@ -162,9 +160,13 @@ function clonePotentialDraft(base) { // property descriptors are recycled to make sure we don't create a get and set closure per property, // but share them all instead -const descriptors = {} +const descriptors: {[prop: string]: PropertyDescriptor} = {} -function proxyProperty(draft, prop, enumerable) { +function proxyProperty( + draft: Drafted, + prop: string | number, + enumerable: boolean +) { let desc = descriptors[prop] if (desc) { desc.enumerable = enumerable @@ -172,10 +174,10 @@ function proxyProperty(draft, prop, enumerable) { descriptors[prop] = desc = { configurable: true, enumerable, - get() { + get(this: any) { return get(this[DRAFT_STATE], prop) }, - set(value) { + set(this: any, value) { set(this[DRAFT_STATE], prop, value) } } @@ -183,164 +185,7 @@ function proxyProperty(draft, prop, enumerable) { Object.defineProperty(draft, prop, desc) } -function proxyMap(target) { - Object.defineProperties(target, mapTraps) - - if (hasSymbol) { - Object.defineProperty( - target, - Symbol.iterator, - // @ts-ignore - proxyMethod(iterateMapValues) //TODO: , Symbol.iterator) - ) - } -} - -const mapTraps = finalizeTraps({ - size: state => latest(state).size, - has: state => key => latest(state).has(key), - set: state => (key, value) => { - if (latest(state).get(key) !== value) { - prepareCopy(state) - markChanged(state) - state.assigned.set(key, true) - state.copy.set(key, value) - } - return state.draft - }, - delete: state => key => { - prepareCopy(state) - markChanged(state) - state.assigned.set(key, false) - state.copy.delete(key) - return false - }, - clear: state => () => { - if (!state.copy) { - prepareCopy(state) - } - markChanged(state) - state.assigned = new Map() - for (const key of latest(state).keys()) { - state.assigned.set(key, false) - } - return state.copy.clear() - }, - forEach: (state, key, reciever) => cb => { - latest(state).forEach((value, key, map) => { - cb(reciever.get(key), key, map) - }) - }, - get: state => key => { - const value = latest(state).get(key) - - if (state.finalizing || state.finalized || !isDraftable(value)) { - return value - } - - if (value !== state.base.get(key)) { - return value - } - const draft = createProxy(value, state) - prepareCopy(state) - state.copy.set(key, draft) - return draft - }, - keys: state => () => latest(state).keys(), - values: iterateMapValues, - entries: iterateMapValues -}) - -function proxySet(target) { - Object.defineProperties(target, setTraps) - - if (hasSymbol) { - Object.defineProperty( - target, - Symbol.iterator, - // @ts-ignore - proxyMethod(iterateSetValues) //TODO: , Symbol.iterator) - ) - } -} - -const iterateSetValues = makeIterateSetValues(createProxy) - -const setTraps = finalizeTraps({ - size: state => { - return latest(state).size - }, - add: state => value => { - if (!latest(state).has(value)) { - markChanged(state) - if (!state.copy) { - prepareCopy(state) - } - state.copy.add(value) - } - return state.draft - }, - delete: state => value => { - markChanged(state) - if (!state.copy) { - prepareCopy(state) - } - return state.copy.delete(value) - }, - has: state => key => { - return latest(state).has(key) - }, - clear: state => () => { - markChanged(state) - if (!state.copy) { - prepareCopy(state) - } - return state.copy.clear() - }, - keys: iterateSetValues, - entries: iterateSetValues, - values: iterateSetValues, - forEach: state => (cb, thisArg) => { - const iterator = iterateSetValues(state)() - let result = iterator.next() - while (!result.done) { - cb.call(thisArg, result.value, result.value, state.draft) - result = iterator.next() - } - } -}) - -function finalizeTraps(traps) { - return Object.keys(traps).reduce(function(acc, key) { - const builder = key === "size" ? proxyAttr : proxyMethod - acc[key] = builder(traps[key], key) - return acc - }, {}) -} - -function proxyAttr(fn) { - return { - get() { - const state = this[DRAFT_STATE] - assertUnrevoked(state) - return fn(state) - } - } -} - -function proxyMethod(trap, key) { - return { - get() { - return function(this: ES5Draft, ...args) { - const state = this[DRAFT_STATE] - assertUnrevoked(state) - return trap(state, key, state.draft)(...args) - } - } - } -} - -function assertUnrevoked(state) { +export function assertUnrevoked(state: ES5State | MapState | SetState) { 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? " + @@ -349,7 +194,7 @@ function assertUnrevoked(state) { } // This looks expensive, but only proxies are visited, and only objects without known changes are scanned. -function markChangesSweep(drafts) { +function markChangesSweep(drafts: Drafted[]) { // 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 @@ -357,46 +202,50 @@ function markChangesSweep(drafts) { 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 (isMap(state.base)) { - if (hasMapChanges(state)) markChanged(state) - } else if (isSet(state.base)) { - if (hasSetChanges(state)) markChanged(state) - } else if (hasObjectChanges(state)) { - markChanged(state) + switch (state.type) { + case ProxyType.ES5Array: + if (hasArrayChanges(state)) markChangedES5(state) + break + case ProxyType.ES5Object: + if (hasObjectChanges(state)) markChangedES5(state) + break } } } } -function markChangesRecursively(object) { +function markChangesRecursively(object: any) { if (!object || typeof object !== "object") return const state = object[DRAFT_STATE] if (!state) return - const {base, draft, assigned} = state - if (!Array.isArray(object)) { + const {base, draft, assigned, type} = state + if (type === ProxyType.ES5Object) { // Look for added keys. - Object.keys(draft).forEach(key => { + // TODO: looks quite duplicate to hasObjectChanges, + // probably there is a faster way to detect changes, as sweep + recurse seems to do some + // unnecessary work. + // also: probably we can store the information we detect here, to speed up tree finalization! + each(draft, key => { + if ((key as any) === DRAFT_STATE) return // The `undefined` check is a fast path for pre-existing keys. if (base[key] === undefined && !has(base, key)) { assigned[key] = true - markChanged(state) + markChangedES5(state) } else if (!assigned[key]) { // Only untouched properties trigger recursion. markChangesRecursively(draft[key]) } }) // Look for removed keys. - Object.keys(base).forEach(key => { + each(base, key => { // The `undefined` check is a fast path for pre-existing keys. if (draft[key] === undefined && !has(draft, key)) { assigned[key] = false - markChanged(state) + markChangedES5(state) } }) - } else if (hasArrayChanges(state)) { - markChanged(state) + } else if (type === ProxyType.ES5Array && hasArrayChanges(state)) { + markChangedES5(state) assigned.length = true if (draft.length < base.length) { for (let i = draft.length; i < base.length; i++) assigned[i] = false @@ -410,7 +259,7 @@ function markChangesRecursively(object) { } } -function hasObjectChanges(state) { +function hasObjectChanges(state: ES5ObjectState) { const {base, draft} = state // Search for added keys and changed keys. Start at the back, because @@ -439,7 +288,7 @@ function hasObjectChanges(state) { return keys.length !== Object.keys(base).length } -function hasArrayChanges(state) { +function hasArrayChanges(state: ES5ArrayState) { const {draft} = state if (draft.length !== state.base.length) return true // See #116 @@ -455,41 +304,3 @@ function hasArrayChanges(state) { // For all other cases, we don't have to compare, as they would have been picked up by the index setters return false } - -function hasMapChanges(state) { - const {base, draft} = state - - if (base.size !== draft.size) return true - - // IE11 supports only forEach iteration - let hasChanges = false - draft.forEach(function(value, key) { - if (!hasChanges) { - hasChanges = isDraftable(value) ? value.modified : value !== base.get(key) - } - }) - return hasChanges -} - -function hasSetChanges(state) { - const {base, draft} = state - - if (base.size !== draft.size) return true - - // IE11 supports only forEach iteration - let hasChanges = false - draft.forEach(function(value, key) { - if (!hasChanges) { - hasChanges = isDraftable(value) ? value.modified : !base.has(key) - } - }) - return hasChanges -} - -function createHiddenProperty(target, prop, value) { - Object.defineProperty(target, prop, { - value: value, - enumerable: false, - writable: true - }) -} diff --git a/src/finalize.ts b/src/finalize.ts new file mode 100644 index 00000000..4ecaf10e --- /dev/null +++ b/src/finalize.ts @@ -0,0 +1,203 @@ +import {Immer} from "./immer" +import {ImmerState, Drafted, ProxyType} from "./types" +import {ImmerScope} from "./scope" +import { + isSet, + has, + is, + get, + each, + DRAFT_STATE, + NOTHING, + freeze, + shallowCopy, + set +} from "./common" +import {isDraft, isDraftable} from "./index" +import {SetState} from "./set" +import {generatePatches, PatchPath} from "./patches" + +export function processResult(immer: Immer, result: any, scope: ImmerScope) { + const baseDraft = scope.drafts![0] + const isReplaced = result !== undefined && result !== baseDraft + immer.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 = finalize(immer, result, scope) + maybeFreeze(immer, result) + } + 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 = finalize(immer, baseDraft, scope, []) + } + scope.revoke() + if (scope.patches) { + scope.patchListener!(scope.patches, scope.inversePatches!) + } + return result !== NOTHING ? result : undefined +} + +function finalize( + immer: Immer, + draft: Drafted, + scope: ImmerScope, + path?: PatchPath +) { + const state = draft[DRAFT_STATE] + if (!state) { + if (Object.isFrozen(draft)) return draft + return finalizeTree(immer, draft, scope) + } + // Never finalize drafts owned by another scope. + if (state.scope !== scope) { + return draft + } + if (!state.modified) { + maybeFreeze(immer, state.base, true) + return state.base + } + if (!state.finalized) { + state.finalized = true + finalizeTree(immer, state.draft, scope, path) + + // We cannot really delete anything inside of a Set. We can only replace the whole Set. + if (immer.onDelete && state.type !== ProxyType.Set) { + // The `assigned` object is unreliable with ES5 drafts. + if (immer.useProxies) { + const {assigned} = state + each(assigned, (prop, exists) => { + if (!exists) immer.onDelete!(state, prop as any) + }) + } else { + const {base, copy} = state + each(base, prop => { + if (!has(copy, prop)) immer.onDelete!(state, prop as any) + }) + } + } + if (immer.onCopy) { + immer.onCopy(state) + } + + // At this point, all descendants of `state.copy` have been finalized, + // so we can be sure that `scope.canAutoFreeze` is accurate. + if (immer.autoFreeze && scope.canAutoFreeze) { + freeze(state.copy, false) + } + + if (path && scope.patches) { + generatePatches(state, path, scope.patches, scope.inversePatches!) + } + } + return state.copy +} + +function finalizeTree( + immer: Immer, + root: Drafted, + scope: ImmerScope, + rootPath?: PatchPath +) { + const state = root[DRAFT_STATE] + if (state) { + if ( + state.type === ProxyType.ES5Object || + state.type === ProxyType.ES5Array + ) { + // Create the final copy, with added keys and without deleted keys. + state.copy = shallowCopy(state.draft, true) + } + root = state.copy + } + each(root, (key, value) => + finalizeProperty(immer, scope, root, state, root, key, value, rootPath) + ) + return root +} + +function finalizeProperty( + immer: Immer, + scope: ImmerScope, + root: Drafted, + rootState: ImmerState, + parentValue: Drafted, + prop: string | number, + childValue: any, + rootPath?: PatchPath +) { + if (childValue === parentValue) { + throw Error("Immer forbids circular references") + } + + // In the `finalizeTree` method, only the `root` object may be a draft. + const isDraftProp = !!rootState && parentValue === root + const isSetMember = isSet(parentValue) + + if (isDraft(childValue)) { + const path = + rootPath && + isDraftProp && + !isSetMember && // Set objects are atomic since they have no keys. + !has((rootState as Exclude).assigned!, prop) // Skip deep patches for assigned keys. + ? rootPath!.concat(prop) + : undefined + + // Drafts owned by `scope` are finalized here. + childValue = finalize(immer, childValue, scope, path) + set(parentValue, prop, childValue) + + // Drafts from another scope must prevent auto-freezing. + if (isDraft(childValue)) { + scope.canAutoFreeze = false + } + } + // Unchanged draft properties are ignored. + else if (isDraftProp && is(childValue, get(rootState.base, prop))) { + return + } + // Search new objects for unfinalized drafts. Frozen objects should never contain drafts. + // TODO: the recursion over here looks weird, shouldn't non-draft stuff have it's own recursion? + // especially the passing on of root and rootState doesn't make sense... + else if (isDraftable(childValue) && !Object.isFrozen(childValue)) { + each(childValue, (key, grandChild) => + finalizeProperty( + immer, + scope, + root, + rootState, + childValue, + key, + grandChild, + rootPath + ) + ) + maybeFreeze(immer, childValue) + } + + if (isDraftProp && immer.onAssign && !isSetMember) { + immer.onAssign(rootState, prop, childValue) + } +} + +export function maybeFreeze(immer: Immer, value: any, deep = false) { + if (immer.autoFreeze && !isDraft(value)) { + freeze(value, deep) + } +} diff --git a/src/immer.ts b/src/immer.ts index a6d94cf0..d777d67a 100644 --- a/src/immer.ts +++ b/src/immer.ts @@ -1,25 +1,33 @@ -import * as legacyProxy from "./es5" -import * as modernProxy from "./proxy" -import {applyPatches, generatePatches} from "./patches" +import {createES5Proxy, willFinalizeES5, markChangedES5} from "./es5" +import {createProxy, markChanged} from "./proxy" + +import {applyPatches} from "./patches" import { - assign, each, - has, - is, isDraft, isSet, - get, isMap, isDraftable, - isEnumerable, - shallowCopy, DRAFT_STATE, NOTHING, - freeze + die } from "./common" import {ImmerScope} from "./scope" -import {ImmerState, IProduce, IProduceWithPatches, Objectish, PatchListener, Draft, Patch} from "./types" - +import { + ImmerState, + IProduce, + IProduceWithPatches, + Objectish, + PatchListener, + Draft, + Patch, + Drafted +} from "./types" +import {proxyMap} from "./map" +import {proxySet} from "./set" +import {processResult, maybeFreeze} from "./finalize" + +/* istanbul ignore next */ function verifyMinified() {} const configDefaults = { @@ -30,11 +38,12 @@ const configDefaults = { autoFreeze: typeof process !== "undefined" ? process.env.NODE_ENV !== "production" - : verifyMinified.name === "verifyMinified", + : /* istanbul ignore next */ + verifyMinified.name === "verifyMinified", onAssign: null, onDelete: null, onCopy: null -} +} as const interface ProducersFns { produce: IProduce @@ -44,15 +53,9 @@ interface ProducersFns { export class Immer implements ProducersFns { useProxies: boolean = false autoFreeze: boolean = false - onAssign?: ( - state: ImmerState, - prop: string | number, - value: unknown - ) => void + onAssign?: (state: ImmerState, prop: string | number, value: unknown) => void onDelete?: (state: ImmerState, prop: string | number) => void onCopy?: (state: ImmerState) => void - createProxy!:(value: T) => T; - willFinalize!: (scope: ImmerScope, thing: any, isReplaced: boolean) => void; constructor(config?: { useProxies?: boolean @@ -65,7 +68,10 @@ export class Immer implements ProducersFns { onDelete?: (state: ImmerState, prop: string | number) => void onCopy?: (state: ImmerState) => void }) { - assign(this, configDefaults, config) + each(configDefaults, (key, value) => { + // @ts-ignore + this[key] = config?.[key] ?? value + }) this.setUseProxies(this.useProxies) this.produce = this.produce.bind(this) this.produceWithPatches = this.produceWithPatches.bind(this) @@ -90,15 +96,19 @@ export class Immer implements ProducersFns { * @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(base, recipe?, patchListener?) { + produce(base: any, recipe?: any, patchListener?: any) { // curried invocation if (typeof base === "function" && typeof recipe !== "function") { const defaultBase = recipe recipe = base const self = this - return function curriedProduce(this: any, base = defaultBase, ...args) { - return self.produce(base, draft => recipe.call(this, draft, ...args)) // prettier-ignore + return function curriedProduce( + this: any, + base = defaultBase, + ...args: any[] + ) { + return self.produce(base, (draft: Drafted) => recipe.call(this, draft, ...args)) // prettier-ignore } } @@ -116,8 +126,8 @@ export class Immer implements ProducersFns { // Only plain objects, arrays, and "immerable classes" are drafted. if (isDraftable(base)) { - const scope = ImmerScope.enter() - const proxy = this.createProxy(base) + const scope = ImmerScope.enter(this) + const proxy = this.createProxy(base, undefined) let hasError = true try { result = recipe(proxy) @@ -131,7 +141,7 @@ export class Immer implements ProducersFns { return result.then( result => { scope.usePatches(patchListener) - return this.processResult(result, scope) + return processResult(this, result, scope) }, error => { scope.revoke() @@ -140,46 +150,48 @@ export class Immer implements ProducersFns { ) } scope.usePatches(patchListener) - return this.processResult(result, scope) + return processResult(this, result, scope) } else { result = recipe(base) if (result === NOTHING) return undefined if (result === undefined) result = base - this.maybeFreeze(result, true) + maybeFreeze(this, result, true) return result } } - produceWithPatches(arg1, arg2?, arg3?): any { + produceWithPatches(arg1: any, arg2?: any, arg3?: any): any { if (typeof arg1 === "function") { - const self = this - return (state, ...args) => - this.produceWithPatches(state, draft => arg1(draft, ...args)) + return (state: any, ...args: any[]) => + this.produceWithPatches(state, (draft: any) => arg1(draft, ...args)) } // non-curried form - if (arg3) - throw new Error("A patch listener cannot be passed to produceWithPatches") - let patches, inversePatches - const nextState = this.produce(arg1, arg2, (p, ip) => { + /* istanbul ignore next */ + if (arg3) die() + let patches: Patch[], inversePatches: Patch[] + const nextState = this.produce(arg1, arg2, (p: Patch[], ip: Patch[]) => { patches = p inversePatches = ip }) - return [nextState, patches, inversePatches] + return [nextState, patches!, inversePatches!] } - createDraft(base: T): Draft { + createDraft(base: T): Drafted { 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) + const scope = ImmerScope.enter(this) + const proxy = this.createProxy(base, undefined) proxy[DRAFT_STATE].isManual = true scope.leave() - return proxy as any + return proxy } - finishDraft>(draft: D, patchListener: PatchListener): D extends Draft ? T : never { - const state = draft && draft[DRAFT_STATE] + finishDraft>( + draft: D, + patchListener?: PatchListener + ): D extends Draft ? T : never { + const state: ImmerState = draft && draft[DRAFT_STATE] if (!state || !state.isManual) { throw new Error("First argument to `finishDraft` must be a draft returned by `createDraft`") // prettier-ignore } @@ -188,7 +200,7 @@ export class Immer implements ProducersFns { } const {scope} = state scope.usePatches(patchListener) - return this.processResult(undefined, scope) + return processResult(this, undefined, scope) } /** @@ -208,13 +220,12 @@ export class Immer implements ProducersFns { */ setUseProxies(value: boolean) { this.useProxies = value - assign(this, value ? modernProxy : legacyProxy) } applyPatches(base: Objectish, patches: Patch[]) { // If a patch replaces the entire state, take that replacement as base // before applying patches - let i + let i: number for (i = patches.length - 1; i >= 0; i--) { const patch = patches[i] if (patch.path.length === 0 && patch.op === "replace") { @@ -228,190 +239,38 @@ export class Immer implements ProducersFns { return applyPatches(base, patches) } // Otherwise, produce a copy of the base state. - return this.produce(base, draft => + return this.produce(base, (draft: Drafted) => applyPatches(draft, patches.slice(i + 1)) ) } - /** @internal */ - processResult(result: any, scope: ImmerScope) { - 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) - this.maybeFreeze(result) - } - 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 + createProxy( + value: T, + parent?: ImmerState + ): Drafted { + // precondition: createProxy should be guarded by isDraftable, so we know we can safely draft + const draft: Drafted = isMap(value) + ? proxyMap(value, parent) + : isSet(value) + ? proxySet(value, parent) + : this.useProxies + ? createProxy(value, parent) + : createES5Proxy(value, parent) + + const scope = parent ? parent.scope : ImmerScope.current! + scope.drafts.push(draft) + return draft } - /** - * @internal - * Finalize a draft, returning either the unmodified base state or a modified - * copy of the base state. - */ - finalize(draft: any, path: string[] | null, scope: ImmerScope) { - 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) { - this.maybeFreeze(state.base, true) - return state.base - } - if (!state.finalized) { - state.finalized = true - this.finalizeTree(state.draft, path, scope) - - // We cannot really delete anything inside of a Set. We can only replace the whole Set. - if (this.onDelete && !isSet(state.base)) { - // The `assigned` object is unreliable with ES5 drafts. - if (this.useProxies) { - const {assigned} = state - each(assigned, (prop, exists) => { - if (!exists) this.onDelete?.(state, prop as any) - }) - } else { - // TODO: Figure it out for Maps and Sets if we need to support ES5 - const {base, copy} = state - each(base, prop => { - if (!has(copy, prop)) this.onDelete?.(state, prop as any) - }) - } - } - 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) { - freeze(state.copy, false) - } - - if (path && scope.patches) { - generatePatches(state, path, scope.patches, scope.inversePatches!) - } - } - return state.copy + willFinalize(scope: ImmerScope, thing: any, isReplaced: boolean) { + if (!this.useProxies) willFinalizeES5(scope, thing, isReplaced) } - /** - * @internal - * Finalize all drafts in the given state tree. - */ - finalizeTree(root: any, rootPath: string[] | null, scope: ImmerScope) { - 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") - } - - // In the `finalizeTree` method, only the `root` object may be a draft. - const isDraftProp = !!state && parent === root - const isSetMember = isSet(parent) - - if (isDraft(value)) { - const path = - isDraftProp && - needPatches && - !isSetMember && // Set objects are atomic since they have no keys. - !has(state.assigned, prop) // Skip deep patches for assigned keys. - ? rootPath!.concat(prop) - : null - - // Drafts owned by `scope` are finalized here. - value = this.finalize(value, path, scope) - replace(parent, prop, value) - - // Drafts from another scope must prevent auto-freezing. - if (isDraft(value)) { - scope.canAutoFreeze = false - } - - // Unchanged drafts are never passed to the `onAssign` hook. - if (isDraftProp && value === get(state.base, prop)) return - } - // Unchanged draft properties are ignored. - else if (isDraftProp && is(value, get(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) - this.maybeFreeze(value) - } - - if (isDraftProp && this.onAssign && !isSetMember) { - this.onAssign(state, prop, value) - } - } - - each(root, finalizeProperty) - return root - } - maybeFreeze(value, deep = false) { - if (this.autoFreeze && !isDraft(value)) { - freeze(value, deep) + markChanged(state: ImmerState) { + if (this.useProxies) { + markChanged(state) + } else { + markChangedES5(state) } } } - -function replace(parent, prop, value) { - if (isMap(parent)) { - parent.set(prop, value) - } else if (isSet(parent)) { - // In this case, the `prop` is actually a draft. - parent.delete(prop) - parent.add(value) - } else if (Array.isArray(parent) || isEnumerable(parent, prop)) { - // Preserve non-enumerable properties. - parent[prop] = value - } else { - Object.defineProperty(parent, prop, { - value, - writable: true, - configurable: true - }) - } -} diff --git a/src/map.ts b/src/map.ts new file mode 100644 index 00000000..e46a8f7b --- /dev/null +++ b/src/map.ts @@ -0,0 +1,163 @@ +import {isDraftable, DRAFT_STATE, latest, iteratorSymbol} from "./common" + +import {ImmerScope} from "./scope" +import {AnyMap, Drafted, ImmerState, ImmerBaseState, ProxyType} from "./types" +import {assertUnrevoked} from "./es5" + +export interface MapState extends ImmerBaseState { + type: ProxyType.Map + copy: AnyMap | undefined + assigned: Map | undefined + base: AnyMap + revoked: boolean + draft: Drafted +} + +// Make sure DraftMap declarion doesn't die if Map is not avialable... +/* istanbul ignore next */ +const MapBase: MapConstructor = + typeof Map !== "undefined" ? Map : (function FakeMap() {} as any) + +export class DraftMap extends MapBase implements Map { + [DRAFT_STATE]: MapState + constructor(target: AnyMap, parent?: ImmerState) { + super() + this[DRAFT_STATE] = { + type: ProxyType.Map, + parent, + scope: parent ? parent.scope : ImmerScope.current!, + modified: false, + finalized: false, + copy: undefined, + assigned: undefined, + base: target, + draft: this, + isManual: false, + revoked: false + } + } + + get size(): number { + return latest(this[DRAFT_STATE]).size + } + + has(key: K): boolean { + return latest(this[DRAFT_STATE]).has(key) + } + + set(key: K, value: V): this { + const state = this[DRAFT_STATE] + assertUnrevoked(state) + if (latest(state).get(key) !== value) { + prepareCopy(state) + state.scope.immer.markChanged(state) + state.assigned!.set(key, true) + state.copy!.set(key, value) + state.assigned!.set(key, true) + } + return this + } + + delete(key: K): boolean { + if (!this.has(key)) { + return false + } + + const state = this[DRAFT_STATE] + assertUnrevoked(state) + prepareCopy(state) + state.scope.immer.markChanged(state) + state.assigned!.set(key, false) + state.copy!.delete(key) + return true + } + + clear() { + const state = this[DRAFT_STATE] + assertUnrevoked(state) + prepareCopy(state) + state.scope.immer.markChanged(state) + state.assigned = new Map() + for (const key of latest(state).keys()) { + state.assigned.set(key, false) + } + return state.copy!.clear() + } + + forEach(cb: (value: V, key: K, self: this) => void, thisArg?: any) { + const state = this[DRAFT_STATE] + latest(state).forEach((_value: V, key: K, _map: this) => { + cb.call(thisArg, this.get(key), key, this) + }) + } + + get(key: K): V { + const state = this[DRAFT_STATE] + assertUnrevoked(state) + const value = latest(state).get(key) + if (state.finalized || !isDraftable(value)) { + return value + } + if (value !== state.base.get(key)) { + return value // either already drafted or reassigned + } + // despite what it looks, this creates a draft only once, see above condition + const draft = state.scope.immer.createProxy(value, state) + prepareCopy(state) + state.copy!.set(key, draft) + return draft + } + + keys(): IterableIterator { + return latest(this[DRAFT_STATE]).keys() + } + + values(): IterableIterator { + const iterator = this.keys() + return { + [iteratorSymbol]: () => this.values(), + next: () => { + const r = iterator.next() + /* istanbul ignore next */ + if (r.done) return r + const value = this.get(r.value) + return { + done: false, + value + } + } + } as any + } + + entries(): IterableIterator<[K, V]> { + const iterator = this.keys() + return { + [iteratorSymbol]: () => this.entries(), + next: () => { + const r = iterator.next() + /* istanbul ignore next */ + if (r.done) return r + const value = this.get(r.value) + return { + done: false, + value: [r.value, value] + } + } + } as any + } + + [iteratorSymbol]() { + return this.entries() + } +} + +export function proxyMap(target: AnyMap, parent?: ImmerState) { + return new DraftMap(target, parent) +} + +function prepareCopy(state: MapState) { + if (!state.copy) { + state.assigned = new Map() + state.copy = new Map(state.base) + } +} diff --git a/src/patches.ts b/src/patches.ts index 3a347d56..28892c64 100644 --- a/src/patches.ts +++ b/src/patches.ts @@ -1,31 +1,54 @@ -import {get, each, isMap, isSet, has, clone} from "./common" -import {Patch, ImmerState} from "./types" +import {get, each, isMap, has, die, getArchtype} from "./common" +import {Patch, ImmerState, ProxyType, Archtype} from "./types" +import {SetState} from "./set" +import {ES5ArrayState, ES5ObjectState} from "./es5" +import {ProxyArrayState, ProxyObjectState} from "./proxy" +import {MapState} from "./map" + +export type PatchPath = (string | number)[] export function generatePatches( state: ImmerState, - basePath: (string | number)[], + basePath: PatchPath, patches: Patch[], inversePatches: Patch[] -) { - const generatePatchesFn = Array.isArray(state.base) - ? generateArrayPatches - : isSet(state.base) - ? generateSetPatches - : generatePatchesFromAssigned - - generatePatchesFn(state, basePath, patches, inversePatches) +): void { + switch (state.type) { + case ProxyType.ProxyObject: + case ProxyType.ES5Object: + case ProxyType.Map: + return generatePatchesFromAssigned( + state, + basePath, + patches, + inversePatches + ) + case ProxyType.ES5Array: + case ProxyType.ProxyArray: + return generateArrayPatches(state, basePath, patches, inversePatches) + case ProxyType.Set: + return generateSetPatches( + (state as any) as SetState, + basePath, + patches, + inversePatches + ) + } } function generateArrayPatches( - state: ImmerState, - basePath: (string | number)[], + state: ES5ArrayState | ProxyArrayState, + basePath: PatchPath, patches: Patch[], inversePatches: Patch[] ) { - let {base, copy, assigned} = state + let {base, assigned, copy} = state + /* istanbul ignore next */ + if (!copy) die() // Reduce complexity by ensuring `base` is never longer. if (copy.length < base.length) { + // @ts-ignore ;[base, copy] = [copy, base] ;[patches, inversePatches] = [inversePatches, patches] } @@ -80,15 +103,15 @@ function generateArrayPatches( // This is used for both Map objects and normal objects. function generatePatchesFromAssigned( - state: ImmerState, - basePath: (number | string)[], + state: MapState | ES5ObjectState | ProxyObjectState, + basePath: PatchPath, patches: Patch[], inversePatches: Patch[] ) { const {base, copy} = state - each(state.assigned, (key, assignedValue) => { + each(state.assigned!, (key, assignedValue) => { const origValue = get(base, key) - const value = get(copy, key) + const value = get(copy!, key) const op = !assignedValue ? "remove" : has(base, key) ? "replace" : "add" if (origValue === value && op === "replace") return const path = basePath.concat(key as any) @@ -104,8 +127,8 @@ function generatePatchesFromAssigned( } function generateSetPatches( - state: ImmerState, - basePath: (number | string)[], + state: SetState, + basePath: PatchPath, patches: Patch[], inversePatches: Patch[] ) { @@ -113,7 +136,7 @@ function generateSetPatches( let i = 0 base.forEach(value => { - if (!copy.has(value)) { + if (!copy!.has(value)) { const path = basePath.concat([i]) patches.push({ op: "remove", @@ -129,7 +152,7 @@ function generateSetPatches( i++ }) i = 0 - copy.forEach(value => { + copy!.forEach(value => { if (!base.has(value)) { const path = basePath.concat([i]) patches.push({ @@ -151,53 +174,56 @@ export function applyPatches(draft: T, patches: Patch[]): T { patches.forEach(patch => { const {path, op} = patch - if (!path.length) throw new Error("Illegal state") + /* istanbul ignore next */ + if (!path.length) die() - let base = draft + let base: any = draft for (let i = 0; i < path.length - 1; i++) { base = get(base, path[i]) if (!base || typeof base !== "object") throw new Error("Cannot apply patch, path doesn't resolve: " + path.join("/")) // prettier-ignore } - const value = clone(patch.value) // used to clone patch to ensure original patch is not modified, see #411 - + const type = getArchtype(base) + const value = deepClonePatchValue(patch.value) // used to clone patch to ensure original patch is not modified, see #411 const key = path[path.length - 1] switch (op) { case "replace": - if (isMap(base)) { - base.set(key, value) - } else if (isSet(base)) { - throw new Error('Sets cannot have "replace" patches.') - } else { - // if value is an object, then it's assigned by reference - // in the following add or remove ops, the value field inside the patch will also be modifyed - // so we use value from the cloned patch - base[key] = value + switch (type) { + case Archtype.Map: + return base.set(key, value) + /* istanbul ignore next */ + case Archtype.Set: + throw new Error('Sets cannot have "replace" patches.') + default: + // if value is an object, then it's assigned by reference + // in the following add or remove ops, the value field inside the patch will also be modifyed + // so we use value from the cloned patch + // @ts-ignore + return (base[key] = value) } - break case "add": - if (isSet(base)) { - base.delete(patch.value) + switch (type) { + case Archtype.Array: + return base.splice(key as any, 0, value) + case Archtype.Map: + return base.set(key, value) + case Archtype.Set: + return base.add(value) + default: + return (base[key] = value) } - - Array.isArray(base) - ? base.splice(key as any, 0, value) - : isMap(base) - ? base.set(key, value) - : isSet(base) - ? base.add(value) - : (base[key] = value) - break case "remove": - Array.isArray(base) - ? base.splice(key as any, 1) - : isMap(base) - ? base.delete(key) - : isSet(base) - ? base.delete(patch.value) - : delete base[key] - break + switch (type) { + case Archtype.Array: + return base.splice(key as any, 1) + case Archtype.Map: + return base.delete(key) + case Archtype.Set: + return base.delete(patch.value) + default: + return delete base[key] + } default: throw new Error("Unsupported patch operation: " + op) } @@ -205,3 +231,20 @@ export function applyPatches(draft: T, patches: Patch[]): T { return draft } + +// TODO: optimize: this is quite a performance hit, can we detect intelligently when it is needed? +// E.g. auto-draft when new objects from outside are assigned and modified? +// (See failing test when deepClone just returns obj) +function deepClonePatchValue(obj: T): T +function deepClonePatchValue(obj: any) { + if (!obj || typeof obj !== "object") return obj + if (Array.isArray(obj)) return obj.map(deepClonePatchValue) + if (isMap(obj)) + return new Map( + Array.from(obj.entries()).map(([k, v]) => [k, deepClonePatchValue(v)]) + ) + // Not needed: if (isSet(obj)) return new Set(Array.from(obj.values()).map(deepClone)) + const cloned = Object.create(Object.getPrototypeOf(obj)) + for (const key in obj) cloned[key] = deepClonePatchValue(obj[key]) + return cloned +} diff --git a/src/proxy.ts b/src/proxy.ts index 7f4432b2..009dde04 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,64 +1,65 @@ "use strict" import { - assign, each, has, is, isDraftable, - isDraft, - isMap, - isSet, - hasSymbol, shallowCopy, - makeIterable, DRAFT_STATE, - assignMap, - assignSet, - original, - iterateMapValues, - makeIterateSetValues + latest } from "./common" import {ImmerScope} from "./scope" +import { + AnyObject, + Drafted, + ImmerState, + AnyArray, + Objectish, + ImmerBaseState, + ProxyType +} from "./types" + +interface ProxyBaseState extends ImmerBaseState { + assigned: { + [property: string]: boolean + } + parent?: ImmerState + drafts?: { + [property: string]: Drafted + } + revoke(): void +} -// Do nothing before being finalized. -export function willFinalize() {} - -interface ES6Draft {} +export interface ProxyObjectState extends ProxyBaseState { + type: ProxyType.ProxyObject + base: AnyObject + copy: AnyObject | null + draft: Drafted +} -interface ES6State { - scope: ImmerScope - modified: boolean - finalized: boolean - assigned: - | { - [property: string]: boolean - } - | Map // TODO: always use a Map? - parent: ES6State - base: T - draft: ES6Draft | null - drafts: - | { - [property: string]: ES6Draft - } - | Map // TODO: always use a Map? - copy: T | null - revoke: null | (() => void) +export interface ProxyArrayState extends ProxyBaseState { + type: ProxyType.ProxyArray + base: AnyArray + copy: AnyArray | null + draft: Drafted } +type ProxyState = ProxyObjectState | ProxyArrayState + /** * Returns a new draft of the `base` object. * * The second argument is the parent draft-state (used internally). */ -export function createProxy( +export function createProxy( base: T, - parent: ES6State -): ES6Draft { - const scope = parent ? parent.scope : ImmerScope.current! - const state: ES6State = { + parent?: ImmerState +): Drafted { + const isArray = Array.isArray(base) + const state: ProxyState = { + type: isArray ? ProxyType.ProxyArray : (ProxyType.ProxyObject as any), // Track which produce call this is associated with. - scope, + scope: parent ? parent.scope : ImmerScope.current!, // True for both shallow and deep changes. modified: false, // Used during finalization. @@ -70,13 +71,14 @@ export function createProxy( // The base state. base, // The base proxy. - draft: null, + draft: null as any, // set below // Any property proxies. drafts: {}, // The base copy with any updated values. copy: null, // Called by the `produce` function. - revoke: null + revoke: null as any, + isManual: false } // the traps must target something, a bit like the 'real' base. @@ -87,43 +89,30 @@ export function createProxy( // Note that in the case of an array, we put the state in an array to have better Reflect defaults ootb let target: T = state as any let traps: ProxyHandler> = objectTraps - if (Array.isArray(base)) { + if (isArray) { target = [state] as any traps = arrayTraps } - // Map drafts must support object keys, so we use Map objects to track changes. - else if (isMap(base)) { - traps = mapTraps - state.drafts = new Map() - state.assigned = new Map() - } - // Set drafts use a Map object to track which of its values are drafted. - // And we don't need the "assigned" property, because Set objects have no keys. - else if (isSet(base)) { - traps = setTraps - state.drafts = new Map() - } + // TODO: optimization: might be faster, cheaper if we created a non-revocable proxy + // and administrate revoking ourselves const {revoke, proxy} = Proxy.revocable(target, traps) - - state.draft = proxy + state.draft = proxy as any state.revoke = revoke - - scope.drafts!.push(proxy) - return proxy + return proxy as any } /** * Object drafts */ -const objectTraps: ProxyHandler = { +const objectTraps: ProxyHandler = { 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] + return drafts![prop as any] } const value = latest(state)[prop] @@ -136,10 +125,11 @@ const objectTraps: ProxyHandler = { // Assigned values are never drafted. This catches any drafts we created, too. if (value !== peek(state.base, prop)) return value // Store drafts on the copy (when one exists). + // @ts-ignore drafts = state.copy } - return (drafts[prop] = createProxy(value, state)) + return (drafts![prop as any] = state.scope.immer.createProxy(value, state)) }, has(state, prop) { return prop in latest(state) @@ -147,31 +137,35 @@ const objectTraps: ProxyHandler = { ownKeys(state) { return Reflect.ownKeys(latest(state)) }, - set(state, prop, value) { + set(state, prop: string /* strictly not, but helps TS */, 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) || value === state.drafts![prop] : is(baseValue, value) && prop in state.base if (isUnchanged) return true + prepareCopy(state) markChanged(state) } state.assigned[prop] = true - state.copy[prop] = value + // @ts-ignore + state.copy![prop] = value return true }, - deleteProperty(state, prop) { + deleteProperty(state, prop: string) { // 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 + prepareCopy(state) markChanged(state) } else if (state.assigned[prop]) { // if an originally not assigned property was deleted delete state.assigned[prop] } + // @ts-ignore if (state.copy) delete state.copy[prop] return true }, @@ -182,7 +176,8 @@ const objectTraps: ProxyHandler = { const desc = Reflect.getOwnPropertyDescriptor(owner, prop) if (desc) { desc.writable = true - desc.configurable = !Array.isArray(owner) || prop !== "length" + desc.configurable = + state.type !== ProxyType.ProxyArray || prop !== "length" } return desc }, @@ -201,8 +196,9 @@ const objectTraps: ProxyHandler = { * Array drafts */ -const arrayTraps: ProxyHandler<[ES6State]> = {} +const arrayTraps: ProxyHandler<[ProxyArrayState]> = {} each(objectTraps, (key, fn) => { + // @ts-ignore arrayTraps[key] = function() { arguments[0] = arguments[0][0] return fn.apply(this, arguments) @@ -221,148 +217,12 @@ arrayTraps.set = function(state, prop, value) { return objectTraps.set!.call(this, state[0], prop, value, state[0]) } -// Used by Map and Set drafts -const reflectTraps = makeReflectTraps([ - "ownKeys", - "has", - "set", - "deleteProperty", - "defineProperty", - "getOwnPropertyDescriptor", - "preventExtensions", - "isExtensible", - "getPrototypeOf" -]) - /** * Map drafts */ -const mapTraps = makeTrapsForGetters>({ - [DRAFT_STATE]: state => state, - size: state => latest(state).size, - has: state => key => latest(state).has(key), - set: state => (key, value) => { - const values = latest(state) - if (!values.has(key) || values.get(key) !== value) { - markChanged(state) - // @ts-ignore - state.assigned.set(key, true) - state.copy!.set(key, value) - } - return state.draft - }, - delete: state => key => { - if (latest(state).has(key)) { - markChanged(state) - // @ts-ignore - state.assigned.set(key, false) - return state.copy!.delete(key) - } - return false - }, - clear: state => () => { - markChanged(state) - state.assigned = new Map() - each(latest(state).keys(), (_, key) => { - // @ts-ignore - state.assigned.set(key, false) - }) - return state.copy!.clear() - }, - // @ts-ignore - forEach: (state, _, receiver) => (cb, thisArg) => - latest(state).forEach((_, key, map) => { - const value = receiver.get(key) - cb.call(thisArg, value, key, map) - }), - get: state => key => { - const drafts = state.modified ? state.copy : state.drafts - - // @ts-ignore TODO: ...or fix by using different ES6Draft types (but better just unify to maps) - if (drafts!.has(key)) { - // @ts-ignore - const value = drafts.get(key) - - if (isDraft(value) || !isDraftable(value)) return value - - const draft = createProxy(value, state) - // @ts-ignore - drafts.set(key, draft) - return draft - } - - const value = latest(state).get(key) - if (state.finalized || !isDraftable(value)) { - return value - } - - const draft = createProxy(value, state) - //@ts-ignore - drafts.set(key, draft) - return draft - }, - keys: state => () => latest(state).keys(), - //@ts-ignore - values: iterateMapValues, - //@ts-ignore - entries: iterateMapValues, - [hasSymbol ? Symbol.iterator : "@@iterator"]: iterateMapValues -}) - -const iterateSetValues = makeIterateSetValues(createProxy) -/** - * Set drafts - */ - -const setTraps = makeTrapsForGetters>({ - //@ts-ignore - [DRAFT_STATE]: state => state, - size: state => latest(state).size, - has: state => key => latest(state).has(key), - add: state => value => { - if (!latest(state).has(value)) { - markChanged(state) - //@ts-ignore - state.copy.add(value) - } - return state.draft - }, - delete: state => value => { - markChanged(state) - //@ts-ignore - return state.copy.delete(value) - }, - clear: state => () => { - markChanged(state) - //@ts-ignore - return state.copy.clear() - }, - forEach: state => (cb, thisArg) => { - const iterator = iterateSetValues(state)() - let result = iterator.next() - while (!result.done) { - cb.call(thisArg, result.value, result.value, state.draft) - result = iterator.next() - } - }, - keys: iterateSetValues, - values: iterateSetValues, - entries: iterateSetValues, - [hasSymbol ? Symbol.iterator : "@@iterator"]: iterateSetValues -}) - -/** - * Helpers - */ - -// Retrieve the latest values of the draft. -function latest(state) { - return state.copy || state.base -} - // Access a property without creating an Immer draft. -function peek(draft, prop) { +function peek(draft: Drafted, prop: PropertyKey): any { const state = draft[DRAFT_STATE] const desc = Reflect.getOwnPropertyDescriptor( state ? latest(state) : draft, @@ -371,60 +231,29 @@ function peek(draft, prop) { return desc && desc.value } -function markChanged(state) { +export function markChanged(state: ImmerState) { if (!state.modified) { state.modified = true - - const {base, drafts, parent} = state - const copy = shallowCopy(base) - - if (isSet(base)) { - // Note: The `drafts` property is preserved for Set objects, since - // we need to keep track of which values are drafted. - assignSet(copy, drafts) - } else { - // Merge nested drafts into the copy. - if (isMap(base)) assignMap(copy, drafts) - else assign(copy, drafts) - state.drafts = null + if ( + state.type === ProxyType.ProxyObject || + state.type === ProxyType.ProxyArray + ) { + const copy = (state.copy = shallowCopy(state.base)) + each(state.drafts!, (key, value) => { + // @ts-ignore + copy[key] = value + }) + state.drafts = undefined } - state.copy = copy - if (parent) { - markChanged(parent) + if (state.parent) { + markChanged(state.parent) } } } -/** Create traps that all use the `Reflect` API on the `latest(state)` */ -function makeReflectTraps( - names: (keyof typeof Reflect)[] -): ProxyHandler { - return names.reduce( - (traps, name) => { - // @ts-ignore - traps[name] = (state, ...args) => Reflect[name](latest(state), ...args) - return traps - }, - {} as any - ) -} - -function makeTrapsForGetters( - getters: { - [K in keyof T]: ( - state: ES6State - ) => /* Skip first arg of: ProxyHandler[K] */ any +function prepareCopy(state: ProxyState) { + if (!state.copy) { + state.copy = shallowCopy(state.base) } -): ProxyHandler { - return assign({}, reflectTraps, { - get(state, prop, receiver) { - return getters.hasOwnProperty(prop) - ? getters[prop](state, prop, receiver) - : Reflect.get(state, prop, receiver) - }, - setPrototypeOf(state) { - throw new Error("Object.setPrototypeOf() cannot be used on an Immer draft") // prettier-ignore - } - }) } diff --git a/src/scope.ts b/src/scope.ts index 4436feae..9c66a91d 100644 --- a/src/scope.ts +++ b/src/scope.ts @@ -1,5 +1,6 @@ import {DRAFT_STATE} from "./common" -import {ImmerState, Patch, PatchListener} from "./types" +import {Patch, PatchListener, Drafted, ProxyType} from "./types" +import {Immer} from "./immer" /** Each scope represents a `produce` call. */ export class ImmerScope { @@ -11,20 +12,19 @@ export class ImmerScope { drafts: any[] parent?: ImmerScope patchListener?: PatchListener + immer: Immer - constructor(parent?: ImmerScope) { + constructor(parent: ImmerScope | undefined, immer: Immer) { this.drafts = [] this.parent = parent + this.immer = immer // 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 as any // TODO: } - usePatches(patchListener: PatchListener) { + usePatches(patchListener?: PatchListener) { if (patchListener) { this.patches = [] this.inversePatches = [] @@ -36,7 +36,7 @@ export class ImmerScope { this.leave() this.drafts.forEach(revoke) // @ts-ignore - this.drafts = null // TODO: // Make draft-related methods throw. + this.drafts = null } leave() { @@ -45,13 +45,19 @@ export class ImmerScope { } } - static enter() { - const scope = new ImmerScope(ImmerScope.current) + static enter(immer: Immer) { + const scope = new ImmerScope(ImmerScope.current, immer) ImmerScope.current = scope return scope } } -function revoke(draft) { - draft[DRAFT_STATE].revoke() +function revoke(draft: Drafted) { + const state = draft[DRAFT_STATE] + if ( + state.type === ProxyType.ProxyObject || + state.type === ProxyType.ProxyArray + ) + state.revoke() + else state.revoked = true } diff --git a/src/set.ts b/src/set.ts new file mode 100644 index 00000000..a952b4af --- /dev/null +++ b/src/set.ts @@ -0,0 +1,145 @@ +import {DRAFT_STATE, latest, isDraftable, iteratorSymbol} from "./common" + +import {ImmerScope} from "./scope" +import {AnySet, Drafted, ImmerState, ImmerBaseState, ProxyType} from "./types" +import {assertUnrevoked} from "./es5" + +export interface SetState extends ImmerBaseState { + type: ProxyType.Set + copy: AnySet | undefined + base: AnySet + drafts: Map // maps the original value to the draft value in the new set + revoked: boolean + draft: Drafted +} + +// Make sure DraftSet declarion doesn't die if Map is not avialable... +/* istanbul ignore next */ +const SetBase: SetConstructor = + typeof Set !== "undefined" ? Set : (function FakeSet() {} as any) + +export class DraftSet extends SetBase implements Set { + [DRAFT_STATE]: SetState + constructor(target: AnySet, parent?: ImmerState) { + super() + this[DRAFT_STATE] = { + type: ProxyType.Set, + parent, + scope: parent ? parent.scope : ImmerScope.current!, + modified: false, + finalized: false, + copy: undefined, + base: target, + draft: this, + drafts: new Map(), + revoked: false, + isManual: false + } + } + + get size(): number { + return latest(this[DRAFT_STATE]).size + } + + has(value: V): boolean { + const state = this[DRAFT_STATE] + assertUnrevoked(state) + // bit of trickery here, to be able to recognize both the value, and the draft of its value + if (!state.copy) { + return state.base.has(value) + } + if (state.copy.has(value)) return true + if (state.drafts.has(value) && state.copy.has(state.drafts.get(value))) + return true + return false + } + + add(value: V): this { + const state = this[DRAFT_STATE] + assertUnrevoked(state) + if (state.copy) { + state.copy.add(value) + } else if (!state.base.has(value)) { + prepareCopy(state) + state.scope.immer.markChanged(state) + state.copy!.add(value) + } + return this + } + + delete(value: V): boolean { + if (!this.has(value)) { + return false + } + + const state = this[DRAFT_STATE] + assertUnrevoked(state) + prepareCopy(state) + state.scope.immer.markChanged(state) + return ( + state.copy!.delete(value) || + (state.drafts.has(value) + ? state.copy!.delete(state.drafts.get(value)) + : /* istanbul ignore next */ false) + ) + } + + clear() { + const state = this[DRAFT_STATE] + assertUnrevoked(state) + prepareCopy(state) + state.scope.immer.markChanged(state) + return state.copy!.clear() + } + + values(): IterableIterator { + const state = this[DRAFT_STATE] + assertUnrevoked(state) + prepareCopy(state) + return state.copy!.values() + } + + entries(): IterableIterator<[V, V]> { + const state = this[DRAFT_STATE] + assertUnrevoked(state) + prepareCopy(state) + return state.copy!.entries() + } + + keys(): IterableIterator { + return this.values() + } + + [iteratorSymbol]() { + return this.values() + } + + forEach(cb: (value: V, key: V, self: this) => void, thisArg?: any) { + const iterator = this.values() + let result = iterator.next() + while (!result.done) { + cb.call(thisArg, result.value, result.value, this) + result = iterator.next() + } + } +} + +export function proxySet(target: AnySet, parent?: ImmerState) { + return new DraftSet(target, parent) +} + +function prepareCopy(state: SetState) { + if (!state.copy) { + // create drafts for all entries to preserve insertion order + state.copy = new Set() + state.base.forEach(value => { + if (isDraftable(value)) { + const draft = state.scope.immer.createProxy(value, state) + state.drafts.set(value, draft) + state.copy!.add(draft) + } else { + state.copy!.add(value) + } + }) + } +} diff --git a/src/types.ts b/src/types.ts index 5ec69ee5..e0562c00 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,20 +1,58 @@ -import {Nothing} from "./common" - -export type Objectish = any[] | Map | Set | {} +import {Nothing, DRAFT_STATE} from "./common" +import {SetState} from "./set" +import {MapState} from "./map" +import {ProxyObjectState, ProxyArrayState} from "./proxy" +import {ES5ObjectState, ES5ArrayState} from "./es5" +import {ImmerScope} from "./scope" + +export type Objectish = AnyObject | AnyArray | AnyMap | AnySet +export type ObjectishNoSet = AnyObject | AnyArray | AnyMap + +export type AnyObject = {[key: string]: any} +export type AnyArray = Array +export type AnySet = Set +export type AnyMap = Map +export enum Archtype { + Object, + Array, + Map, + Set +} -export type ObjectishNoSet = any[] | Map | {} +export enum ProxyType { + ProxyObject, + ProxyArray, + ES5Object, + ES5Array, + Map, + Set +} -export interface ImmerState { +export interface ImmerBaseState { parent?: ImmerState - base: T - copy: T - assigned: {[prop: string]: boolean; [index: number]: boolean} + scope: ImmerScope + modified: boolean + finalized: boolean + isManual: boolean } -type Tail = ((...t: T) => any) extends (( +export type ImmerState = + | ProxyObjectState + | ProxyArrayState + | ES5ObjectState + | ES5ArrayState + | MapState + | SetState + +// The _internal_ type used for drafts (not to be confused with Draft, which is public facing) +export type Drafted = { + [DRAFT_STATE]: T +} & Base + +type Tail = ((...t: T) => any) extends ( _: any, ...tail: infer TT -) => any) +) => any ? TT : [] @@ -176,12 +214,3 @@ export interface IProduceWithPatches { recipe: (draft: D) => Return ): [Produced, Patch[], Patch[]] } - -// Backward compatibility with --target es5 -// TODO: still needed? -// declare global { -// interface Set {} -// interface Map {} -// interface WeakSet {} -// interface WeakMap {} -// } diff --git a/tsconfig.json b/tsconfig.json index 390e9c21..4c36b761 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,12 +1,17 @@ { - "include": ["src"], "compilerOptions": { "lib": ["es2015"], + "target": "ES6", "strict": true, "declaration": true, "importHelpers": false, + "noImplicitAny": true, + "esModuleInterop": true, + "noUnusedLocals": true, "sourceMap": true, "declarationMap": true, - "noImplicitAny": false // TODO: true, - } + "noEmit": true, + "moduleResolution": "node" + }, + "files": ["./src/index.ts"] } diff --git a/yarn.lock b/yarn.lock index b4a8c2ea..cfa7df9b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5876,10 +5876,10 @@ preserve@^0.2.0: resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b" integrity sha1-gV7R9uvGWSb4ZbMQwHE7yzMVzks= -prettier@1.17.0: - version "1.17.0" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.17.0.tgz#53b303676eed22cc14a9f0cec09b477b3026c008" - integrity sha512-sXe5lSt2WQlCbydGETgfm1YBShgOX4HxQkFPvbxkcwgDvGDeqVau8h+12+lmSVlP3rHPz0oavfddSZg/q+Szjw== +prettier@1.19.1: + version "1.19.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb" + integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew== pretty-format@^24.7.0: version "24.7.0"