Skip to content

Commit 1f7bf0a

Browse files
committed
fix: handling circular references properly in getObjectSubset (#8663)
1 parent 2c6eb48 commit 1f7bf0a

3 files changed

Lines changed: 112 additions & 16 deletions

File tree

packages/expect/src/__tests__/__snapshots__/matchers.test.js.snap

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4092,7 +4092,18 @@ Expected: not <green>Set {2, 1}</>
40924092
Received: <red>Set {1, 2}</>"
40934093
`;
40944094

4095-
exports[`toMatchObject() circular references simple circular references {pass: false} expect({"a": "hello", "ref": [Circular]}).toMatchObject({"a": "world", "ref": [Circular]}) 1`] = `"Maximum call stack size exceeded"`;
4095+
exports[`toMatchObject() circular references simple circular references {pass: false} expect({"a": "hello", "ref": [Circular]}).toMatchObject({"a": "world", "ref": [Circular]}) 1`] = `
4096+
"<dim>expect(</><red>received</><dim>).</>toMatchObject<dim>(</><green>expected</><dim>)</>
4097+
4098+
<green>- Expected</>
4099+
<red>+ Received</>
4100+
4101+
<dim> Object {</>
4102+
<green>- \\"a\\": \\"world\\",</>
4103+
<red>+ \\"a\\": \\"hello\\",</>
4104+
<dim> \\"ref\\": [Circular],</>
4105+
<dim> }</>"
4106+
`;
40964107

40974108
exports[`toMatchObject() circular references simple circular references {pass: false} expect({"ref": "not a ref"}).toMatchObject({"a": "hello", "ref": [Circular]}) 1`] = `
40984109
"<dim>expect(</><red>received</><dim>).</>toMatchObject<dim>(</><green>expected</><dim>)</>
@@ -4133,7 +4144,20 @@ Expected: not <green>{}</>
41334144
Received: <red>{\\"a\\": \\"hello\\", \\"ref\\": [Circular]}</>"
41344145
`;
41354146

4136-
exports[`toMatchObject() circular references transitive circular references {pass: false} expect({"a": "world", "nestedObj": {"parentObj": [Circular]}}).toMatchObject({"a": "hello", "nestedObj": {"parentObj": [Circular]}}) 1`] = `"Maximum call stack size exceeded"`;
4147+
exports[`toMatchObject() circular references transitive circular references {pass: false} expect({"a": "world", "nestedObj": {"parentObj": [Circular]}}).toMatchObject({"a": "hello", "nestedObj": {"parentObj": [Circular]}}) 1`] = `
4148+
"<dim>expect(</><red>received</><dim>).</>toMatchObject<dim>(</><green>expected</><dim>)</>
4149+
4150+
<green>- Expected</>
4151+
<red>+ Received</>
4152+
4153+
<dim> Object {</>
4154+
<green>- \\"a\\": \\"hello\\",</>
4155+
<red>+ \\"a\\": \\"world\\",</>
4156+
<dim> \\"nestedObj\\": Object {</>
4157+
<dim> \\"parentObj\\": [Circular],</>
4158+
<dim> },</>
4159+
<dim> }</>"
4160+
`;
41374161

41384162
exports[`toMatchObject() circular references transitive circular references {pass: false} expect({"nestedObj": {"parentObj": "not the parent ref"}}).toMatchObject({"a": "hello", "nestedObj": {"parentObj": [Circular]}}) 1`] = `
41394163
"<dim>expect(</><red>received</><dim>).</>toMatchObject<dim>(</><green>expected</><dim>)</>

packages/expect/src/__tests__/utils.test.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,74 @@ describe('getObjectSubset()', () => {
164164
},
165165
);
166166
});
167+
168+
describe('calculating subsets of objects with circular references', () => {
169+
test('simple circular references', () => {
170+
const nonCircularObj = {a: 'world', b: 'something'};
171+
172+
const circularObjA = {a: 'hello'};
173+
circularObjA.ref = circularObjA;
174+
175+
const circularObjB = {a: 'world'};
176+
circularObjB.ref = circularObjB;
177+
178+
const primitiveInsteadOfRef = {b: 'something'};
179+
primitiveInsteadOfRef.ref = 'not a ref';
180+
181+
const nonCircularRef = {b: 'something'};
182+
nonCircularRef.ref = {};
183+
184+
expect(getObjectSubset(circularObjA, nonCircularObj)).toEqual({
185+
a: 'hello',
186+
});
187+
expect(getObjectSubset(nonCircularObj, circularObjA)).toEqual({
188+
a: 'world',
189+
});
190+
191+
expect(getObjectSubset(circularObjB, circularObjA)).toEqual(circularObjB);
192+
193+
expect(getObjectSubset(primitiveInsteadOfRef, circularObjA)).toEqual({
194+
ref: 'not a ref',
195+
});
196+
expect(getObjectSubset(nonCircularRef, circularObjA)).toEqual({
197+
ref: {},
198+
});
199+
});
200+
201+
test('transitive circular references', () => {
202+
const nonCircularObj = {a: 'world', b: 'something'};
203+
204+
const transitiveCircularObjA = {a: 'hello'};
205+
transitiveCircularObjA.nestedObj = {parentObj: transitiveCircularObjA};
206+
207+
const transitiveCircularObjB = {a: 'world'};
208+
transitiveCircularObjB.nestedObj = {parentObj: transitiveCircularObjB};
209+
210+
const primitiveInsteadOfRef = {};
211+
primitiveInsteadOfRef.nestedObj = {otherProp: 'not the parent ref'};
212+
213+
const nonCircularRef = {};
214+
nonCircularRef.nestedObj = {otherProp: {}};
215+
216+
expect(getObjectSubset(transitiveCircularObjA, nonCircularObj)).toEqual({
217+
a: 'hello',
218+
});
219+
expect(getObjectSubset(nonCircularObj, transitiveCircularObjA)).toEqual({
220+
a: 'world',
221+
});
222+
223+
expect(
224+
getObjectSubset(transitiveCircularObjB, transitiveCircularObjA),
225+
).toEqual(transitiveCircularObjB);
226+
227+
expect(
228+
getObjectSubset(primitiveInsteadOfRef, transitiveCircularObjA),
229+
).toEqual({nestedObj: {otherProp: 'not the parent ref'}});
230+
expect(getObjectSubset(nonCircularRef, transitiveCircularObjA)).toEqual({
231+
nestedObj: {otherProp: {}},
232+
});
233+
});
234+
});
167235
});
168236

169237
describe('emptyObject()', () => {

packages/expect/src/utils.ts

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,11 @@ export const getPath = (
104104

105105
// Strip properties from object that are not present in the subset. Useful for
106106
// printing the diff for toMatchObject() without adding unrelated noise.
107-
export const getObjectSubset = (object: any, subset: any): any => {
107+
export const getObjectSubset = (
108+
object: any,
109+
subset: any,
110+
seenReferences: WeakMap<object, boolean> = new WeakMap(),
111+
): any => {
108112
if (Array.isArray(object)) {
109113
if (Array.isArray(subset) && subset.length === object.length) {
110114
return subset.map((sub: any, i: number) =>
@@ -113,18 +117,17 @@ export const getObjectSubset = (object: any, subset: any): any => {
113117
}
114118
} else if (object instanceof Date) {
115119
return object;
116-
} else if (
117-
typeof object === 'object' &&
118-
object !== null &&
119-
typeof subset === 'object' &&
120-
subset !== null
121-
) {
120+
} else if (isObject(object) && isObject(subset)) {
122121
const trimmed: any = {};
123-
Object.keys(subset)
124-
.filter(key => hasOwnProperty(object, key))
125-
.forEach(
126-
key => (trimmed[key] = getObjectSubset(object[key], subset[key])),
127-
);
122+
seenReferences.set(object, trimmed);
123+
124+
Object.keys(object)
125+
.filter(key => hasOwnProperty(subset, key))
126+
.forEach(key => {
127+
trimmed[key] = seenReferences.has(object[key])
128+
? seenReferences.get(object[key])
129+
: getObjectSubset(object[key], subset[key], seenReferences);
130+
});
128131

129132
if (Object.keys(trimmed).length > 0) {
130133
return trimmed;
@@ -257,9 +260,10 @@ export const iterableEquality = (
257260
return true;
258261
};
259262

263+
const isObject = (a: any) => a !== null && typeof a === 'object';
264+
260265
const isObjectWithKeys = (a: any) =>
261-
a !== null &&
262-
typeof a === 'object' &&
266+
isObject(a) &&
263267
!(a instanceof Error) &&
264268
!(a instanceof Array) &&
265269
!(a instanceof Date);

0 commit comments

Comments
 (0)