@@ -24,23 +24,24 @@ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
2424const 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 = (
101102const 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