fix(json-schema): keep sibling union branches that share a $defs entry - #1927
Conversation
`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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
More templates
@orpc/ai-sdk
@orpc/arktype
@orpc/bun
@orpc/client
@orpc/cloudflare
@orpc/contract
@orpc/experimental-effect
@orpc/evlog
@orpc/hibernation
@orpc/json-schema
@orpc/experimental-msw
@orpc/nest
@orpc/next
@orpc/node
@orpc/openapi
@orpc/opentelemetry
@orpc/pinia-colada
@orpc/pino
@orpc/publisher
@orpc/ratelimit
@orpc/server
@orpc/shared
@orpc/swr
@orpc/tanstack-query
@orpc/trpc
@orpc/valibot
@orpc/zod
commit: |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
✅ 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 ofresolvingRefs(new Set(...).add(...)) instead of mutating the shared set in place, so a$refresolved on one siblinganyOf/oneOfbranch no longer leaks into later siblings referencing the same$defsentry. The root-cause analysis is accurate, and the change matches the siblingextractJsonObjectSchemaEntriesInternalidiom (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 mutualA↔Brecursion 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.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Sibling
anyOf/oneOfbranches that reference the same$defsentry no longer collapse into one when a union is flattened.flattenJsonUnionSchemaInternaltracked in-progress$refs in a Set it mutated in place, and sinceSet.prototype.addreturns the same set, a$refbeing 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
extractJsonObjectSchemaEntriesInternalalready does.Fixes
The reported case now keeps both branches with their own keywords instead of returning only the first:
Downstream, OpenAPI documents generated from unions whose members share a
$refno 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/oneOfand top-level$refonly, never intopropertiesoritems. 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
$defsentry that is itself a union (2 branches to 4), and mutualAtoBrecursion, which also pins that cycle detection stays path-scoped and terminating.Full suite (3251 tests),
pnpm type:checkandpnpm lintpass.Closes #1926