-
- Notifications
You must be signed in to change notification settings - Fork 35.2k
Description
Version
v25.8.1
Platform
Linux codespaces-c0c7e6 6.8.0-1044-azure #50~22.04.1-Ubuntu SMP Wed Dec 3 15:13:22 UTC 2025 x86_64 GNU/Linux Subsystem
assert
What steps will reproduce the bug?
I have included a test which reproduces the bug in a draft PR in my fork: CraigMacomber#1
How often does it reproduce? Is there a required condition?
This can only happen when cycle detection has been enabled due to some prior deep equality assert containing a cycle.
It also only happens when there are some reference equal values in the expected or actual structures which are reference equal in one but not the other.
What is the expected behavior? Why is that the expected behavior?
I expect deepStrictEqual to never throw when objects have the same structure but are not reference-equal.
I expect deepStrictEqual's behaviour should not be impacted by previous calls to deepStrictEqual.
I have these expectations since they seem like intuitive assumptions to infer based on the current documentation for deepStrictEqual.
What do you see instead?
Both the above expectations are violated, see how the referenced test triggers a "Values have same structure but are not reference-equal" error from deepStrictEqual, but only if a previous call contained a cycle.
Additional information
I was going to try and include a fix and not just a reproduction of the issue, but the lack of documentation and fine-grained testing of the functions in lib/internal/util/comparisons.js made me not have the confidence to attempt a fix.
Here is a copy of the test from the linked PR so that thus bug is fully self-contained.
Note that AI was used in the construction and documentation of this bug reproduction, but I manually confirmed it reproduces on current main ( 8e8b98d ), v24.14.0 and v25.8.1, and that it does not reproduce in v22.22.1.
I also looked at the implementation of handleCycles in lib/internal/util/comparisons.js and confirmed that the explanation given for why this issue occurs is plausible, but I have not fully confirmed it to be accurate.
This issue is not blocking any of my work: I just happened to run into it once and took that opportunity to minimize a reproduction or it. I do not expect to be following up on this, and do not need it prioritized in any way. I simply hope that this bug report is useful to others to help improve the quality of Node.JS.
Feel free to use my test/repro upstream as a regression test if this gets fixed.
// Confirmed to fail in Node.JS v24.14.0 and v25.8.1 // Regressed from v22.22.1 which works as expected. // // Node.js's `deepStrictEqual` (and `strict.deepEqual`) uses an internal // `detectCycles` function that starts with `memos = null` (no cycle // detection). The first time a comparison throws during the null-memos // path — typically a stack overflow caused by comparing two circular // structures — `detectCycles` is permanently replaced by `innerDeepEqual`, // which passes a live `memos` object through every recursive call. // // In that memo-enabled mode the cycle-detection set (`memos.set`) is // seeded with the *current* val2 (`memos.d`) when it is first created. // That seed is never removed after the nested comparison returns, so when // the same expected object reference appears as val2 in a sibling // comparison, `set.add(sharedRef)` is a no-op. The invariant // `originalSize === set.size - 2` then fails (only one new item was added // instead of two), and Node.js incorrectly concludes the structures are // not equal. test("deepStrictEqual rejects structurally equal arrays when expected has a shared reference and cycle detection is active", () => { // `actual` has two *distinct* objects with identical content. // `expected` reuses the *same* object reference at both positions. const sharedExpected = { outer: { inner: 0 } }; const actualValues = [{ outer: { inner: 0 } }, { outer: { inner: 0 } }]; const expectedValues = [sharedExpected, sharedExpected]; // Works, but only if no cycles have been processed before running this test. assert.deepStrictEqual(actualValues, expectedValues); // Activate cycle-detection mode permanently in this process // by comparing two isomorphic circular objects. // The first attempt with null memos causes a stack overflow; // the catch handler replaces detectCycles with innerDeepEqual for all future calls. const circA = {}; circA.self = circA; const circB = {}; circB.self = circB; assert.deepStrictEqual(circA, circB); // triggers the permanent switch // Individual element comparisons always pass … assert.deepStrictEqual(actualValues[0], expectedValues[0]); assert.deepStrictEqual(actualValues[1], expectedValues[1]); // The combined comparison now fails because Node.js's // cycle-detection set still contains `sharedExpected` from the first // element's comparison when the second element is evaluated. // Fails with: // AssertionError [ERR_ASSERTION]: Values have same structure but are not reference-equal... assert.deepStrictEqual(actualValues, expectedValues); });