Skip to content

isEqual: false 'circular references' warning for a reference repeated across sibling keys #816

Description

@savkaoleg

isEqual reports "There may be circular references" and returns false for deeply-equal objects

Package: rc-util@5.44.4 and @rc-component/util@1.13.0 (both current; identical logic in src/isEqual.ts)

What happens

isEqual returns false for two objects that are deeply equal, and logs Warning: There may be circular references, when one of them holds the same reference in two different keys. Neither object contains a cycle.

import isEqual from 'rc-util/lib/isEqual';

const shared = [];
const a = { errors: shared, warnings: shared }; // one array, two keys
const b = { errors: [], warnings: [] }; // same value, two separate arrays

isEqual(a, b);
// → false
// → console.error: "Warning: There may be circular references"

JSON.stringify(a) === JSON.stringify(b); // → true

The same happens for any repeated reference, not just arrays:

const point = { x: 1 };
isEqual({ a: point, b: point }, { a: { x: 1 }, b: { x: 1 } }); // → false, warns

Why

refSet is meant to detect cycles, but it records every value the walk has ever visited and never removes anything when the walk leaves a branch:

const refSet = new Set<any>();
function deepEqual(a: any, b: any, level = 1): boolean {
  const circular = refSet.has(a);
  warning(!circular, 'Warning: There may be circular references');
  if (circular) {
    return false;
  }
  ...
  refSet.add(a);   // added on the way in, never removed on the way out

A cycle means a value reachable from itself — that is, a value that is its own ancestor along the current path. The set therefore needs to hold the current path, not the whole history. As written, a value legitimately reached twice in two sibling branches is indistinguishable from a cycle: the second visit finds it in the set, warns, and returns false.

Why it matters in practice

rc-field-form shares one empty-array constant between two fields of a field's meta (Field.tsx):

const EMPTY_ERRORS: any[] = [];
...
public errors: string[] = EMPTY_ERRORS;
public warnings: string[] = EMPTY_ERRORS;

Field.triggerMetaEvent then compares the previous meta with the next via isEqual. Once a field has validated, its errors is a fresh [] while another field's meta still carries the shared constant in both keys — so the comparison hits exactly the case above.

The visible result in any antd app is a Warning: There may be circular references in the dev console after a programmatic form.setFieldValue(...), pointing at application code that has no circular data of any kind. The functional effect is milder but real: isEqual returns false for metas that are equal, so onMetaChange fires when nothing changed.

Suggested fix

Track ancestors rather than history — remove the entry once the walk leaves the branch:

     refSet.add(a);
     const newLevel = level + 1;
-    if (Array.isArray(a)) {
-      ...
-    }
-    // other
-    return false;
+    try {
+      if (Array.isArray(a)) {
+        ...
+      }
+      // other
+      return false;
+    } finally {
+      // `a` is an ancestor only while the walk is inside it; a value reached again in a sibling
+      // branch is a repeat, not a cycle.
+      refSet.delete(a);
+    }
   }

A full patch is attached, with cases added to the existing src/test/isEqual.test.ts. Every test already in that file passes unchanged, including should not equal 6, which is the cyclic one. With the change:

case before after
one array held in two keys false, warns true, silent
one object reused across sibling keys false, warns true, silent
a genuine cycle (a.self = a) false, warns false, warns
plainly equal / unequal values unchanged unchanged

Happy to open the PR if the approach looks right.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions