Skip to content

fix(json-schema): keep sibling union branches that share a $defs entry - #1927

Merged
dinwwwh merged 1 commit into
middleapi:mainfrom
dinwwwh:claude/github-issue-1926-fcffed
Aug 23, 2026
Merged

fix(json-schema): keep sibling union branches that share a $defs entry#1927
dinwwwh merged 1 commit into
middleapi:mainfrom
dinwwwh:claude/github-issue-1926-fcffed

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Aug 23, 2026

Copy link
Copy Markdown
Member

Sibling anyOf/oneOf branches that reference the same $defs entry no longer collapse into one when a union is flattened. flattenJsonUnionSchemaInternal tracked in-progress $refs in a Set it mutated in place, and since Set.prototype.add returns the same set, a $ref being resolved leaked out of its own recursion path and persisted into later sibling branches. Any branch pointing at an entry an earlier sibling had already touched hit the cycle guard and was silently dropped.

Copying the set per recursion path scopes the guard to the path actually being walked, matching what the sibling function extractJsonObjectSchemaEntriesInternal already does.

Fixes

The reported case now keeps both branches with their own keywords instead of returning only the first:

flattenJsonUnionSchema({
  $defs: { Shared: { type: 'string' } },
  anyOf: [
    { $ref: '#/$defs/Shared', minLength: 2 },
    { $ref: '#/$defs/Shared', maxLength: 5 },
  ],
})

Downstream, OpenAPI documents generated from unions whose members share a $ref no longer lose members: detailed output schemas keep every response status, and request/response bodies keep every variant. Cycle detection stays intact and still terminates, including on mutually recursive $defs.

Performance

The in-place mutation was acting as accidental global memoization, so flattening is now exponential in union nesting depth in the worst case. This only bites on shapes far outside normal contracts, because flattening recurses through anyOf/oneOf and top-level $ref only, never into properties or items. A union nested 7 deep with 3 branches per level measures ~20 ms; a synthetic depth-20 chain of unions-of-unions takes seconds. Generation happens at build/startup on developer-authored schemas, and the previous cost was paid in silently wrong output, so the trade seemed worth making rather than keeping a faster incorrect traversal.

Worth noting for anyone who revisits this: a memo would have to be keyed on the resolved definition plus the path-relevant ref set. Keying on schema identity alone would collapse legitimately distinct branches and reintroduce exactly this bug.

Testing

Three regression tests, each confirmed to fail before the change and pass after: the reported repro, a shared $defs entry that is itself a union (2 branches to 4), and mutual A to B recursion, which also pins that cycle detection stays path-scoped and terminating.

Full suite (3251 tests), pnpm type:check and pnpm lint pass.

Closes #1926

`flattenJsonUnionSchemaInternal` tracked in-progress `$ref`s with a Set it
mutated in place. `Set.prototype.add` returns the same set, so a `$ref`
being resolved leaked out of its own recursion path and persisted into
sibling `anyOf`/`oneOf` branches. Any later branch pointing at the same
`$defs` entry then tripped the cycle guard and was dropped, collapsing
two branches into one.

Copying the set per recursion path scopes the guard to the path being
walked, which is what the sibling function
`extractJsonObjectSchemaEntriesInternal` already does.

Closes middleapi#1926
@vercel

vercel Bot commented Aug 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
orpc Ready Ready Preview Aug 23, 2026 1:12am

@pkg-pr-new

pkg-pr-new Bot commented Aug 23, 2026

Copy link
Copy Markdown
More templates

@orpc/ai-sdk

npm i https://pkg.pr.new/@orpc/ai-sdk@1927

@orpc/arktype

npm i https://pkg.pr.new/@orpc/arktype@1927

@orpc/bun

npm i https://pkg.pr.new/@orpc/bun@1927

@orpc/client

npm i https://pkg.pr.new/@orpc/client@1927

@orpc/cloudflare

npm i https://pkg.pr.new/@orpc/cloudflare@1927

@orpc/contract

npm i https://pkg.pr.new/@orpc/contract@1927

@orpc/experimental-effect

npm i https://pkg.pr.new/@orpc/experimental-effect@1927

@orpc/evlog

npm i https://pkg.pr.new/@orpc/evlog@1927

@orpc/hibernation

npm i https://pkg.pr.new/@orpc/hibernation@1927

@orpc/json-schema

npm i https://pkg.pr.new/@orpc/json-schema@1927

@orpc/experimental-msw

npm i https://pkg.pr.new/@orpc/experimental-msw@1927

@orpc/nest

npm i https://pkg.pr.new/@orpc/nest@1927

@orpc/next

npm i https://pkg.pr.new/@orpc/next@1927

@orpc/node

npm i https://pkg.pr.new/@orpc/node@1927

@orpc/openapi

npm i https://pkg.pr.new/@orpc/openapi@1927

@orpc/opentelemetry

npm i https://pkg.pr.new/@orpc/opentelemetry@1927

@orpc/pinia-colada

npm i https://pkg.pr.new/@orpc/pinia-colada@1927

@orpc/pino

npm i https://pkg.pr.new/@orpc/pino@1927

@orpc/publisher

npm i https://pkg.pr.new/@orpc/publisher@1927

@orpc/ratelimit

npm i https://pkg.pr.new/@orpc/ratelimit@1927

@orpc/server

npm i https://pkg.pr.new/@orpc/server@1927

@orpc/shared

npm i https://pkg.pr.new/@orpc/shared@1927

@orpc/swr

npm i https://pkg.pr.new/@orpc/swr@1927

@orpc/tanstack-query

npm i https://pkg.pr.new/@orpc/tanstack-query@1927

@orpc/trpc

npm i https://pkg.pr.new/@orpc/trpc@1927

@orpc/valibot

npm i https://pkg.pr.new/@orpc/valibot@1927

@orpc/zod

npm i https://pkg.pr.new/@orpc/zod@1927

commit: c6097ef

@codecov

codecov Bot commented Aug 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 30 untouched benchmarks


Comparing dinwwwh:claude/github-issue-1926-fcffed (c6097ef) with main (0c7ec80)

Open in CodSpeed

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes — one-line correctness fix in flattenJsonUnionSchemaInternal plus three regression tests; reviewed the complete 84-line diff against base main.

  • Bug fix (composition-utils.ts)$ref-resolution recursion now passes a per-path copy of resolvingRefs (new Set(...).add(...)) instead of mutating the shared set in place, so a $ref resolved on one sibling anyOf/oneOf branch no longer leaks into later siblings referencing the same $defs entry. The root-cause analysis is accurate, and the change matches the sibling extractJsonObjectSchemaEntriesInternal idiom (line 248) exactly; no in-place set mutations remain anywhere in the file.
  • Regression tests (composition-utils.test.ts) — the reported repro (both keyword-bearing branches kept), a shared def that is itself a union (2 top branches expand to the 4-way cartesian product), and mutual AB recursion confirming cycle detection stays path-scoped and terminating.

I verified the tests are real coverage: temporarily reverting the one-line fix makes all three new tests fail, and with the fix they pass. Downstream consumers (flattenJsonUnionSchema / matchArrayableJsonSchema in packages/openapi) are unaffected — json-schema (157) and openapi (437) tests pass, tsc -b on both packages is clean, and eslint on the changed files is clean. The documented exponential worst-case for adversarial union-of-unions shapes is inherent to producing the now-correct output (the previous speed was the bug), and the build-time/startup generation context makes the tradeoff reasonable.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@dinwwwh
dinwwwh merged commit d7e45f9 into middleapi:main Aug 23, 2026
12 checks passed
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.

fix(json-schema): sibling union branches referencing the same $defs entry collapse into one when flattening

1 participant