From a2caade6acd03571af82fa0e8e1ddd05a39aeb7e Mon Sep 17 00:00:00 2001 From: BellCube Dev <33764825+BellCubeDev@users.noreply.github.com> Date: Mon, 18 Mar 2024 20:46:26 -0400 Subject: [PATCH] Add Optional Support For Multiple References to an Object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # What This Does Some state trees may need to reference an object more than once (such as the tree for my [fomod](https://www.npmjs.com/package/fomod) library). In essence, we store existing drafts when an off-by-default Immer class configuration option is enabled. This should be a painless solution. Specifics are described below. ## Implementation Details * Two `WeakMap` are used to keep track of draft states and related data at different parts of the immerification process: * `existingStateMap_` maps a given base object to the first draft state created for it. This state includes a reference to the revokable draft. * If a state is referenced multiple times, it will be given a new `revoke_()` function that, once called the first time, calls the old `revoke_()` function. The result is that the final `revoke_()` must be called once for every requested draft before the Proxy is finally revoked. Since a proxy which has has its `revoke_()` method called should be considered revoked by all code paths, duplicate calls should not be an issue. * During finalization, `encounteredObjects` keeps track of objects we've finalized and doesn't traverse an object if it's already seen it. It prevents infinite recursion when circular references are present. * Introduced the `extraParents_` property to the `ImmerBaseState` interface. This keeps track of additional values that would normally be attached to `parent_` so that functionality such as marking the parent state as modified is retained for objects with multiple parent objects * For Maps and Sets, a proxy is established between the state and DraftMap/DraftSet classes to handle multiple references to these native classes while preserving the idea of having one DraftSet per reference. * For Sets, each child draft has a single symbol value set so that a copy is prepared. (discussion needed; see TODOs below) * During finalization, objects may have drafted children and, thus, even unmodified children are finalized in multi-ref mode * To enable the feature, it is the same as other Immer class configuration options (such as `useStrictShallowCopy`). That is, either specify it in the config object passed to the class's constructor OR call the relevant method, `setAllowMultiRefs()` > [!NOTE] > Because of the extra computation involved with checking every proxied object against a map and traversing every object in a tree, enabling multi-ref will have a significant performance impact—even on trees which contain no repeated references. # Tests The file `__tests__/multiref.ts` contains a number of tests related to this multi-reference support implementation. Such tests seek to verify that: * Direct circular references (which Immer tests for normally) do not throw an error when multi-ref is enabled * When the properties of multiple references are modified, all references are modified * Unmodified references to the same object are kept * The same copy is provided for every reference (new references are strictly equivalent [`===`] just as the references before `produce()` would have been) Tests are performed on all relevant object archetypes where applicable. # Outstanding Discussion TODOs * [ ] What to do regarding documentation * [ ] Possible alternate solution for preparing copies for multi-reference DraftSet children * [ ] Add an error for when WeakMap isn't supported in the current environment? (supported in every noteworthy browser and server environment since late 2015) --- __tests__/__prod_snapshots__/base.js.snap | 128 +++++++++ __tests__/__prod_snapshots__/manual.js.snap | 2 + __tests__/multiref.ts | 283 ++++++++++++++++++++ src/core/finalize.ts | 87 ++++-- src/core/immerClass.ts | 51 +++- src/core/proxy.ts | 67 +++-- src/plugins/mapset.ts | 182 +++++++++---- src/types/types-internal.ts | 2 + src/utils/plugins.ts | 15 +- website/docs/pitfalls.md | 2 + 10 files changed, 722 insertions(+), 97 deletions(-) create mode 100644 __tests__/multiref.ts 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