diff --git a/__tests__/__prod_snapshots__/base.js.snap b/__tests__/__prod_snapshots__/base.js.snap index 84c5e6b2..146f5669 100644 --- a/__tests__/__prod_snapshots__/base.js.snap +++ b/__tests__/__prod_snapshots__/base.js.snap @@ -8,6 +8,22 @@ exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener= exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=false recipe functions cannot return an object that references itself 1`] = `"Maximum call stack size exceeded"`; +exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=false revokes the draft once produce returns 1`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=false revokes the draft once produce returns 2`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=false revokes the draft once produce returns 3`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=false revokes the draft once produce returns 4`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=false revokes the draft once produce returns 5`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=false revokes the draft once produce returns 6`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=false revokes the draft once produce returns 7`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=false revokes the draft once produce returns 8`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=false set drafts revokes sets 1`] = `"[Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf"`; exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=false set drafts revokes sets 2`] = `"[Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf"`; @@ -26,6 +42,22 @@ exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener= exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=true recipe functions cannot return an object that references itself 1`] = `"Maximum call stack size exceeded"`; +exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=true revokes the draft once produce returns 1`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=true revokes the draft once produce returns 2`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=true revokes the draft once produce returns 3`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=true revokes the draft once produce returns 4`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=true revokes the draft once produce returns 5`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=true revokes the draft once produce returns 6`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=true revokes the draft once produce returns 7`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=true revokes the draft once produce returns 8`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=true set drafts revokes sets 1`] = `"[Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf"`; exports[`base functionality - auto-freeze=false:shallow-copy=false:use-listener=true set drafts revokes sets 2`] = `"[Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf"`; @@ -44,6 +76,22 @@ exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=f exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=false recipe functions cannot return an object that references itself 1`] = `"Maximum call stack size exceeded"`; +exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=false revokes the draft once produce returns 1`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=false revokes the draft once produce returns 2`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=false revokes the draft once produce returns 3`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=false revokes the draft once produce returns 4`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=false revokes the draft once produce returns 5`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=false revokes the draft once produce returns 6`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=false revokes the draft once produce returns 7`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=false revokes the draft once produce returns 8`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=false set drafts revokes sets 1`] = `"[Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf"`; exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=false set drafts revokes sets 2`] = `"[Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf"`; @@ -62,6 +110,22 @@ exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=t exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=true recipe functions cannot return an object that references itself 1`] = `"Maximum call stack size exceeded"`; +exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=true revokes the draft once produce returns 1`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=true revokes the draft once produce returns 2`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=true revokes the draft once produce returns 3`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=true revokes the draft once produce returns 4`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=true revokes the draft once produce returns 5`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=true revokes the draft once produce returns 6`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=true revokes the draft once produce returns 7`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=true revokes the draft once produce returns 8`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=true set drafts revokes sets 1`] = `"[Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf"`; exports[`base functionality - auto-freeze=false:shallow-copy=true:use-listener=true set drafts revokes sets 2`] = `"[Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf"`; @@ -80,6 +144,22 @@ exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=f exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=false recipe functions cannot return an object that references itself 1`] = `"Maximum call stack size exceeded"`; +exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=false revokes the draft once produce returns 1`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=false revokes the draft once produce returns 2`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=false revokes the draft once produce returns 3`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=false revokes the draft once produce returns 4`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=false revokes the draft once produce returns 5`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=false revokes the draft once produce returns 6`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=false revokes the draft once produce returns 7`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=false revokes the draft once produce returns 8`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=false set drafts revokes sets 1`] = `"[Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf"`; exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=false set drafts revokes sets 2`] = `"[Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf"`; @@ -98,6 +178,22 @@ exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=t exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=true recipe functions cannot return an object that references itself 1`] = `"Maximum call stack size exceeded"`; +exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=true revokes the draft once produce returns 1`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=true revokes the draft once produce returns 2`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=true revokes the draft once produce returns 3`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=true revokes the draft once produce returns 4`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=true revokes the draft once produce returns 5`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=true revokes the draft once produce returns 6`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=true revokes the draft once produce returns 7`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=true revokes the draft once produce returns 8`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=true set drafts revokes sets 1`] = `"[Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf"`; exports[`base functionality - auto-freeze=true:shallow-copy=false:use-listener=true set drafts revokes sets 2`] = `"[Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf"`; @@ -116,6 +212,22 @@ exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=fa exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=false recipe functions cannot return an object that references itself 1`] = `"Maximum call stack size exceeded"`; +exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=false revokes the draft once produce returns 1`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=false revokes the draft once produce returns 2`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=false revokes the draft once produce returns 3`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=false revokes the draft once produce returns 4`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=false revokes the draft once produce returns 5`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=false revokes the draft once produce returns 6`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=false revokes the draft once produce returns 7`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=false revokes the draft once produce returns 8`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=false set drafts revokes sets 1`] = `"[Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf"`; exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=false set drafts revokes sets 2`] = `"[Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf"`; @@ -134,6 +246,22 @@ exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=tr exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=true recipe functions cannot return an object that references itself 1`] = `"Maximum call stack size exceeded"`; +exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=true revokes the draft once produce returns 1`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=true revokes the draft once produce returns 2`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=true revokes the draft once produce returns 3`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=true revokes the draft once produce returns 4`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=true revokes the draft once produce returns 5`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=true revokes the draft once produce returns 6`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=true revokes the draft once produce returns 7`] = `"Cannot perform 'get' on a proxy that has been revoked"`; + +exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=true revokes the draft once produce returns 8`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=true set drafts revokes sets 1`] = `"[Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf"`; exports[`base functionality - auto-freeze=true:shallow-copy=true:use-listener=true set drafts revokes sets 2`] = `"[Immer] minified error nr: 3. Full error at: https://bit.ly/3cXEKWf"`; diff --git a/__tests__/__prod_snapshots__/manual.js.snap b/__tests__/__prod_snapshots__/manual.js.snap index 40315388..4b055da7 100644 --- a/__tests__/__prod_snapshots__/manual.js.snap +++ b/__tests__/__prod_snapshots__/manual.js.snap @@ -2,6 +2,8 @@ exports[`manual - proxy cannot finishDraft twice 1`] = `"Cannot perform 'get' on a proxy that has been revoked"`; +exports[`manual - proxy cannot modify after finish 1`] = `"Cannot perform 'set' on a proxy that has been revoked"`; + exports[`manual - proxy should check arguments 1`] = `"[Immer] minified error nr: 8. Full error at: https://bit.ly/3cXEKWf"`; exports[`manual - proxy should check arguments 2`] = `"[Immer] minified error nr: 8. Full error at: https://bit.ly/3cXEKWf"`; diff --git a/__tests__/multiref.ts b/__tests__/multiref.ts new file mode 100644 index 00000000..312ed63f --- /dev/null +++ b/__tests__/multiref.ts @@ -0,0 +1,283 @@ +import {Immer, enableMapSet} from "../src/immer" +import {inspect} from "util" + +// Implementation note: TypeScript says ES5 doesn't support iterating directly over a Set so I've used Array.from(). +// If the project is moved to a later JS feature set, we can drop the Array.from() and do `for (const value of ref)` instead. + +test("modified circular object", () => { + const immer = new Immer({allowMultiRefs: true}) + const base = {a: 1, b: null} as any + base.b = base + + const envs = ["production", "development", "testing"] + for (const env of envs) { + process.env.NODE_ENV = env + expect(() => { + const next = immer.produce(base, (draft: any) => { + draft.a = 2 + }) + expect(next).toEqual({a: 2, b: next}) + }).not.toThrow() + } +}) + +test("unmodified circular object", () => { + const immer = new Immer({allowMultiRefs: true}) + const base = {a: 1, b: null} as any + base.b = base + + const envs = ["production", "development", "testing"] + for (const env of envs) { + process.env.NODE_ENV = env + expect(() => { + const next = immer.produce({state: null}, (draft: any) => { + draft.state = base + }) + expect(next.state).toBe(base) + }).not.toThrow() + } +}) + +describe("access value & change child's child value", () => { + describe("with object", () => { + const immer = new Immer({allowMultiRefs: true}) + const sameRef = {someNumber: 1, someString: "one"} + const objectOfRefs = {a: sameRef, b: sameRef, c: sameRef, d: sameRef} + + const base = { + objectRef1: objectOfRefs, + objectRef2: objectOfRefs, + objectRef3: objectOfRefs, + objectRef4: objectOfRefs + } + const next = immer.produce(base, draft => { + draft.objectRef2.b.someNumber = 2 + draft.objectRef3.c.someString = "two" + }) + + it("should have kept the Object refs the same", () => { + expect(next.objectRef1).toBe(next.objectRef2), + expect(next.objectRef2).toBe(next.objectRef3), + expect(next.objectRef3).toBe(next.objectRef4) + }) + + it("should have updated the values across everything", () => { + function verifyObjectReference( + ref: {[key: string]: {someNumber: number; someString: string}}, + objectNum: number + ) { + verifySingleReference(ref.a, objectNum, "a") + verifySingleReference(ref.b, objectNum, "b") + verifySingleReference(ref.c, objectNum, "c") + verifySingleReference(ref.d, objectNum, "d") + } + + function verifySingleReference( + ref: {someNumber: number; someString: string}, + objectNum: number, + refKey: string + ) { + //it(`should have updated the values across everything (ref ${refKey.toUpperCase()} in object #${objectNum})`, () => { + expect(ref.someNumber).toBe(2) + expect(ref.someString).toBe("two") + //}) + } + + verifyObjectReference(next.objectRef1, 1) + verifyObjectReference(next.objectRef2, 2) + verifyObjectReference(next.objectRef3, 3) + verifyObjectReference(next.objectRef4, 4) + }); + }) + + describe("with map", () => { + const immer = new Immer({allowMultiRefs: true}) + enableMapSet() + const sameRef = {someNumber: 1, someString: "one"} + const mapOfRefs = new Map([ + ["a", sameRef], + ["b", sameRef], + ["c", sameRef], + ["d", sameRef] + ]) + + const base = { + mapRef1: mapOfRefs, + mapRef2: mapOfRefs, + mapRef3: mapOfRefs, + mapRef4: mapOfRefs + } + const next = immer.produce(base, draft => { + draft.mapRef2.get("b")!.someNumber = 2 + draft.mapRef3.get("c")!.someString = "two" + }) + + it("should have kept the Map refs the same", () => { + expect(next.mapRef1).toBe(next.mapRef2), + expect(next.mapRef2).toBe(next.mapRef3), + expect(next.mapRef3).toBe(next.mapRef4) + }) + + it("should have updated the values across everything", () => { + function verifyMapReference( + ref: Map, + mapNum: number + ) { + verifySingleReference(ref.get("a")!, mapNum, "a") + verifySingleReference(ref.get("b")!, mapNum, "b") + verifySingleReference(ref.get("c")!, mapNum, "c") + verifySingleReference(ref.get("d")!, mapNum, "d") + + //it(`should have the same child refs (map #${mapNum})`, () => { + expect(ref.get("a")).toBe(ref.get("b")), + expect(ref.get("b")).toBe(ref.get("c")), + expect(ref.get("c")).toBe(ref.get("d")) + //}) + } + + function verifySingleReference( + ref: {someNumber: number; someString: string}, + mapNum: number, + refKey: string + ) { + //it(`should have updated the values across everything (ref ${refKey.toUpperCase()} in map #${mapNum})`, () => { + expect(ref.someNumber).toBe(2) + expect(ref.someString).toBe("two") + //}) + } + + verifyMapReference(next.mapRef1, 1) + verifyMapReference(next.mapRef2, 2) + verifyMapReference(next.mapRef3, 3) + verifyMapReference(next.mapRef4, 4) + + }); + }) + + describe("with array", () => { + const immer = new Immer({allowMultiRefs: true}) + const sameRef = {someNumber: 1, someString: "one"} + const arrayOfRefs = [sameRef, sameRef, sameRef, sameRef] + + const base = { + arrayRef1: arrayOfRefs, + arrayRef2: arrayOfRefs, + arrayRef3: arrayOfRefs, + arrayRef4: arrayOfRefs + } + const next = immer.produce(base, draft => { + draft.arrayRef2[1].someNumber = 2 + draft.arrayRef3[2].someString = "two" + }) + + it("should have kept the Array refs the same", () => { + expect(next.arrayRef1).toBe(next.arrayRef2), + expect(next.arrayRef2).toBe(next.arrayRef3), + expect(next.arrayRef3).toBe(next.arrayRef4) + }) + + it("should have updated the values across everything", () => { + function verifyArrayReference( + ref: {someNumber: number; someString: string}[], + arrayNum: number + ) { + let i = 0 + for (const value of ref) { + //it(`should have updated the values across everything (ref #${i++} in array #${arrayNum})`, () => { + verifySingleReference(value) + //}) + } + } + + function verifySingleReference(ref: { + someNumber: number + someString: string + }) { + expect(ref.someNumber).toBe(2) + expect(ref.someString).toBe("two") + } + + verifyArrayReference(next.arrayRef1, 1) + verifyArrayReference(next.arrayRef2, 2) + verifyArrayReference(next.arrayRef3, 3) + verifyArrayReference(next.arrayRef4, 4) + }); + }) + + describe("with set", () => { + const immer = new Immer({allowMultiRefs: true}) + enableMapSet() + const sameRef = {someNumber: 1, someString: "one"} + const setOfRefs = new Set([{sameRef}, {sameRef}, {sameRef}, {sameRef}]) + + const base = { + setRef1: setOfRefs, + setRef2: setOfRefs, + setRef3: setOfRefs, + setRef4: setOfRefs + } + //console.log("base", inspect(base, {depth: 6, colors: true, compact: true})) + + const next = immer.produce(base, draft => { + const set2Values = draft.setRef2.values() + set2Values.next() + set2Values.next().value.sameRef.someNumber = 2 + + const set3Values = draft.setRef3.values() + set3Values.next() + set3Values.next() + set3Values.next().value.sameRef.someString = "two" + }) + + //console.log( + // "next", + // inspect(next, { + // depth: 20, + // colors: true, + // compact: true, + // breakLength: Infinity + // }) + //) + + it("should have kept the Set refs the same", () => { + expect(next.setRef1).toBe(next.setRef2), + expect(next.setRef2).toBe(next.setRef3), + expect(next.setRef3).toBe(next.setRef4) + }) + + it("should have updated the values across everything", () => { + function verifySetReference( + ref: Set<{sameRef: {someNumber: number; someString: string}}>, + setLetter: string + ) { + //it(`should have the same child refs (set ${setLetter.toUpperCase()})`, () => { + let first = ref.values().next().value.sameRef + for (const value of Array.from(ref)) { + expect(value.sameRef).toBe(first) + } + //}) + + let i = 0 + for (const value of Array.from(ref)) { + //it(`should have updated the values across everything (ref #${i++} in set ${setLetter.toUpperCase()})`, () => { + verifySingleReference(value.sameRef) + //}) + } + } + + function verifySingleReference(ref: { + someNumber: number + someString: string + }) { + expect(ref.someNumber).toBe(2) + expect(ref.someString).toBe("two") + } + + verifySetReference(next.setRef1, "a") + verifySetReference(next.setRef2, "b") + verifySetReference(next.setRef3, "c") + verifySetReference(next.setRef4, "d") + + }); + }) +}) diff --git a/src/core/finalize.ts b/src/core/finalize.ts index 6ee69ce6..660667e7 100644 --- a/src/core/finalize.ts +++ b/src/core/finalize.ts @@ -15,10 +15,15 @@ import { getPlugin, die, revokeScope, - isFrozen + isFrozen, + Objectish } from "../internal" -export function processResult(result: any, scope: ImmerScope) { +export function processResult( + result: any, + scope: ImmerScope, + existingStateMap?: WeakMap +) { scope.unfinalizedDrafts_ = scope.drafts_.length const baseDraft = scope.drafts_![0] const isReplaced = result !== undefined && result !== baseDraft @@ -29,8 +34,8 @@ export function processResult(result: any, scope: ImmerScope) { } if (isDraftable(result)) { // Finalize the result in case it contains (or is) a subset of the draft. - result = finalize(scope, result) - if (!scope.parent_) maybeFreeze(scope, result) + result = finalize(scope, result, undefined, existingStateMap) + if (!scope.parent_) maybeFreeze(scope, result, false) } if (scope.patches_) { getPlugin("Patches").generateReplacementPatches_( @@ -42,7 +47,7 @@ export function processResult(result: any, scope: ImmerScope) { } } else { // Finalize the base draft. - result = finalize(scope, baseDraft, []) + result = finalize(scope, baseDraft, [], existingStateMap) } revokeScope(scope) if (scope.patches_) { @@ -51,23 +56,43 @@ export function processResult(result: any, scope: ImmerScope) { return result !== NOTHING ? result : undefined } -function finalize(rootScope: ImmerScope, value: any, path?: PatchPath) { +function finalize( + rootScope: ImmerScope, + value: any, + path?: PatchPath, + existingStateMap?: WeakMap, + encounteredObjects = new WeakSet() +): any { // Don't recurse in tho recursive data structures - if (isFrozen(value)) return value + if (isFrozen(value) || encounteredObjects.has(value)) return value + encounteredObjects.add(value) + + let state: ImmerState = value[DRAFT_STATE] - const state: ImmerState = value[DRAFT_STATE] // A plain object, might need freezing, might contain drafts - if (!state) { - each(value, (key, childValue) => - finalizeProperty(rootScope, state, value, key, childValue, path) + if (!state || (!state.modified_ && state.existingStateMap_)) { + each( + value, + (key, childValue) => + finalizeProperty( + rootScope, + state, + value, + key, + childValue, + path, + undefined, + existingStateMap, + encounteredObjects + ) ) - return value + return state ? state.base_ : value } // Never finalize drafts owned by another scope. if (state.scope_ !== rootScope) return value // Unmodified draft, return the (frozen) original if (!state.modified_) { - maybeFreeze(rootScope, state.base_, true) + maybeFreeze(rootScope, state.copy_ ?? state.base_, true) return state.base_ } // Not finalized yet, let's do that now @@ -87,7 +112,17 @@ function finalize(rootScope: ImmerScope, value: any, path?: PatchPath) { isSet = true } each(resultEach, (key, childValue) => - finalizeProperty(rootScope, state, result, key, childValue, path, isSet) + finalizeProperty( + rootScope, + state, + result, + key, + childValue, + path, + isSet, + existingStateMap, + encounteredObjects + ) ) // everything inside is frozen, we can freeze here maybeFreeze(rootScope, result, false) @@ -101,6 +136,7 @@ function finalize(rootScope: ImmerScope, value: any, path?: PatchPath) { ) } } + return state.copy_ } @@ -111,10 +147,20 @@ function finalizeProperty( prop: string | number, childValue: any, rootPath?: PatchPath, - targetIsSet?: boolean + targetIsSet?: boolean, + existingStateMap?: WeakMap, + encounteredObjects = new WeakSet() ) { if (process.env.NODE_ENV !== "production" && childValue === targetObject) die(5) + + if (!isDraft(childValue) && isDraftable(childValue)) { + const existingState = existingStateMap?.get(childValue) + if (existingState) { + childValue = existingState.draft_ + } + } + if (isDraft(childValue)) { const path = rootPath && @@ -124,7 +170,7 @@ function finalizeProperty( ? rootPath!.concat(prop) : undefined // Drafts owned by `scope` are finalized here. - const res = finalize(rootScope, childValue, path) + const res = finalize(rootScope, childValue, path, existingStateMap) set(targetObject, prop, res) // Drafts from another scope must prevented to be frozen // if we got a draft back from finalize, we're in a nested produce and shouldn't freeze @@ -134,6 +180,7 @@ function finalizeProperty( } else if (targetIsSet) { targetObject.add(childValue) } + // Search new objects for unfinalized drafts. Frozen objects should never contain drafts. if (isDraftable(childValue) && !isFrozen(childValue)) { if (!rootScope.immer_.autoFreeze_ && rootScope.unfinalizedDrafts_ < 1) { @@ -144,7 +191,13 @@ function finalizeProperty( // See add-data.js perf test return } - finalize(rootScope, childValue) + finalize( + rootScope, + childValue, + undefined, + existingStateMap, + encounteredObjects + ) // Immer deep freezes plain objects, so if there is no parent state, we freeze as well // Per #590, we never freeze symbolic properties. Just to make sure don't accidentally interfere // with other frameworks. diff --git a/src/core/immerClass.ts b/src/core/immerClass.ts index 6c673e0a..88eb07a9 100644 --- a/src/core/immerClass.ts +++ b/src/core/immerClass.ts @@ -34,12 +34,19 @@ interface ProducersFns { export class Immer implements ProducersFns { autoFreeze_: boolean = true useStrictShallowCopy_: boolean = false + allowMultiRefs_: boolean = false - constructor(config?: {autoFreeze?: boolean; useStrictShallowCopy?: boolean}) { + constructor(config?: { + autoFreeze?: boolean + useStrictShallowCopy?: boolean + allowMultiRefs: boolean + }) { if (typeof config?.autoFreeze === "boolean") this.setAutoFreeze(config!.autoFreeze) if (typeof config?.useStrictShallowCopy === "boolean") this.setUseStrictShallowCopy(config!.useStrictShallowCopy) + if (typeof config?.allowMultiRefs === "boolean") + this.setAllowMultiRefs(config!.allowMultiRefs) } /** @@ -86,7 +93,8 @@ export class Immer implements ProducersFns { // Only plain objects, arrays, and "immerable classes" are drafted. if (isDraftable(base)) { const scope = enterScope(this) - const proxy = createProxy(base, undefined) + const stateMap = this.allowMultiRefs_ ? new Map() : undefined + const proxy = createProxy(base, undefined, stateMap) let hasError = true try { result = recipe(proxy) @@ -97,7 +105,7 @@ export class Immer implements ProducersFns { else leaveScope(scope) } usePatchesInScope(scope, patchListener) - return processResult(result, scope) + return processResult(result, scope, stateMap) } else if (!base || typeof base !== "object") { result = recipe(base) if (result === undefined) result = base @@ -132,7 +140,11 @@ export class Immer implements ProducersFns { if (!isDraftable(base)) die(8) if (isDraft(base)) base = current(base) const scope = enterScope(this) - const proxy = createProxy(base, undefined) + const proxy = createProxy( + base, + undefined, + this.allowMultiRefs_ ? new WeakMap() : undefined + ) proxy[DRAFT_STATE].isManual_ = true leaveScope(scope) return proxy as any @@ -144,9 +156,10 @@ export class Immer implements ProducersFns { ): D extends Draft ? T : never { const state: ImmerState = draft && (draft as any)[DRAFT_STATE] if (!state || !state.isManual_) die(9) - const {scope_: scope} = state + + const {scope_: scope, existingStateMap_} = state usePatchesInScope(scope, patchListener) - return processResult(undefined, scope) + return processResult(undefined, scope, existingStateMap_) as any } /** @@ -167,6 +180,11 @@ export class Immer implements ProducersFns { this.useStrictShallowCopy_ = value } + /** Pass true to allow multiple references to the same object in the same state tree. */ + setAllowMultiRefs(value: boolean) { + this.allowMultiRefs_ = value + } + applyPatches(base: T, patches: Patch[]): T { // If a patch replaces the entire state, take that replacement as base // before applying patches @@ -198,16 +216,29 @@ export class Immer implements ProducersFns { export function createProxy( value: T, - parent?: ImmerState + parent?: ImmerState, + stateMap: + | WeakMap + | undefined = parent?.existingStateMap_ ): Drafted { // precondition: createProxy should be guarded by isDraftable, so we know we can safely draft const draft: Drafted = isMap(value) - ? getPlugin("MapSet").proxyMap_(value, parent) + ? getPlugin("MapSet").proxyMap_( + value, + parent, + stateMap ?? parent?.existingStateMap_ + ) : isSet(value) - ? getPlugin("MapSet").proxySet_(value, parent) - : createProxyProxy(value, parent) + ? getPlugin("MapSet").proxySet_( + value, + parent, + stateMap ?? parent?.existingStateMap_ + ) + : createProxyProxy(value, parent, stateMap ?? parent?.existingStateMap_) const scope = parent ? parent.scope_ : getCurrentScope() + scope.drafts_.push(draft) + return draft } diff --git a/src/core/proxy.ts b/src/core/proxy.ts index 3ce06aa8..80447738 100644 --- a/src/core/proxy.ts +++ b/src/core/proxy.ts @@ -51,10 +51,11 @@ type ProxyState = ProxyObjectState | ProxyArrayState */ export function createProxyProxy( base: T, - parent?: ImmerState + parent?: ImmerState, + stateMap?: WeakMap ): Drafted { const isArray = Array.isArray(base) - const state: ProxyState = { + const state: ProxyState = (stateMap?.get(base) as ProxyState) || { type_: isArray ? ArchType.Array : (ArchType.Object as any), // Track which produce call this is associated with. scope_: parent ? parent.scope_ : getCurrentScope()!, @@ -74,7 +75,13 @@ export function createProxyProxy( copy_: null, // Called by the `produce` function. revoke_: null as any, - isManual_: false + isManual_: false, + existingStateMap_: stateMap + } + + if (parent && state.parent_ !== parent) { + if (state.extraParents_) state.extraParents_.push(parent) + else state.extraParents_ = [parent] } // the traps must target something, a bit like the 'real' base. @@ -90,10 +97,21 @@ export function createProxyProxy( traps = arrayTraps } - const {revoke, proxy} = Proxy.revocable(target, traps) - state.draft_ = proxy as any - state.revoke_ = revoke - return proxy as any + if (state.revoke_) { + let thisHasBeenRevoked = false + const oldRevoke = state.revoke_ + state.revoke_ = () => { + if (thisHasBeenRevoked) return oldRevoke() + thisHasBeenRevoked = true + } + } else { + const {revoke, proxy} = Proxy.revocable(target, traps) + + if (!state.draft_) state.draft_ = proxy as any + state.revoke_ = revoke + } + + return state.draft_ as any } /** @@ -116,7 +134,11 @@ export const objectTraps: ProxyHandler = { // Assigned values are never drafted. This catches any drafts we created, too. if (value === peek(state.base_, prop)) { prepareCopy(state) - return (state.copy_![prop as any] = createProxy(value, state)) + return (state.copy_![prop as any] = createProxy( + value, + state, + state.existingStateMap_ + )) } return value }, @@ -275,18 +297,27 @@ export function markChanged(state: ImmerState) { if (state.parent_) { markChanged(state.parent_) } + if (state.extraParents_) { + for (let i = 0; i < state.extraParents_.length; i++) { + markChanged(state.extraParents_[i]) + } + } } } -export function prepareCopy(state: { - base_: any - copy_: any - scope_: ImmerScope -}) { - if (!state.copy_) { - state.copy_ = shallowCopy( - state.base_, - state.scope_.immer_.useStrictShallowCopy_ - ) +export function prepareCopy(state: ImmerState) { + if (state.copy_) return + + const existing = state.existingStateMap_?.get(state.base_) + if (existing) { + Object.assign(state, existing) + return } + + state.copy_ = shallowCopy( + state.base_, + state.scope_.immer_.useStrictShallowCopy_ + ) + + state.existingStateMap_?.set(state.base_, state) } diff --git a/src/plugins/mapset.ts b/src/plugins/mapset.ts index edc628a7..6244dc3c 100644 --- a/src/plugins/mapset.ts +++ b/src/plugins/mapset.ts @@ -14,27 +14,60 @@ import { markChanged, die, ArchType, - each + each, + Objectish } from "../internal" export function enableMapSet() { class DraftMap extends Map { [DRAFT_STATE]: MapState - constructor(target: AnyMap, parent?: ImmerState) { + constructor( + target: AnyMap, + parent?: ImmerState, + stateMap: + | WeakMap + | undefined = parent?.existingStateMap_ + ) { super() - this[DRAFT_STATE] = { - type_: ArchType.Map, - parent_: parent, - scope_: parent ? parent.scope_ : getCurrentScope()!, - modified_: false, - finalized_: false, - copy_: undefined, - assigned_: undefined, - base_: target, - draft_: this as any, - isManual_: false, - revoked_: false + let revoked = false + const this_ = this + this[DRAFT_STATE] = new Proxy( + (stateMap?.get(target) as MapState) || { + type_: ArchType.Map, + parent_: parent, + scope_: parent ? parent.scope_ : getCurrentScope()!, + modified_: false, + finalized_: false, + copy_: undefined, + assigned_: undefined, + base_: target, + draft_: this as any, + isManual_: false, + revoked_: false, + existingStateMap_: parent?.existingStateMap_ as any + }, + { + get(target, p, receiver) { + if (p === "revoked_") return revoked + if (p === "draft_") return this_ + return Reflect.get(target, p, receiver) + }, + set(target, p, newValue, receiver) { + if (p === "revoked_") { + revoked = newValue + return true + } + if (p === "draft_") return false + return Reflect.set(target, p, newValue, receiver) + } + } + ) + + if (parent && this[DRAFT_STATE].parent_ !== parent) { + if (this[DRAFT_STATE].extraParents_) + this[DRAFT_STATE].extraParents_.push(parent) + else this[DRAFT_STATE].extraParents_ = [parent] } } @@ -109,7 +142,7 @@ export function enableMapSet() { return value // either already drafted or reassigned } // despite what it looks, this creates a draft only once, see above condition - const draft = createProxy(value, state) + const draft = createProxy(value, state, state.existingStateMap_) prepareMapCopy(state) state.copy_!.set(key, draft) return draft @@ -158,34 +191,72 @@ export function enableMapSet() { } } - function proxyMap_(target: T, parent?: ImmerState): T { + function proxyMap_( + target: T, + parent?: ImmerState, + stateMap: + | WeakMap + | undefined = parent?.existingStateMap_ + ): T { // @ts-ignore - return new DraftMap(target, parent) + return new DraftMap(target, parent, stateMap) } function prepareMapCopy(state: MapState) { - if (!state.copy_) { - state.assigned_ = new Map() - state.copy_ = new Map(state.base_) - } + if (state.copy_) return + state.assigned_ = new Map() + state.copy_ = new Map(state.base_) + state.existingStateMap_?.set(state.base_, state) } class DraftSet extends Set { [DRAFT_STATE]: SetState - constructor(target: AnySet, parent?: ImmerState) { + constructor( + target: AnySet, + parent?: ImmerState, + stateMap: + | WeakMap + | undefined = parent?.existingStateMap_ + ) { super() - this[DRAFT_STATE] = { - type_: ArchType.Set, - parent_: parent, - scope_: parent ? parent.scope_ : getCurrentScope()!, - modified_: false, - finalized_: false, - copy_: undefined, - base_: target, - draft_: this, - drafts_: new Map(), - revoked_: false, - isManual_: false + let revoked = false + const this_ = this + this[DRAFT_STATE] = new Proxy( + (stateMap?.get(target) as SetState) || { + type_: ArchType.Set, + parent_: parent, + scope_: parent ? parent.scope_ : getCurrentScope()!, + modified_: false, + finalized_: false, + copy_: undefined, + base_: target, + draft_: this, + drafts_: new Map(), + revoked_: false, + isManual_: false, + existingStateMap_: parent?.existingStateMap_ as any + }, + { + get(target, p, receiver) { + if (p === "revoked_") return revoked + if (p === "draft_") return this_ + return Reflect.get(target, p, receiver) + }, + set(target, p, newValue, receiver) { + if (p === "revoked_") { + revoked = newValue + return true + } + if (p === "draft_") return false + return Reflect.set(target, p, newValue, receiver) + } + } + ) + + if (parent && this[DRAFT_STATE].parent_ !== parent) { + if (this[DRAFT_STATE].extraParents_) + this[DRAFT_STATE].extraParents_.push(parent) + else this[DRAFT_STATE].extraParents_ = [parent] } } @@ -267,6 +338,7 @@ export function enableMapSet() { } forEach(cb: any, thisArg?: any) { + console.log("Set forEach", this) const iterator = this.values() let result = iterator.next() while (!result.done) { @@ -275,25 +347,37 @@ export function enableMapSet() { } } } - function proxySet_(target: T, parent?: ImmerState): T { + + function proxySet_( + target: T, + parent?: ImmerState, + stateMap: + | WeakMap + | undefined = parent?.existingStateMap_ + ): T { // @ts-ignore - return new DraftSet(target, parent) + return new DraftSet(target, parent, stateMap) } + const unusedValueSymbol = Symbol("unused") + function prepareSetCopy(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 = createProxy(value, state) - state.drafts_.set(value, draft) - state.copy_!.add(draft) - } else { - state.copy_!.add(value) - } - }) - } + if (state.copy_) return + // create drafts for all entries to preserve insertion order + state.copy_ = new Set() + // @ts-ignore + state.existingStateMap_?.set(state.base_, state) + state.base_.forEach(value => { + if (isDraftable(value)) { + const draft = createProxy(value, state, state.existingStateMap_) + if (state.existingStateMap_) + draft[unusedValueSymbol] = unusedValueSymbol + state.drafts_.set(value, draft) + state.copy_!.add(draft) + } else { + state.copy_!.add(value) + } + }) } function assertUnrevoked(state: any /*ES5State | MapState | SetState*/) { diff --git a/src/types/types-internal.ts b/src/types/types-internal.ts index 5c506252..47cf6b83 100644 --- a/src/types/types-internal.ts +++ b/src/types/types-internal.ts @@ -24,10 +24,12 @@ export const enum ArchType { export interface ImmerBaseState { parent_?: ImmerState + extraParents_?: ImmerState[] scope_: ImmerScope modified_: boolean finalized_: boolean isManual_: boolean + existingStateMap_?: WeakMap | undefined } export type ImmerState = diff --git a/src/utils/plugins.ts b/src/utils/plugins.ts index 36cc1d70..4db4f97f 100644 --- a/src/utils/plugins.ts +++ b/src/utils/plugins.ts @@ -6,7 +6,8 @@ import { AnyMap, AnySet, ArchType, - die + die, + Objectish } from "../internal" /** Plugin utilities */ @@ -27,8 +28,16 @@ const plugins: { applyPatches_(draft: T, patches: Patch[]): T } MapSet?: { - proxyMap_(target: T, parent?: ImmerState): T - proxySet_(target: T, parent?: ImmerState): T + proxyMap_( + target: T, + parent?: ImmerState, + stateMap?: WeakMap + ): T + proxySet_( + target: T, + parent?: ImmerState, + stateMap?: WeakMap + ): T } } = {} diff --git a/website/docs/pitfalls.md b/website/docs/pitfalls.md index afa58c2b..01de6fed 100644 --- a/website/docs/pitfalls.md +++ b/website/docs/pitfalls.md @@ -17,6 +17,8 @@ Never reassign the `draft` argument (example: `draft = myCoolNewState`). Instead ### Immer only supports unidirectional trees + + Immer assumes your state to be a unidirectional tree. That is, no object should appear twice in the tree, there should be no circular references. There should be exactly one path from the root to any node of the tree. ### Never explicitly return `undefined` from a producer