Skip to content

Commit eebb9e2

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 eebb9e2

File tree

6 files changed

+98
-37
lines changed

6 files changed

+98
-37
lines changed

src/core/finalize.ts

Lines changed: 36 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>, existingFinalizationMap?: WeakMap<Objectish, Drafted>) {
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, existingFinalizationMap)
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, existingFinalizationMap)
4649
}
4750
revokeScope(scope)
4851
if (scope.patches_) {
@@ -51,17 +54,19 @@ 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>, existingFinalizationMap?: WeakMap<Objectish, Drafted>): 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) {
65+
existingFinalizationMap?.set(value, value)
6166
each(
6267
value,
6368
(key, childValue) =>
64-
finalizeProperty(rootScope, state, value, key, childValue, path),
69+
finalizeProperty(rootScope, state, value, key, childValue, path, undefined, existingStateMap, existingFinalizationMap),
6570
true // See #590, don't recurse into non-enumerable of non drafted objects
6671
)
6772
return value
@@ -70,11 +75,13 @@ function finalize(rootScope: ImmerScope, value: any, path?: PatchPath) {
7075
if (state.scope_ !== rootScope) return value
7176
// Unmodified draft, return the (frozen) original
7277
if (!state.modified_) {
73-
maybeFreeze(rootScope, state.base_, true)
78+
maybeFreeze(rootScope, state.copy_ ?? state.base_, true);
7479
return state.base_
7580
}
7681
// Not finalized yet, let's do that now
7782
if (!state.finalized_) {
83+
existingFinalizationMap?.set(state.base_, state.copy_)
84+
7885
state.finalized_ = true
7986
state.scope_.unfinalizedDrafts_--
8087
const result = state.copy_
@@ -90,7 +97,7 @@ function finalize(rootScope: ImmerScope, value: any, path?: PatchPath) {
9097
isSet = true
9198
}
9299
each(resultEach, (key, childValue) =>
93-
finalizeProperty(rootScope, state, result, key, childValue, path, isSet)
100+
finalizeProperty(rootScope, state, result, key, childValue, path, isSet, existingStateMap, existingFinalizationMap)
94101
)
95102
// everything inside is frozen, we can freeze here
96103
maybeFreeze(rootScope, result, false)
@@ -104,6 +111,7 @@ function finalize(rootScope: ImmerScope, value: any, path?: PatchPath) {
104111
)
105112
}
106113
}
114+
107115
return state.copy_
108116
}
109117

@@ -114,10 +122,23 @@ function finalizeProperty(
114122
prop: string | number,
115123
childValue: any,
116124
rootPath?: PatchPath,
117-
targetIsSet?: boolean
125+
targetIsSet?: boolean,
126+
existingStateMap?: WeakMap<Objectish, ImmerState>,
127+
existingFinalizationMap?: WeakMap<Objectish, Drafted>,
118128
) {
119129
if (process.env.NODE_ENV !== "production" && childValue === targetObject)
120130
die(5)
131+
132+
if (!isDraft(childValue) && isDraftable(childValue)) {
133+
const existingState = existingStateMap?.get(childValue)
134+
if (existingState) {
135+
childValue = existingState.draft_
136+
} else {
137+
const existingFinalization = existingFinalizationMap?.get(childValue)
138+
if (existingFinalization) return set(targetObject, prop, existingFinalization)
139+
}
140+
}
141+
121142
if (isDraft(childValue)) {
122143
const path =
123144
rootPath &&
@@ -127,7 +148,7 @@ function finalizeProperty(
127148
? rootPath!.concat(prop)
128149
: undefined
129150
// Drafts owned by `scope` are finalized here.
130-
const res = finalize(rootScope, childValue, path)
151+
const res = finalize(rootScope, childValue, path, existingStateMap, existingFinalizationMap)
131152
set(targetObject, prop, res)
132153
// Drafts from another scope must prevented to be frozen
133154
// if we got a draft back from finalize, we're in a nested produce and shouldn't freeze
@@ -137,6 +158,7 @@ function finalizeProperty(
137158
} else if (targetIsSet) {
138159
targetObject.add(childValue)
139160
}
161+
140162
// Search new objects for unfinalized drafts. Frozen objects should never contain drafts.
141163
if (isDraftable(childValue) && !isFrozen(childValue)) {
142164
if (!rootScope.immer_.autoFreeze_ && rootScope.unfinalizedDrafts_ < 1) {
@@ -147,10 +169,10 @@ function finalizeProperty(
147169
// See add-data.js perf test
148170
return
149171
}
150-
finalize(rootScope, childValue)
172+
finalize(rootScope, childValue, undefined, existingStateMap, existingFinalizationMap)
151173
// immer deep freezes plain objects, so if there is no parent state, we freeze as well
152174
if (!parentState || !parentState.scope_.parent_)
153-
maybeFreeze(rootScope, childValue)
175+
maybeFreeze(rootScope, childValue, false);
154176
}
155177
}
156178

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, this.allowMultiRefs_ ? new WeakMap() : undefined)
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_, this.allowMultiRefs_ ? new WeakMap() : undefined) 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)