Skip to content

Fix/circular object validation#5664

Open
lawless-m wants to merge 3 commits intocolinhacks:mainfrom
lawless-m:fix/circular-object-validation
Open

Fix/circular object validation#5664
lawless-m wants to merge 3 commits intocolinhacks:mainfrom
lawless-m:fix/circular-object-validation

Conversation

@lawless-m
Copy link

No description provided.

@lawless-m lawless-m force-pushed the fix/circular-object-validation branch from 7d0207c to 848a0e9 Compare January 26, 2026 16:54
@lawless-m lawless-m marked this pull request as ready for review January 26, 2026 17:02
@lawless-m
Copy link
Author

Oh, didn't make any comments. this fix is for the fixed circular object validation It's AI slop, so I hope it works. :)

Copy link
Contributor

@pullfrog pullfrog bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not recommended for merge in current form. This PR addresses a legitimate issue (infinite recursion when parsing objects with circular references), and the fundamental approach is correct. However, the implementation has significant code quality issues that should be addressed before merging into a library as widely used as Zod.

What works well:

  • The tests are comprehensive and cover important edge cases
  • The (input, schema) → output mapping correctly handles the case where the same object is parsed by different schemas
  • Lazy initialization of the visited map avoids overhead for simple cases

Blocking concerns:

  1. Massive code duplication — ~35 lines of nearly identical circular tracking logic are copy-pasted 4 times across $ZodArray, $ZodObject, $ZodObjectJIT, and $ZodTuple. This violates DRY and will make maintenance difficult.
  2. Incomplete coverage$ZodRecord, $ZodMap, and $ZodSet also iterate over collections and could theoretically have circular values, but don't have tracking added. The record tests pass only because they delegate to $ZodObject which has tracking.
  3. Unrelated change — The .gitignore addition for .claude/ is unrelated and shouldn't be part of this PR.

I'd recommend either:

  • Extracting the circular tracking logic into helper functions (e.g., checkCircularRef and registerCircularRef)
  • Or discussing with maintainers whether this feature belongs in core Zod at all, vs. being a userland utility

Thank you for the contribution and for tackling a real pain point!

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow runpullfrog.com𝕏

Comment on lines 1615 to 1649
// Check if we need circular reference tracking
// We need it if there are any object/array elements that could create cycles
let visited = ctx?.visited;
const needsTracking = input.some((item) => typeof item === "object" && item !== null);

if (needsTracking) {
// Lazily initialize visited if not already set
if (!visited) {
visited = new WeakMap();
(ctx as any).visited = visited;
}

// Check if we've already seen this (array, schema) pair
const schemaMap = visited.get(input);
if (schemaMap) {
const cached = schemaMap.get(inst);
if (cached !== undefined) {
// Already validated (or being validated) by THIS schema - return cached output
payload.value = cached;
return payload;
}
}
}

payload.value = Array(input.length);

// Store the mapping before recursing (so circular refs can find it)
if (visited && needsTracking) {
let schemaMap = visited.get(input);
if (!schemaMap) {
schemaMap = new WeakMap();
visited.set(input, schemaMap);
}
schemaMap.set(inst, payload.value);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ~35-line block is duplicated almost identically in $ZodObject (lines 1934-1977), $ZodObjectJIT (lines 2110-2141), and $ZodTuple (lines 2708-2742). Consider extracting this into helper functions, e.g.:

function checkCircularRef(
  input: object,
  inst: object,
  ctx: ParseContext | undefined
): object | undefined {
  const visited = ctx?.visited;
  if (!visited) return undefined;
  return visited.get(input)?.get(inst);
}

function registerCircularRef(
  input: object,
  inst: object,
  output: object,
  ctx: ParseContext | undefined
): void {
  // ... initialization and registration logic
}

This would reduce ~140 lines of duplicated code to ~40 lines of shared logic.

Comment on lines 2110 to 2141
// Check if we need circular reference tracking
// We need it if there are any nested object/array values that could create cycles
let visited = ctx?.visited;
let needsTracking = !!visited;
if (!needsTracking) {
for (const key in input) {
const v = input[key];
if (typeof v === "object" && v !== null) {
needsTracking = true;
break;
}
}
}

if (needsTracking) {
// Lazily initialize visited if not already set
if (!visited) {
visited = new WeakMap();
(ctx as any).visited = visited;
}

// Check if we've already seen this (object, schema) pair
const schemaMap = visited.get(input);
if (schemaMap) {
const cached = schemaMap.get(inst);
if (cached !== undefined) {
// Already validated (or being validated) by THIS schema - return cached output
payload.value = cached;
return payload;
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This circular tracking code duplicates logic from $ZodObject, but there's a subtle inefficiency: after checking for a cached result (and not finding one), this code calls superParse which performs the exact same check again. Either:

  1. Remove this check entirely and let superParse handle all circular tracking, OR
  2. If you keep the check, also do the registration here and avoid calling superParse for the circular case

Current flow: JIT checks cache (miss) → calls superParse → superParse checks cache again (miss) → stores → parses

.gitignore Outdated
tsconfig.vitest-temp.json
*.tgz

.claude/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is unrelated to circular object validation. If you want to add .claude/ to gitignore, it should be a separate PR or at minimum mentioned in the PR description.

@pullfrog
Copy link
Contributor

pullfrog bot commented Jan 26, 2026

Review Complete

I've reviewed PR #5664 and left detailed feedback. The PR addresses a legitimate issue (infinite recursion when parsing objects with circular references), but has significant code quality concerns:

Summary:

  • ✅ Tests pass (all 3607 + new circular tests)
  • ✅ Fundamental approach is sound
  • ❌ ~35 lines of code duplicated 4 times
  • ❌ Incomplete coverage (Record/Map/Set missing)
  • ❌ Unrelated .gitignore change

Recommendation: Not ready for merge in current form. See inline comments for specific suggestions.

Pullfrog  | Triggered by Pullfrog | Using Claude CodeView workflow runpullfrog.com𝕏

Copy link
Author

@lawless-m lawless-m left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

responded to pullfrogs criticism

Add circular reference tracking to prevent infinite recursion when
validating objects with circular references via z.lazy(). Uses a WeakMap
to track (input object, schema) pairs during parsing.

Changes:
- Add helper functions (needsCircularTracking, checkCircularRef, registerCircularRef)
- Add circular detection to $ZodArray, $ZodObject, $ZodObjectJIT, $ZodTuple
- Add circular detection to $ZodRecord, $ZodMap, $ZodSet
- Disable JIT fast path when circular tracking is active
- Add comprehensive test suite for circular validation scenarios

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@lawless-m
Copy link
Author

pullfrog is a great tool

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants