-
-
Notifications
You must be signed in to change notification settings - Fork 6.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix handling circular references correctly in objects (closes #8663) #8687
Fix handling circular references correctly in objects (closes #8663) #8687
Conversation
b3cb2f9
to
15a6555
Compare
Codecov Report
@@ Coverage Diff @@
## master #8687 +/- ##
=========================================
- Coverage 63.83% 63.5% -0.34%
=========================================
Files 274 274
Lines 11387 11396 +9
Branches 2769 2775 +6
=========================================
- Hits 7269 7237 -32
- Misses 3499 3546 +47
+ Partials 619 613 -6
Continue to review full report at Codecov.
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice
packages/expect/src/utils.ts
Outdated
// it has already visited to avoid infinite loops in case | ||
// there are circular references in the subset passed to it. | ||
const subsetEqualityWithContext = ( | ||
seenReferences: WeakMap<object, boolean>, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about moving the initialization of the weak map here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good idea. Just pushed this change.
Btw the Azure builds seem to be very flaky on Windows machines due to their network connection 😢
15a6555
to
b0f9514
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Lucas thank you for this contribution, and especially such a clear explanation.
b0f9514
to
f144074
Compare
f144074
to
2c6eb48
Compare
Received: <red>{\\"a\\": \\"hello\\", \\"ref\\": [Circular]}</>" | ||
`; | ||
|
||
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"`; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@lucasfcosta I have added some line breaks so you can verify if the long-line snapshot is the intended criterion:
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"`;
I think it is from test case: [circularObjA1, circularObjB]
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Excellent catch! I have just fixed that. Please see the Appendix below.
Thank you @pedrottimark ✨
AppendixI have just added to this PR a commit which fixes this issue raised by @pedrottimark. This was a bit more involved than I thought it would be, so there's a long explanation below 😅 Why this happenedThis mistake — a very well spotted one — was caused by problems in the function which calculates the object subsets to generate the actual diff when tests fail. This is also why the Stack Overflow did not happen when When the tests fail, they will use This problem was very similar to the previous one and was caused specifically by the How I solved itThis solution was very similar to the previous one, but there was a major caveat which makes a minor detail in the implementation extremely important. In If we happen to go through an object that we have already seen it means it is a circular reference and therefore we must use it's correspondent If we used the reference that That nested Keeping track of TestsI have added tests to cover all the weird cases I could think of in Other details
@pedrottimark Thanks for the excellent review and for catching this problem. Without this, all the above wouldn't have been found. Brilliant work. |
dac2fdb
to
a97950f
Compare
a97950f
to
1f7bf0a
Compare
Hello everyone, Last week was very busy, but I just thought I'd ping some of you here so that we could perhaps get this merged. If there's anything I can do please let me know and I'll do my best to answer ASAP. Thank you very much for the brilliant work 😊 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Lucas, thank you for hard work, positive interaction, and your patience with a delayed final review.
Thanks, @pedrottimark, I highly appreciate your work and the whole team's. Thanks for the brilliant and careful review. Looking forward to contributing more often ❤️ |
This pull request has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs. |
Summary
This PR fixes the StackOverflow caused by passing objects with circular references to
toMatchObject
as per described in #8663.And many thanks to @leoselig for providing an accurate report 💖
Why this problem happens
toMatchObject
method ofexpect
. This method usesequals
and passes two custom testers to it:iterableEquality
andsubsetEquality
.equals
method will then use the passedcustomTesters
to check for equality betweena
andb
. If acustomTester
returns something other thanundefined
, that result is returned.subsetEquality
method would then useequals
for all the properties in theobject
with itself as one of thecustomTesters
. Due to this, if there were circular references theequals
method would callsubsetEquality
with the very same object repeatedly and the stack would reach its limit.It's important to highlight that this problem would only occur if the
subset
were the object which contained circular references since it's the one whose keys we iterate through.Please notice that even though
equals
had its own mechanism to avoid infinite recursion on circular references that would never get called because of thecustomTesters
which would cause that function to return early.Implementation details
To solve this I created a nested function called
subsetEqualityWithContext
which takes not onlya
andb
but also aWeakMap
to keep track of checked objects. I then usesubsetEqualityWithContext
instead ofsubsetEquality
as one of thecustomTesters
forequals
.This approach works because we add any found objects to
seenReferences
so that the next time they are seen we can useequals
without thecustomTester
which would cause infinite recursion.A little bit of semantics
The choice to use
equals
again if the object has already been seen is due to the semantics of circular references when it comes to equality. This is a very important detail. I opted for considering a circular reference as always being equal to another circular reference instead of checking the actual target of the reference.This implementation choice makes the following objects match successfully even though
ref
inotherCircularObj
does not point tocircularObj
.If we simply returned
true
orfalse
we would be avoiding to checkref
and if we used strict equality (===
)ref
would have to point to the exact same object in order for them to match:In a testing context, I believe users will want the former behaviour, and therefore I opted for making all circular references semantically equal.
Test plan
I have extensively tested this code both with simple circular references and transitive circular references.
In the tests for
toMatchObject
, I have checked both if they are matched correctly and also the snapshots of when the matching fails. To make these tests less verbose and repetitive, I also extracted the method responsible for doing the match and the negated match (the one with.not
) and reuse them throughout the tests fortoMatchObject
.I also added tests for the
subsetEquality
function inutils
to ensure that it's returning the correct boolean results itself and maintain tests granular (covering different layers oftoMatchObject
).In these I cover the following cases (separately for simple references and transitive references):
toMatchObject
Circular references in Arrays were already being handled correctly, but I added tests for it just to avoid any possible regressions.