Skip to content

Commit 6b1a811

Browse files
authored
fix(commons): correctly handle shared references in deepMerge (#5195)
1 parent 66ce41e commit 6b1a811

2 files changed

Lines changed: 283 additions & 67 deletions

File tree

packages/commons/src/deepMerge.ts

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -24,23 +24,24 @@ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
2424
const mergeArrayItemsByIndex = (
2525
targetArray: unknown[],
2626
sourceArray: unknown[],
27-
seen: WeakSet<object>
27+
ancestors: object[]
2828
): void => {
2929
for (let i = 0; i < sourceArray.length; i++) {
3030
const srcItem = sourceArray[i];
3131
const tgtItem = targetArray[i];
3232

3333
const isSrcPlainObject = isPlainObject(srcItem);
3434

35-
// Skip already-seen objects to prevent circular references
36-
if (isSrcPlainObject && seen.has(srcItem)) {
35+
// Skip objects in the current ancestor chain to prevent circular references
36+
if (isSrcPlainObject && ancestors.includes(srcItem)) {
3737
continue;
3838
}
3939

4040
// Merge nested plain objects recursively
4141
if (isSrcPlainObject && isPlainObject(tgtItem)) {
42-
seen.add(srcItem);
43-
mergeRecursive(tgtItem, srcItem, seen);
42+
ancestors.push(srcItem);
43+
mergeRecursive(tgtItem, srcItem, ancestors);
44+
ancestors.pop();
4445
continue;
4546
}
4647

@@ -61,14 +62,14 @@ const handleArrayMerge = (
6162
key: string,
6263
sourceArray: unknown[],
6364
targetValue: unknown,
64-
seen: WeakSet<object>
65+
ancestors: object[]
6566
): void => {
6667
if (!Array.isArray(targetValue)) {
6768
target[key] = [...sourceArray];
6869
return;
6970
}
7071

71-
mergeArrayItemsByIndex(targetValue, sourceArray, seen);
72+
mergeArrayItemsByIndex(targetValue, sourceArray, ancestors);
7273
};
7374

7475
/**
@@ -81,15 +82,15 @@ const handleObjectMerge = (
8182
key: string,
8283
sourceObject: Record<string, unknown>,
8384
targetValue: unknown,
84-
seen: WeakSet<object>
85+
ancestors: object[]
8586
): void => {
8687
if (isPlainObject(targetValue)) {
87-
mergeRecursive(targetValue, sourceObject, seen);
88+
mergeRecursive(targetValue, sourceObject, ancestors);
8889
return;
8990
}
9091

9192
const newTarget: Record<string, unknown> = {};
92-
mergeRecursive(newTarget, sourceObject, seen);
93+
mergeRecursive(newTarget, sourceObject, ancestors);
9394
target[key] = newTarget;
9495
};
9596

@@ -101,7 +102,7 @@ const handleObjectMerge = (
101102
const mergeRecursive = (
102103
target: Record<string, unknown>,
103104
source: Record<string, unknown>,
104-
seen: WeakSet<object>
105+
ancestors: object[]
105106
): void => {
106107
for (const key of Object.keys(source)) {
107108
if (UNSAFE_KEYS.has(key)) {
@@ -112,16 +113,18 @@ const mergeRecursive = (
112113
const targetValue = target[key];
113114

114115
if (Array.isArray(sourceValue)) {
115-
if (seen.has(sourceValue)) continue;
116-
seen.add(sourceValue);
117-
handleArrayMerge(target, key, sourceValue, targetValue, seen);
116+
if (ancestors.includes(sourceValue)) continue;
117+
ancestors.push(sourceValue);
118+
handleArrayMerge(target, key, sourceValue, targetValue, ancestors);
119+
ancestors.pop();
118120
continue;
119121
}
120122

121123
if (isPlainObject(sourceValue)) {
122-
if (seen.has(sourceValue)) continue;
123-
seen.add(sourceValue);
124-
handleObjectMerge(target, key, sourceValue, targetValue, seen);
124+
if (ancestors.includes(sourceValue)) continue;
125+
ancestors.push(sourceValue);
126+
handleObjectMerge(target, key, sourceValue, targetValue, ancestors);
127+
ancestors.pop();
125128
continue;
126129
}
127130

@@ -135,8 +138,9 @@ const mergeRecursive = (
135138
* Recursively merge properties from source objects into the target object, mutating it.
136139
*
137140
* Nested plain objects are merged recursively, arrays are merged by index (e.g., `[1, 2]` + `[3]` → `[3, 2]`),
138-
* and class instances (Date, RegExp, custom classes) are assigned by reference. Circular references and
139-
* prototype pollution attempts (`__proto__`, `constructor`) are safely skipped.
141+
* and class instances (Date, RegExp, custom classes) are assigned by reference. Circular references are
142+
* detected via ancestor-chain tracking and safely skipped, while shared (non-circular) object references
143+
* are merged correctly. Prototype pollution attempts (`__proto__`, `constructor`) are also skipped.
140144
*
141145
* @example
142146
* ```typescript
@@ -155,13 +159,13 @@ const deepMerge = <T extends Record<string, unknown>>(
155159
target: T,
156160
...sources: Array<Record<string, unknown> | undefined | null>
157161
): T => {
158-
const seen = new WeakSet<object>();
159-
seen.add(target);
162+
const ancestors: object[] = [target];
160163

161164
for (const source of sources) {
162165
if (source != null) {
163-
seen.add(source);
164-
mergeRecursive(target, source, seen);
166+
ancestors.push(source);
167+
mergeRecursive(target, source, ancestors);
168+
ancestors.pop();
165169
}
166170
}
167171

0 commit comments

Comments
 (0)