Skip to content

Commit 15a6555

Browse files
committed
fix: handle circular references correctly in objects (closes #8663)
1 parent b76f01b commit 15a6555

5 files changed

Lines changed: 260 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
- `[jest-core]` Fix incorrect `passWithNoTests` warning ([#8595](https://github.com/facebook/jest/pull/8595))
2525
- `[jest-snapshots]` Fix test retries that contain snapshots ([#8629](https://github.com/facebook/jest/pull/8629))
2626
- `[jest-mock]` Fix incorrect assignments when restoring mocks in instances where they originally didn't exist ([#8631](https://github.com/facebook/jest/pull/8631))
27+
- `[expect]` Fix stack overflow when matching objects with circular references ([#8687](https://github.com/facebook/jest/pull/8687))
2728

2829
### Chore & Maintenance
2930

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

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4092,6 +4092,92 @@ 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"`;
4096+
4097+
exports[`toMatchObject() circular references simple circular references {pass: false} expect({"ref": "not a ref"}).toMatchObject({"a": "hello", "ref": [Circular]}) 1`] = `
4098+
"<dim>expect(</><red>received</><dim>).</>toMatchObject<dim>(</><green>expected</><dim>)</>
4099+
4100+
<green>- Expected</>
4101+
<red>+ Received</>
4102+
4103+
<dim> Object {</>
4104+
<green>- \\"a\\": \\"hello\\",</>
4105+
<green>- \\"ref\\": [Circular],</>
4106+
<red>+ \\"ref\\": \\"not a ref\\",</>
4107+
<dim> }</>"
4108+
`;
4109+
4110+
exports[`toMatchObject() circular references simple circular references {pass: false} expect({}).toMatchObject({"a": "hello", "ref": [Circular]}) 1`] = `
4111+
"<dim>expect(</><red>received</><dim>).</>toMatchObject<dim>(</><green>expected</><dim>)</>
4112+
4113+
<green>- Expected</>
4114+
<red>+ Received</>
4115+
4116+
<green>- Object {</>
4117+
<green>- \\"a\\": \\"hello\\",</>
4118+
<green>- \\"ref\\": [Circular],</>
4119+
<green>- }</>
4120+
<red>+ Object {}</>"
4121+
`;
4122+
4123+
exports[`toMatchObject() circular references simple circular references {pass: true} expect({"a": "hello", "ref": [Circular]}).toMatchObject({"a": "hello", "ref": [Circular]}) 1`] = `
4124+
"<dim>expect(</><red>received</><dim>).</>not<dim>.</>toMatchObject<dim>(</><green>expected</><dim>)</>
4125+
4126+
Expected: not <green>{\\"a\\": \\"hello\\", \\"ref\\": [Circular]}</>"
4127+
`;
4128+
4129+
exports[`toMatchObject() circular references simple circular references {pass: true} expect({"a": "hello", "ref": [Circular]}).toMatchObject({}) 1`] = `
4130+
"<dim>expect(</><red>received</><dim>).</>not<dim>.</>toMatchObject<dim>(</><green>expected</><dim>)</>
4131+
4132+
Expected: not <green>{}</>
4133+
Received: <red>{\\"a\\": \\"hello\\", \\"ref\\": [Circular]}</>"
4134+
`;
4135+
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"`;
4137+
4138+
exports[`toMatchObject() circular references transitive circular references {pass: false} expect({"nestedObj": {"parentObj": "not the parent ref"}}).toMatchObject({"a": "hello", "nestedObj": {"parentObj": [Circular]}}) 1`] = `
4139+
"<dim>expect(</><red>received</><dim>).</>toMatchObject<dim>(</><green>expected</><dim>)</>
4140+
4141+
<green>- Expected</>
4142+
<red>+ Received</>
4143+
4144+
<dim> Object {</>
4145+
<green>- \\"a\\": \\"hello\\",</>
4146+
<dim> \\"nestedObj\\": Object {</>
4147+
<green>- \\"parentObj\\": [Circular],</>
4148+
<red>+ \\"parentObj\\": \\"not the parent ref\\",</>
4149+
<dim> },</>
4150+
<dim> }</>"
4151+
`;
4152+
4153+
exports[`toMatchObject() circular references transitive circular references {pass: false} expect({}).toMatchObject({"a": "hello", "nestedObj": {"parentObj": [Circular]}}) 1`] = `
4154+
"<dim>expect(</><red>received</><dim>).</>toMatchObject<dim>(</><green>expected</><dim>)</>
4155+
4156+
<green>- Expected</>
4157+
<red>+ Received</>
4158+
4159+
<green>- Object {</>
4160+
<green>- \\"a\\": \\"hello\\",</>
4161+
<green>- \\"nestedObj\\": Object {</>
4162+
<green>- \\"parentObj\\": [Circular],</>
4163+
<green>- },</>
4164+
<green>- }</>
4165+
<red>+ Object {}</>"
4166+
`;
4167+
4168+
exports[`toMatchObject() circular references transitive circular references {pass: true} expect({"a": "hello", "nestedObj": {"parentObj": [Circular]}}).toMatchObject({"a": "hello", "nestedObj": {"parentObj": [Circular]}}) 1`] = `
4169+
"<dim>expect(</><red>received</><dim>).</>not<dim>.</>toMatchObject<dim>(</><green>expected</><dim>)</>
4170+
4171+
Expected: not <green>{\\"a\\": \\"hello\\", \\"nestedObj\\": {\\"parentObj\\": [Circular]}}</>"
4172+
`;
4173+
4174+
exports[`toMatchObject() circular references transitive circular references {pass: true} expect({"a": "hello", "nestedObj": {"parentObj": [Circular]}}).toMatchObject({}) 1`] = `
4175+
"<dim>expect(</><red>received</><dim>).</>not<dim>.</>toMatchObject<dim>(</><green>expected</><dim>)</>
4176+
4177+
Expected: not <green>{}</>
4178+
Received: <red>{\\"a\\": \\"hello\\", \\"nestedObj\\": {\\"parentObj\\": [Circular]}}</>"
4179+
`;
4180+
40954181
exports[`toMatchObject() throws expect("44").toMatchObject({}) 1`] = `
40964182
"<dim>expect(</><red>received</><dim>).</>toMatchObject<dim>(</><green>expected</><dim>)</>
40974183

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

Lines changed: 88 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1537,7 +1537,91 @@ describe('toMatchObject()', () => {
15371537
}
15381538
}
15391539

1540-
[
1540+
const testNotToMatchSnapshots = tuples => {
1541+
tuples.forEach(([n1, n2]) => {
1542+
it(`{pass: true} expect(${stringify(n1)}).toMatchObject(${stringify(
1543+
n2,
1544+
)})`, () => {
1545+
jestExpect(n1).toMatchObject(n2);
1546+
expect(() =>
1547+
jestExpect(n1).not.toMatchObject(n2),
1548+
).toThrowErrorMatchingSnapshot();
1549+
});
1550+
});
1551+
};
1552+
1553+
const testToMatchSnapshots = tuples => {
1554+
tuples.forEach(([n1, n2]) => {
1555+
it(`{pass: false} expect(${stringify(n1)}).toMatchObject(${stringify(
1556+
n2,
1557+
)})`, () => {
1558+
jestExpect(n1).not.toMatchObject(n2);
1559+
expect(() =>
1560+
jestExpect(n1).toMatchObject(n2),
1561+
).toThrowErrorMatchingSnapshot();
1562+
});
1563+
});
1564+
};
1565+
1566+
describe('circular references', () => {
1567+
describe('simple circular references', () => {
1568+
const circularObj = {a: 'hello'};
1569+
circularObj.ref = circularObj;
1570+
1571+
const differentCircularObj = {a: 'world'};
1572+
differentCircularObj.ref = differentCircularObj;
1573+
1574+
const otherCircularObj = {a: 'hello'};
1575+
otherCircularObj.ref = otherCircularObj;
1576+
1577+
const primitiveInsteadOfRef = {};
1578+
primitiveInsteadOfRef.ref = 'not a ref';
1579+
1580+
testNotToMatchSnapshots([
1581+
[circularObj, {}],
1582+
[otherCircularObj, circularObj],
1583+
]);
1584+
1585+
testToMatchSnapshots([
1586+
[{}, circularObj],
1587+
[circularObj, differentCircularObj],
1588+
[primitiveInsteadOfRef, circularObj],
1589+
]);
1590+
});
1591+
1592+
describe('transitive circular references', () => {
1593+
const transitiveCircularObj = {a: 'hello'};
1594+
transitiveCircularObj.nestedObj = {parentObj: transitiveCircularObj};
1595+
1596+
const otherTransitiveCircularObj = {a: 'hello'};
1597+
otherTransitiveCircularObj.nestedObj = {
1598+
parentObj: otherTransitiveCircularObj,
1599+
};
1600+
1601+
const differentTransitiveCircularObj = {a: 'world'};
1602+
differentTransitiveCircularObj.nestedObj = {
1603+
parentObj: differentTransitiveCircularObj,
1604+
};
1605+
1606+
const primitiveInsteadOfRef = {};
1607+
primitiveInsteadOfRef.nestedObj = {
1608+
parentObj: 'not the parent ref',
1609+
};
1610+
1611+
testNotToMatchSnapshots([
1612+
[transitiveCircularObj, {}],
1613+
[otherTransitiveCircularObj, transitiveCircularObj],
1614+
]);
1615+
1616+
testToMatchSnapshots([
1617+
[{}, transitiveCircularObj],
1618+
[differentTransitiveCircularObj, transitiveCircularObj],
1619+
[primitiveInsteadOfRef, transitiveCircularObj],
1620+
]);
1621+
});
1622+
});
1623+
1624+
testNotToMatchSnapshots([
15411625
[{a: 'b', c: 'd'}, {a: 'b'}],
15421626
[{a: 'b', c: 'd'}, {a: 'b', c: 'd'}],
15431627
[{a: 'b', t: {x: {r: 'r'}, z: 'z'}}, {a: 'b', t: {z: 'z'}}],
@@ -1560,18 +1644,9 @@ describe('toMatchObject()', () => {
15601644
[new Error('bar'), {message: 'bar'}],
15611645
[new Foo(), {a: undefined, b: 'b'}],
15621646
[Object.assign(Object.create(null), {a: 'b'}), {a: 'b'}],
1563-
].forEach(([n1, n2]) => {
1564-
it(`{pass: true} expect(${stringify(n1)}).toMatchObject(${stringify(
1565-
n2,
1566-
)})`, () => {
1567-
jestExpect(n1).toMatchObject(n2);
1568-
expect(() =>
1569-
jestExpect(n1).not.toMatchObject(n2),
1570-
).toThrowErrorMatchingSnapshot();
1571-
});
1572-
});
1647+
]);
15731648

1574-
[
1649+
testToMatchSnapshots([
15751650
[{a: 'b', c: 'd'}, {e: 'b'}],
15761651
[{a: 'b', c: 'd'}, {a: 'b!', c: 'd'}],
15771652
[{a: 'a', c: 'd'}, {a: jestExpect.any(Number)}],
@@ -1597,16 +1672,7 @@ describe('toMatchObject()', () => {
15971672
[[1, 2, 3], [1, 2, 2]],
15981673
[new Error('foo'), new Error('bar')],
15991674
[Object.assign(Object.create(null), {a: 'b'}), {c: 'd'}],
1600-
].forEach(([n1, n2]) => {
1601-
it(`{pass: false} expect(${stringify(n1)}).toMatchObject(${stringify(
1602-
n2,
1603-
)})`, () => {
1604-
jestExpect(n1).not.toMatchObject(n2);
1605-
expect(() =>
1606-
jestExpect(n1).toMatchObject(n2),
1607-
).toThrowErrorMatchingSnapshot();
1608-
});
1609-
});
1675+
]);
16101676

16111677
[
16121678
[null, {}],

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,60 @@ describe('subsetEquality()', () => {
202202
test('undefined does not return errors', () => {
203203
expect(subsetEquality(undefined, {foo: 'bar'})).not.toBeTruthy();
204204
});
205+
206+
describe('matching subsets with circular references', () => {
207+
test('simple circular references', () => {
208+
const circularObj = {a: 'hello'};
209+
circularObj.ref = circularObj;
210+
211+
const otherCircularObj = {a: 'hello'};
212+
otherCircularObj.ref = otherCircularObj;
213+
214+
const differentCircularObj = {a: 'world'};
215+
differentCircularObj.ref = differentCircularObj;
216+
217+
const primitiveInsteadOfRef = {};
218+
primitiveInsteadOfRef.ref = 'not a ref';
219+
220+
expect(subsetEquality(circularObj, {})).toBe(true);
221+
expect(subsetEquality({}, circularObj)).toBe(false);
222+
expect(subsetEquality(otherCircularObj, circularObj)).toBe(true);
223+
expect(subsetEquality(differentCircularObj, circularObj)).toBe(false);
224+
expect(subsetEquality(primitiveInsteadOfRef, circularObj)).toBe(false);
225+
});
226+
227+
test('transitive circular references', () => {
228+
const transitiveCircularObj = {a: 'hello'};
229+
transitiveCircularObj.nestedObj = {parentObj: transitiveCircularObj};
230+
231+
const otherTransitiveCircularObj = {a: 'hello'};
232+
otherTransitiveCircularObj.nestedObj = {
233+
parentObj: otherTransitiveCircularObj,
234+
};
235+
236+
const differentTransitiveCircularObj = {a: 'world'};
237+
differentTransitiveCircularObj.nestedObj = {
238+
parentObj: differentTransitiveCircularObj,
239+
};
240+
241+
const primitiveInsteadOfRef = {};
242+
primitiveInsteadOfRef.nestedObj = {
243+
parentObj: 'not the parent ref',
244+
};
245+
246+
expect(subsetEquality(transitiveCircularObj, {})).toBe(true);
247+
expect(subsetEquality({}, transitiveCircularObj)).toBe(false);
248+
expect(
249+
subsetEquality(otherTransitiveCircularObj, transitiveCircularObj),
250+
).toBe(true);
251+
expect(
252+
subsetEquality(differentTransitiveCircularObj, transitiveCircularObj),
253+
).toBe(false);
254+
expect(subsetEquality(primitiveInsteadOfRef, transitiveCircularObj)).toBe(
255+
false,
256+
);
257+
});
258+
});
205259
});
206260

207261
describe('iterableEquality', () => {

packages/expect/src/utils.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -268,16 +268,38 @@ export const subsetEquality = (
268268
object: any,
269269
subset: any,
270270
): undefined | boolean => {
271-
if (!isObjectWithKeys(subset)) {
272-
return undefined;
273-
}
271+
const seenReferences = new WeakMap();
272+
273+
// subsetEquality needs to keep track of the references
274+
// it has already visited to avoid infinite loops in case
275+
// there are circular references in the subset passed to it.
276+
const subsetEqualityWithContext = (
277+
seenReferences: WeakMap<object, boolean>,
278+
) => (object: any, subset: any): undefined | boolean => {
279+
if (!isObjectWithKeys(subset)) {
280+
return undefined;
281+
}
274282

275-
return Object.keys(subset).every(
276-
key =>
277-
object != null &&
278-
hasOwnProperty(object, key) &&
279-
equals(object[key], subset[key], [iterableEquality, subsetEquality]),
280-
);
283+
return Object.keys(subset).every(key => {
284+
if (isObjectWithKeys(subset[key])) {
285+
if (seenReferences.get(subset[key])) {
286+
return equals(object[key], subset[key], [iterableEquality]);
287+
}
288+
seenReferences.set(subset[key], true);
289+
}
290+
291+
return (
292+
object != null &&
293+
hasOwnProperty(object, key) &&
294+
equals(object[key], subset[key], [
295+
iterableEquality,
296+
subsetEqualityWithContext(seenReferences),
297+
])
298+
);
299+
});
300+
};
301+
302+
return subsetEqualityWithContext(seenReferences)(object, subset);
281303
};
282304

283305
export const typeEquality = (a: any, b: any) => {

0 commit comments

Comments
 (0)