Summary
The onCircular callback is only invoked for the first detection of each unique circular reference target, not for every occurrence of a circular $ref in the schema. This is an unintended side effect of the caching optimizations introduced in #380 and #381.
Expected Behavior
According to the documentation:
onCircular (string) => void - A function, called immediately after detecting a circular $ref with the circular $ref in question.
The callback should be called for every $ref that points to a circular target, allowing users to identify all locations where circular references exist.
Actual Behavior
The callback is only called once per unique circular target. Subsequent $refs pointing to the same target return early from the cache without invoking onCircular.
Reproduction
import $RefParser from '@apidevtools/json-schema-ref-parser';
const schema = {
definitions: {
Node: {
type: 'object',
properties: {
self: { $ref: '#/definitions/Node' }, // 1. Self-reference
},
},
Container: {
type: 'object',
properties: {
primaryNode: { $ref: '#/definitions/Node' }, // 2. Should trigger onCircular
secondaryNode: { $ref: '#/definitions/Node' }, // 3. Should trigger onCircular
},
},
},
root: { $ref: '#/definitions/Node' }, // 4. Should trigger onCircular
};
const circularRefs: string[] = [];
await new $RefParser().dereference(schema, {
dereference: {
onCircular: (path) => circularRefs.push(path),
},
});
console.log(circularRefs.length); // Expected: 4, Actual: 1
console.log(circularRefs); // Only contains the self-reference path
Root Cause
In lib/dereference.ts, the dereference$Ref function has multiple early returns for circular cache hits that don't call foundCircularReference:
// Lines ~252-263
if (typeof cache.value === "object" && "$ref" in cache.value && "$ref" in $ref) {
if (cache.value.$ref === $ref.$ref) {
return cache; // ← Returns without calling foundCircularReference!
} else {
// no-op - fall through to re-process (handles external ref edge case)
}
} else {
return cache; // ← Returns without calling foundCircularReference!
}
Proposed Fix
Add foundCircularReference(path, $refs, options) before each circular cache return:
if (typeof cache.value === "object" && "$ref" in cache.value && "$ref" in $ref) {
if (cache.value.$ref === $ref.$ref) {
foundCircularReference(path, $refs, options); // ← Add this
return cache;
} else {
// no-op - fall through to re-process (handles external ref edge case)
}
} else {
foundCircularReference(path, $refs, options); // ← Add this
return cache;
}
This maintains the performance optimization while ensuring onCircular fires for every occurrence, matching the behavior of onDereference which correctly fires for every $ref.
Summary
The
onCircularcallback is only invoked for the first detection of each unique circular reference target, not for every occurrence of a circular$refin the schema. This is an unintended side effect of the caching optimizations introduced in #380 and #381.Expected Behavior
According to the documentation:
The callback should be called for every
$refthat points to a circular target, allowing users to identify all locations where circular references exist.Actual Behavior
The callback is only called once per unique circular target. Subsequent
$refs pointing to the same target return early from the cache without invokingonCircular.Reproduction
Root Cause
In
lib/dereference.ts, thedereference$Reffunction has multiple early returns for circular cache hits that don't callfoundCircularReference:onCircularonCircularProposed Fix
Add
foundCircularReference(path, $refs, options)before each circular cache return:This maintains the performance optimization while ensuring
onCircularfires for every occurrence, matching the behavior ofonDereferencewhich correctly fires for every$ref.