Skip to content

onCircular callback not called for all occurrences of circular $refs (only fires on first detection) #407

@HugoHSun

Description

@HugoHSun

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions