Skip to content

Commit 83e87ff

Browse files
committed
Add Optional Support For Multiple References to an Object
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. By using a combination of a WeakMap to store existing drafts and an off-by-default configuration option, this should be a painless solution. I've tested it within the scope of my project but there may always be issues I haven't foreseen.
1 parent f6736a4 commit 83e87ff

File tree

6 files changed

+91
-37
lines changed

6 files changed

+91
-37
lines changed

src/core/finalize.ts

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@ import {
1515
getPlugin,
1616
die,
1717
revokeScope,
18-
isFrozen
18+
isFrozen,
19+
type Objectish,
20+
type Drafted,
21+
prepareCopy
1922
} from "../internal"
2023

21-
export function processResult(result: any, scope: ImmerScope) {
24+
export function processResult(result: any, scope: ImmerScope, existingStateMap?: WeakMap<Objectish, ImmerState>) {
2225
scope.unfinalizedDrafts_ = scope.drafts_.length
2326
const baseDraft = scope.drafts_![0]
2427
const isReplaced = result !== undefined && result !== baseDraft
@@ -29,8 +32,8 @@ export function processResult(result: any, scope: ImmerScope) {
2932
}
3033
if (isDraftable(result)) {
3134
// Finalize the result in case it contains (or is) a subset of the draft.
32-
result = finalize(scope, result)
33-
if (!scope.parent_) maybeFreeze(scope, result)
35+
result = finalize(scope, result, undefined, existingStateMap)
36+
if (!scope.parent_) maybeFreeze(scope, result, false)
3437
}
3538
if (scope.patches_) {
3639
getPlugin("Patches").generateReplacementPatches_(
@@ -42,7 +45,7 @@ export function processResult(result: any, scope: ImmerScope) {
4245
}
4346
} else {
4447
// Finalize the base draft.
45-
result = finalize(scope, baseDraft, [])
48+
result = finalize(scope, baseDraft, [], existingStateMap)
4649
}
4750
revokeScope(scope)
4851
if (scope.patches_) {
@@ -51,17 +54,18 @@ export function processResult(result: any, scope: ImmerScope) {
5154
return result !== NOTHING ? result : undefined
5255
}
5356

54-
function finalize(rootScope: ImmerScope, value: any, path?: PatchPath) {
57+
function finalize(rootScope: ImmerScope, value: any, path?: PatchPath, existingStateMap?: WeakMap<Objectish, ImmerState>): any {
5558
// Don't recurse in tho recursive data structures
5659
if (isFrozen(value)) return value
5760

58-
const state: ImmerState = value[DRAFT_STATE]
61+
let state: ImmerState = value[DRAFT_STATE]
62+
5963
// A plain object, might need freezing, might contain drafts
6064
if (!state) {
6165
each(
6266
value,
6367
(key, childValue) =>
64-
finalizeProperty(rootScope, state, value, key, childValue, path),
68+
finalizeProperty(rootScope, state, value, key, childValue, path, undefined, existingStateMap),
6569
true // See #590, don't recurse into non-enumerable of non drafted objects
6670
)
6771
return value
@@ -70,7 +74,7 @@ function finalize(rootScope: ImmerScope, value: any, path?: PatchPath) {
7074
if (state.scope_ !== rootScope) return value
7175
// Unmodified draft, return the (frozen) original
7276
if (!state.modified_) {
73-
maybeFreeze(rootScope, state.base_, true)
77+
maybeFreeze(rootScope, state.copy_ ?? state.base_, true);
7478
return state.base_
7579
}
7680
// Not finalized yet, let's do that now
@@ -90,7 +94,7 @@ function finalize(rootScope: ImmerScope, value: any, path?: PatchPath) {
9094
isSet = true
9195
}
9296
each(resultEach, (key, childValue) =>
93-
finalizeProperty(rootScope, state, result, key, childValue, path, isSet)
97+
finalizeProperty(rootScope, state, result, key, childValue, path, isSet, existingStateMap)
9498
)
9599
// everything inside is frozen, we can freeze here
96100
maybeFreeze(rootScope, result, false)
@@ -104,6 +108,7 @@ function finalize(rootScope: ImmerScope, value: any, path?: PatchPath) {
104108
)
105109
}
106110
}
111+
107112
return state.copy_
108113
}
109114

@@ -114,10 +119,19 @@ function finalizeProperty(
114119
prop: string | number,
115120
childValue: any,
116121
rootPath?: PatchPath,
117-
targetIsSet?: boolean
122+
targetIsSet?: boolean,
123+
existingStateMap?: WeakMap<Objectish, ImmerState>,
118124
) {
119125
if (process.env.NODE_ENV !== "production" && childValue === targetObject)
120126
die(5)
127+
128+
if (!isDraft(childValue) && isDraftable(childValue)) {
129+
const existingState = existingStateMap?.get(childValue)
130+
if (existingState) {
131+
childValue = existingState.draft_
132+
}
133+
}
134+
121135
if (isDraft(childValue)) {
122136
const path =
123137
rootPath &&
@@ -127,7 +141,7 @@ function finalizeProperty(
127141
? rootPath!.concat(prop)
128142
: undefined
129143
// Drafts owned by `scope` are finalized here.
130-
const res = finalize(rootScope, childValue, path)
144+
const res = finalize(rootScope, childValue, path, existingStateMap)
131145
set(targetObject, prop, res)
132146
// Drafts from another scope must prevented to be frozen
133147
// if we got a draft back from finalize, we're in a nested produce and shouldn't freeze
@@ -137,6 +151,7 @@ function finalizeProperty(
137151
} else if (targetIsSet) {
138152
targetObject.add(childValue)
139153
}
154+
140155
// Search new objects for unfinalized drafts. Frozen objects should never contain drafts.
141156
if (isDraftable(childValue) && !isFrozen(childValue)) {
142157
if (!rootScope.immer_.autoFreeze_ && rootScope.unfinalizedDrafts_ < 1) {
@@ -147,10 +162,10 @@ function finalizeProperty(
147162
// See add-data.js perf test
148163
return
149164
}
150-
finalize(rootScope, childValue)
165+
finalize(rootScope, childValue, undefined, existingStateMap)
151166
// immer deep freezes plain objects, so if there is no parent state, we freeze as well
152167
if (!parentState || !parentState.scope_.parent_)
153-
maybeFreeze(rootScope, childValue)
168+
maybeFreeze(rootScope, childValue, false);
154169
}
155170
}
156171

src/core/immerClass.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,19 @@ interface ProducersFns {
3434
export class Immer implements ProducersFns {
3535
autoFreeze_: boolean = true
3636
useStrictShallowCopy_: boolean = false
37+
allowMultiRefs_: boolean = false
3738

38-
constructor(config?: {autoFreeze?: boolean; useStrictShallowCopy?: boolean}) {
39+
constructor(config?: {
40+
autoFreeze?: boolean
41+
useStrictShallowCopy?: boolean
42+
allowMultiRefs: boolean
43+
}) {
3944
if (typeof config?.autoFreeze === "boolean")
4045
this.setAutoFreeze(config!.autoFreeze)
4146
if (typeof config?.useStrictShallowCopy === "boolean")
4247
this.setUseStrictShallowCopy(config!.useStrictShallowCopy)
48+
if (typeof config?.allowMultiRefs === "boolean")
49+
this.setAllowMultiRefs(config!.allowMultiRefs)
4350
}
4451

4552
/**
@@ -86,7 +93,8 @@ export class Immer implements ProducersFns {
8693
// Only plain objects, arrays, and "immerable classes" are drafted.
8794
if (isDraftable(base)) {
8895
const scope = enterScope(this)
89-
const proxy = createProxy(base, undefined)
96+
const stateMap = this.allowMultiRefs_ ? new Map() : undefined
97+
const proxy = createProxy(base, undefined, stateMap)
9098
let hasError = true
9199
try {
92100
result = recipe(proxy)
@@ -97,7 +105,7 @@ export class Immer implements ProducersFns {
97105
else leaveScope(scope)
98106
}
99107
usePatchesInScope(scope, patchListener)
100-
return processResult(result, scope)
108+
return processResult(result, scope, stateMap)
101109
} else if (!base || typeof base !== "object") {
102110
result = recipe(base)
103111
if (result === undefined) result = base
@@ -132,7 +140,7 @@ export class Immer implements ProducersFns {
132140
if (!isDraftable(base)) die(8)
133141
if (isDraft(base)) base = current(base)
134142
const scope = enterScope(this)
135-
const proxy = createProxy(base, undefined)
143+
const proxy = createProxy(base, undefined, this.allowMultiRefs_ ? new WeakMap() : undefined)
136144
proxy[DRAFT_STATE].isManual_ = true
137145
leaveScope(scope)
138146
return proxy as any
@@ -144,9 +152,11 @@ export class Immer implements ProducersFns {
144152
): D extends Draft<infer T> ? T : never {
145153
const state: ImmerState = draft && (draft as any)[DRAFT_STATE]
146154
if (!state || !state.isManual_) die(9)
147-
const {scope_: scope} = state
155+
156+
// @ts-ignore -- TODO: Remove this (add typings to state and map!)
157+
const {scope_: scope, existingStateMap_} = state
148158
usePatchesInScope(scope, patchListener)
149-
return processResult(undefined, scope)
159+
return processResult(undefined, scope, existingStateMap_) as any
150160
}
151161

152162
/**
@@ -167,6 +177,11 @@ export class Immer implements ProducersFns {
167177
this.useStrictShallowCopy_ = value
168178
}
169179

180+
/** Pass true to allow multiple references to the same object in the same state tree. */
181+
setAllowMultiRefs(value: boolean) {
182+
this.allowMultiRefs_ = value
183+
}
184+
170185
applyPatches<T extends Objectish>(base: T, patches: Patch[]): T {
171186
// If a patch replaces the entire state, take that replacement as base
172187
// before applying patches
@@ -198,16 +213,19 @@ export class Immer implements ProducersFns {
198213

199214
export function createProxy<T extends Objectish>(
200215
value: T,
201-
parent?: ImmerState
216+
parent?: ImmerState,
217+
stateMap?: WeakMap<Objectish, ImmerState>
202218
): Drafted<T, ImmerState> {
203219
// precondition: createProxy should be guarded by isDraftable, so we know we can safely draft
204220
const draft: Drafted = isMap(value)
205221
? getPlugin("MapSet").proxyMap_(value, parent)
206222
: isSet(value)
207223
? getPlugin("MapSet").proxySet_(value, parent)
208-
: createProxyProxy(value, parent)
224+
: createProxyProxy(value, parent, stateMap)
209225

210226
const scope = parent ? parent.scope_ : getCurrentScope()
227+
211228
scope.drafts_.push(draft)
229+
212230
return draft
213231
}

src/core/proxy.ts

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,15 @@ export interface ProxyObjectState extends ProxyBaseState {
3333
base_: any
3434
copy_: any
3535
draft_: Drafted<AnyObject, ProxyObjectState>
36+
stateMap_?: WeakMap<Objectish, ImmerState> | undefined
3637
}
3738

3839
export interface ProxyArrayState extends ProxyBaseState {
3940
type_: ArchType.Array
4041
base_: AnyArray
4142
copy_: AnyArray | null
4243
draft_: Drafted<AnyArray, ProxyArrayState>
44+
stateMap_?: WeakMap<Objectish, ImmerState> | undefined
4345
}
4446

4547
type ProxyState = ProxyObjectState | ProxyArrayState
@@ -51,8 +53,10 @@ type ProxyState = ProxyObjectState | ProxyArrayState
5153
*/
5254
export function createProxyProxy<T extends Objectish>(
5355
base: T,
54-
parent?: ImmerState
56+
parent?: ImmerState,
57+
stateMap?: WeakMap<Objectish, ImmerState>
5558
): Drafted<T, ProxyState> {
59+
5660
const isArray = Array.isArray(base)
5761
const state: ProxyState = {
5862
type_: isArray ? ArchType.Array : (ArchType.Object as any),
@@ -74,7 +78,8 @@ export function createProxyProxy<T extends Objectish>(
7478
copy_: null,
7579
// Called by the `produce` function.
7680
revoke_: null as any,
77-
isManual_: false
81+
isManual_: false,
82+
stateMap_: stateMap
7883
}
7984

8085
// the traps must target something, a bit like the 'real' base.
@@ -116,7 +121,11 @@ export const objectTraps: ProxyHandler<ProxyState> = {
116121
// Assigned values are never drafted. This catches any drafts we created, too.
117122
if (value === peek(state.base_, prop)) {
118123
prepareCopy(state)
119-
return (state.copy_![prop as any] = createProxy(value, state))
124+
return (state.copy_![prop as any] = createProxy(
125+
value,
126+
state,
127+
state.stateMap_
128+
))
120129
}
121130
return value
122131
},
@@ -278,15 +287,19 @@ export function markChanged(state: ImmerState) {
278287
}
279288
}
280289

281-
export function prepareCopy(state: {
282-
base_: any
283-
copy_: any
284-
scope_: ImmerScope
285-
}) {
286-
if (!state.copy_) {
287-
state.copy_ = shallowCopy(
288-
state.base_,
289-
state.scope_.immer_.useStrictShallowCopy_
290-
)
290+
export function prepareCopy(state: ImmerState) {
291+
if (state.copy_) return
292+
293+
const existing = state.stateMap_?.get(state.base_)
294+
if (existing) {
295+
Object.assign(state, existing)
296+
return
291297
}
298+
299+
state.copy_ = shallowCopy(
300+
state.base_,
301+
state.scope_.immer_.useStrictShallowCopy_
302+
)
303+
304+
state.stateMap_?.set(state.base_, state)
292305
}

src/plugins/mapset.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ export function enableMapSet() {
3434
base_: target,
3535
draft_: this as any,
3636
isManual_: false,
37-
revoked_: false
37+
revoked_: false,
38+
stateMap_: parent?.stateMap_ as any
3839
}
3940
}
4041

@@ -167,6 +168,7 @@ export function enableMapSet() {
167168
if (!state.copy_) {
168169
state.assigned_ = new Map()
169170
state.copy_ = new Map(state.base_)
171+
state.stateMap_?.set(state.base_, state)
170172
}
171173
}
172174

@@ -185,7 +187,8 @@ export function enableMapSet() {
185187
draft_: this,
186188
drafts_: new Map(),
187189
revoked_: false,
188-
isManual_: false
190+
isManual_: false,
191+
stateMap_: parent?.stateMap_ as any
189192
}
190193
}
191194

@@ -284,6 +287,7 @@ export function enableMapSet() {
284287
if (!state.copy_) {
285288
// create drafts for all entries to preserve insertion order
286289
state.copy_ = new Set()
290+
state.stateMap_?.set(state.base_, state)
287291
state.base_.forEach(value => {
288292
if (isDraftable(value)) {
289293
const draft = createProxy(value, state)

src/utils/plugins.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export interface MapState extends ImmerBaseState {
6060
base_: AnyMap
6161
revoked_: boolean
6262
draft_: Drafted<AnyMap, MapState>
63+
stateMap_?: WeakMap<AnyMap, ImmerState> | undefined
6364
}
6465

6566
export interface SetState extends ImmerBaseState {
@@ -69,6 +70,7 @@ export interface SetState extends ImmerBaseState {
6970
drafts_: Map<any, Drafted> // maps the original value to the draft value in the new set
7071
revoked_: boolean
7172
draft_: Drafted<AnySet, SetState>
73+
stateMap_?: WeakMap<AnySet, ImmerState> | undefined
7274
}
7375

7476
/** Patches plugin */

website/docs/pitfalls.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ Never reassign the `draft` argument (example: `draft = myCoolNewState`). Instead
1717

1818
### Immer only supports unidirectional trees
1919

20+
<!-- TODO: Discuss what to do in the docs regarding the multiple references PR -->
21+
2022
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.
2123

2224
### Never explicitly return `undefined` from a producer

0 commit comments

Comments
 (0)