Skip to content

Commit 28d61f4

Browse files
committed
Fix: Prevent stack overflow with Jest matchers and complex objects
Fixes #45
1 parent c8c783d commit 28d61f4

4 files changed

Lines changed: 78 additions & 14 deletions

File tree

index.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ export interface Options {
5353
Recurse nested objects and objects in arrays.
5454
5555
@default false
56+
57+
Built-in objects like `RegExp`, `Error`, `Date`, and `Blob` are not recursed into. Special objects like Jest matchers are also automatically excluded.
5658
*/
5759
readonly deep?: boolean;
5860

index.js

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,33 +6,34 @@ const isObjectCustom = value =>
66
&& !(value instanceof RegExp)
77
&& !(value instanceof Error)
88
&& !(value instanceof Date)
9-
&& !(globalThis.Blob && value instanceof globalThis.Blob);
9+
&& !(globalThis.Blob && value instanceof globalThis.Blob)
10+
&& typeof value.$$typeof !== 'symbol' // Jest asymmetric matchers
11+
&& typeof value.asymmetricMatch !== 'function'; // Jest matchers
1012

1113
export const mapObjectSkip = Symbol('mapObjectSkip');
1214

1315
const _mapObject = (object, mapper, options, isSeen = new WeakMap()) => {
14-
options = {
16+
const {
17+
target = {},
18+
...processOptions
19+
} = {
1520
deep: false,
16-
target: {},
1721
...options,
1822
};
1923

2024
if (isSeen.has(object)) {
2125
return isSeen.get(object);
2226
}
2327

24-
isSeen.set(object, options.target);
28+
isSeen.set(object, target);
2529

26-
const {target} = options;
27-
delete options.target;
28-
29-
const mapArray = array => array.map(element => isObjectCustom(element) ? _mapObject(element, mapper, options, isSeen) : element);
30+
const mapArray = array => array.map(element => isObjectCustom(element) ? _mapObject(element, mapper, processOptions, isSeen) : element);
3031
if (Array.isArray(object)) {
3132
return mapArray(object);
3233
}
3334

3435
for (const [key, value] of Object.entries(object)) {
35-
const mapResult = mapper(key, value, object);
36+
const mapResult = mapper(key, value);
3637

3738
if (mapResult === mapObjectSkip) {
3839
continue;
@@ -45,10 +46,10 @@ const _mapObject = (object, mapper, options, isSeen = new WeakMap()) => {
4546
continue;
4647
}
4748

48-
if (options.deep && shouldRecurse && isObjectCustom(newValue)) {
49+
if (processOptions.deep && shouldRecurse && isObjectCustom(newValue)) {
4950
newValue = Array.isArray(newValue)
5051
? mapArray(newValue)
51-
: _mapObject(newValue, mapper, options, isSeen);
52+
: _mapObject(newValue, mapper, processOptions, isSeen);
5253
}
5354

5455
target[newKey] = newValue;
@@ -67,7 +68,7 @@ export default function mapObject(object, mapper, options) {
6768
}
6869

6970
// Ensure the third mapper argument is always the original input object
70-
const root = object;
71-
const mapperWithRoot = (key, value) => mapper(key, value, root);
71+
const mapperWithRoot = (key, value) => mapper(key, value, object);
72+
7273
return _mapObject(object, mapperWithRoot, options);
7374
}

readme.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ Type: `(sourceKey, sourceValue, source) => [targetKey, targetValue, mapperOption
4747
A mapping function.
4848

4949
> [!NOTE]
50-
> When `options.deep` is `true`, the mapper can receive keys and values from nested objects and arrays. In that case, the mapper parameters are intentionally widened: `sourceKey` is typed as `string` and `sourceValue` as `unknown`, reflecting the actual runtime behavior when recursing into unknown shapes. The third argument `source` is always the original input object, not the current nested owner.
50+
> When `options.deep` is `true`, the mapper receives keys and values from nested objects and arrays. The `sourceKey` parameter is typed as `string` and `sourceValue` as `unknown` to reflect the actual runtime behavior when recursing into unknown shapes. The third argument `source` is always the original input object, not the current nested owner.
5151
5252
##### mapperOptions
5353

@@ -73,6 +73,8 @@ Default: `false`
7373

7474
Recurse nested objects and objects in arrays.
7575

76+
Built-in objects like `RegExp`, `Error`, `Date`, and `Blob` are not recursed into. Special objects like Jest matchers are also automatically excluded.
77+
7678
##### target
7779

7880
Type: `object`\

test.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,3 +215,62 @@ test('remove keys (#36)', t => {
215215
const actual = mapObject(object, mapper, {deep: true});
216216
t.deepEqual(actual, expected);
217217
});
218+
219+
test('should not recurse into Jest-like matchers', t => {
220+
// Mock a Jest asymmetric matcher like expect.anything()
221+
const jestMatcher = {
222+
$$typeof: Symbol.for('jest.asymmetricMatcher'),
223+
asymmetricMatch: () => true,
224+
toString: () => 'expect.anything()',
225+
};
226+
227+
const input = {
228+
normal: {nested: 'value'},
229+
matcher: jestMatcher,
230+
};
231+
232+
let calls = 0;
233+
const result = mapObject(input, (key, value) => {
234+
calls++;
235+
// Should not recurse into jestMatcher properties
236+
t.not(key, '$$typeof');
237+
t.not(key, 'asymmetricMatch');
238+
t.not(key, 'toString');
239+
return [key, value];
240+
}, {deep: true});
241+
242+
t.is(result.matcher, jestMatcher);
243+
t.is(result.normal.nested, 'value');
244+
t.is(calls, 3); // 'normal', 'nested', 'matcher'
245+
});
246+
247+
test('options object is not mutated', t => {
248+
const options = {deep: true, target: {}};
249+
const originalOptions = {...options};
250+
251+
mapObject({a: 1}, (key, value) => [key, value], options);
252+
253+
t.deepEqual(options, originalOptions);
254+
});
255+
256+
test('built-in objects are not recursed into', t => {
257+
const date = new Date();
258+
const regex = /test/;
259+
const error = new Error('test');
260+
261+
const input = {
262+
date,
263+
regex,
264+
error,
265+
normal: {nested: 'value'},
266+
};
267+
268+
const calls = [];
269+
mapObject(input, (key, value) => {
270+
calls.push(key);
271+
return [key, value];
272+
}, {deep: true});
273+
274+
// Should visit top-level keys and nested normal object
275+
t.deepEqual(calls.sort(), ['date', 'error', 'nested', 'normal', 'regex']);
276+
});

0 commit comments

Comments
 (0)