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.
isEqualreports "There may be circular references" and returnsfalsefor deeply-equal objectsPackage:
rc-util@5.44.4and@rc-component/util@1.13.0(both current; identical logic insrc/isEqual.ts)What happens
isEqualreturnsfalsefor two objects that are deeply equal, and logsWarning: There may be circular references, when one of them holds the same reference in two different keys. Neither object contains a cycle.The same happens for any repeated reference, not just arrays:
Why
refSetis meant to detect cycles, but it records every value the walk has ever visited and never removes anything when the walk leaves a branch: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-formshares one empty-array constant between two fields of a field's meta (Field.tsx):Field.triggerMetaEventthen compares the previous meta with the next viaisEqual. Once a field has validated, itserrorsis 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 referencesin the dev console after a programmaticform.setFieldValue(...), pointing at application code that has no circular data of any kind. The functional effect is milder but real:isEqualreturnsfalsefor metas that are equal, soonMetaChangefires 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, includingshould not equal 6, which is the cyclic one. With the change:false, warnstrue, silentfalse, warnstrue, silenta.self = a)false, warnsfalse, warnsHappy to open the PR if the approach looks right.