Skip to content

Commit

Permalink
Add Optional Support For Multiple References to an Object
Browse files Browse the repository at this point in the history
# 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)
  • Loading branch information
BellCubeDev committed Mar 19, 2024
1 parent 8e465ab commit a2caade
Show file tree
Hide file tree
Showing 10 changed files with 722 additions and 97 deletions.
128 changes: 128 additions & 0 deletions __tests__/__prod_snapshots__/base.js.snap

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions __tests__/__prod_snapshots__/manual.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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"`;
Expand Down
283 changes: 283 additions & 0 deletions __tests__/multiref.ts
Original file line number Diff line number Diff line change
@@ -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<string, {someNumber: number; someString: string}>,
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")

});
})
})
Loading

0 comments on commit a2caade

Please sign in to comment.