Skip to content

Commit e9d7457

Browse files
committed
fix(commons): use ancestor-chain tracking in deepMerge to correctly handle shared references
deepMerge used a WeakSet to detect circular references, which incorrectly skipped shared (non-circular) object references. Replace with an ancestor array (push/pop stack) so only true cycles are detected.
1 parent 097e440 commit e9d7457

2 files changed

Lines changed: 176 additions & 36 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

packages/commons/tests/unit/deepMerge.test.ts

Lines changed: 149 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ describe('Function: deepMerge', () => {
303303
expect((result.arr as unknown[])[1]).toBe(2);
304304
});
305305

306-
it('skips already-seen arrays to prevent duplication', () => {
306+
it('merges shared array references into all properties', () => {
307307
// Prepare
308308
const target = {};
309309
const sharedArray = [1, 2, 3];
@@ -315,16 +315,14 @@ describe('Function: deepMerge', () => {
315315
// Act
316316
const result = deepMerge(target, source);
317317

318-
// Assess - shared array is copied on first occurrence,
319-
// skipped on subsequent occurrences
318+
// Assess - shared (non-circular) array is merged into both properties
320319
expect(result).toEqual({
321320
first: [1, 2, 3],
322-
second: undefined,
321+
second: [1, 2, 3],
323322
});
324-
expect(result).not.toHaveProperty('second');
325323
});
326324

327-
it('skips already-seen objects within arrays', () => {
325+
it('merges shared objects within arrays', () => {
328326
// Prepare
329327
const sharedObj = { shared: true };
330328
const target = {
@@ -338,17 +336,85 @@ describe('Function: deepMerge', () => {
338336
// Act
339337
const result = deepMerge(target, source);
340338

341-
// Assess - sharedObj is merged into prop first, then skipped when
342-
// encountered again in the array merge
339+
// Assess - sharedObj is merged into both prop and arr[0]
343340
expect(result.prop).toEqual({ shared: true });
344-
expect((result.arr as Record<string, unknown>[])[0]).toEqual({ a: 1 });
341+
expect((result.arr as Record<string, unknown>[])[0]).toEqual({
342+
a: 1,
343+
shared: true,
344+
});
345+
expect((result.arr as Record<string, unknown>[])[1]).toEqual({
346+
b: 2,
347+
c: 3,
348+
});
349+
});
350+
351+
it('merges shared objects into all referencing properties', () => {
352+
// Prepare
353+
const target = {};
354+
const shared = { value: 42 };
355+
const source = {
356+
first: shared,
357+
second: { nested: shared },
358+
};
359+
360+
// Act
361+
const result = deepMerge(target, source);
362+
363+
// Assess - shared (non-circular) object appears in both locations
364+
expect(result).toEqual({
365+
first: { value: 42 },
366+
second: { nested: { value: 42 } },
367+
});
368+
});
369+
});
370+
371+
describe('Shared (non-circular) references', () => {
372+
it('correctly merges shared array references into both properties', () => {
373+
// Prepare
374+
const target = {};
375+
const sharedArray = [1, 2, 3];
376+
const source = {
377+
first: sharedArray,
378+
second: sharedArray,
379+
};
380+
381+
// Act
382+
const result = deepMerge(target, source);
383+
384+
// Assess - shared array should be merged into both properties
385+
expect(result).toEqual({
386+
first: [1, 2, 3],
387+
second: [1, 2, 3],
388+
});
389+
});
390+
391+
it('correctly merges shared objects referenced in arrays', () => {
392+
// Prepare
393+
const sharedObj = { shared: true };
394+
const target = {
395+
arr: [{ a: 1 }, { b: 2 }],
396+
};
397+
const source = {
398+
prop: sharedObj,
399+
arr: [sharedObj, { c: 3 }],
400+
};
401+
402+
// Act
403+
const result = deepMerge(target, source);
404+
405+
// Assess - sharedObj should be merged into both prop and arr[0]
406+
expect(result.prop).toEqual({ shared: true });
407+
expect((result.arr as Record<string, unknown>[])[0]).toEqual({
408+
a: 1,
409+
shared: true,
410+
});
345411
expect((result.arr as Record<string, unknown>[])[1]).toEqual({
346412
b: 2,
347413
c: 3,
348414
});
349415
});
350416

351-
it('skips already-seen objects to prevent duplication', () => {
417+
it('correctly merges shared objects into all referencing properties', () => {
352418
// Prepare
353419
const target = {};
354420
const shared = { value: 42 };
@@ -360,11 +426,81 @@ describe('Function: deepMerge', () => {
360426
// Act
361427
const result = deepMerge(target, source);
362428

363-
// Assess - shared object is merged on first occurrence,
364-
// skipped on subsequent occurrences to prevent infinite recursion
429+
// Assess - shared object should appear in both locations
365430
expect(result).toEqual({
366431
first: { value: 42 },
367-
second: {},
432+
second: { nested: { value: 42 } },
433+
});
434+
});
435+
436+
it('correctly merges shared objects across multiple sources', () => {
437+
// Prepare
438+
const shared = { x: 1 };
439+
const target = {};
440+
const source1 = { a: shared };
441+
const source2 = { b: shared };
442+
443+
// Act
444+
const result = deepMerge(target, source1, source2);
445+
446+
// Assess
447+
expect(result).toEqual({ a: { x: 1 }, b: { x: 1 } });
448+
});
449+
450+
it('correctly merges diamond-shaped shared references', () => {
451+
// Prepare
452+
const shared = { value: 'shared' };
453+
const target = {};
454+
const source = {
455+
branch1: { leaf: shared },
456+
branch2: { leaf: shared },
457+
};
458+
459+
// Act
460+
const result = deepMerge(target, source);
461+
462+
// Assess - same object at different depths in two branches
463+
expect(result).toEqual({
464+
branch1: { leaf: { value: 'shared' } },
465+
branch2: { leaf: { value: 'shared' } },
466+
});
467+
});
468+
469+
it('merges a shared object that itself contains a circular reference', () => {
470+
// Prepare
471+
const shared: Record<string, unknown> = { value: 1 };
472+
shared.self = shared;
473+
const target = {};
474+
const source = {
475+
first: shared,
476+
second: { nested: shared },
477+
};
478+
479+
// Act
480+
const result = deepMerge(target, source);
481+
482+
// Assess - shared object is merged in both locations,
483+
// but the circular self-reference within it is skipped
484+
expect(result).toEqual({
485+
first: { value: 1 },
486+
second: { nested: { value: 1 } },
487+
});
488+
});
489+
490+
it('merges when source references an object from the target', () => {
491+
// Prepare
492+
const inner = { x: 1 };
493+
const target: Record<string, unknown> = { a: inner };
494+
const source = { b: inner };
495+
496+
// Act
497+
const result = deepMerge(target, source);
498+
499+
// Assess - inner is not in the ancestor chain when processing source,
500+
// so it should be merged into both properties
501+
expect(result).toEqual({
502+
a: { x: 1 },
503+
b: { x: 1 },
368504
});
369505
});
370506
});

0 commit comments

Comments
 (0)