Skip to content

Fix 5338 & 5373: compile precompiled validators with the form's customMergeAllOf - #5371

Open
heath-freenome wants to merge 6 commits into
v7from
fix-5338
Open

heath-freenome wants to merge 6 commits into
v7from
fix-5338

Conversation

@heath-freenome

@heath-freenome heath-freenome commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Reasons for making this change

Fixes #5338. Fixes #5373. Rebased onto v7 now that #5340, whose SchemaContext this builds on, has merged.

A precompiled validator only has functions for the sub-schemas schemaParser() collected, keyed by their $id or hash. The parser had no way to receive a customMergeAllOf, so when a form's merge produced sub-schemas the default merge doesn't, the form threw No precompiled validator function was found for the given schema.

API changes

  • New SchemaParserOptions<S> type in @rjsf/utils, a Pick of SchemaContext holding customMergeAllOf, so a form's SchemaContext can be passed in its place.
  • schemaParser(rootSchema, options?) parses with a SchemaContext built from the options.
  • New CompileValidatorOptionsType<S> in @rjsf/validator-ajv8 and @rjsf/validator-ata, which is their CustomValidatorOptionsType plus the customMergeAllOf the schema is parsed with. compileSchemaValidators(schema, output, options?) and compileSchemaValidatorsCode(schema, options?) take it in place of the CustomValidatorOptionsType they took, so compiling with only a merge takes one options object rather than an empty one followed by a second. It only adds an optional field, so existing calls type-check and behave unchanged; compiling with { customMergeAllOf }, merging the way the form's does, covers the sub-schemas it merges.

Note that the same customMergeAllOf has to reach createPrecompiledValidator() as well, since the validator resolves the root schema it is handed with it. validation.md now says so.

The parser reads an allOf both merged and as it stands

schemaParser() walks the schema with expandAllBranches, which returned an allOf's unmerged branches and nothing else. A form renders the merged schema, so the branches held neither the sub-schemas a customMergeAllOf rewrote (which is #5338) nor the ones that only exist once the merge has brought a property together with the patternProperties matching it — which happens with the default merge too. Expanding all branches now merges the allOf the way the form does, and drops one it cannot merge the way the form does.

But a form does not read an allOf only through that merge, which is what @jimmycallin caught this round:

  • getObjectDefaults() walks a nested object's unmerged properties unless defaultFormStateBehavior.allOf === 'populateDefaults', and getClosestMatchingOption() scores those options as written.
  • omitExtraData() walks the entries a merge leaves in place and scores each entry's options.

Both threw on the merge-only parse. So parseSchema() records the entries and what the schema declares besides them alongside the merge:

const { [ALL_OF_KEY]: allOf, ...withoutAllOf } = schema;
if (allOf) {
  parseSchema<T, S, F>(context, state, rootSchema, withoutAllOf as S);
  for (const subSchema of allOf) {
    if (isSchemaObject<S>(subSchema)) {
      parseSchema<T, S, F>(context, state, rootSchema, subSchema);
    }
  }
}

That stays additive: k nested allOfs cost 2k + 1 merges (5, 9, 13, 17, 21 for k = 2, 4, 6, 8, 10), not a product. Only the parser passes expandAllBranches: true, so none of this changes form behavior — only what gets compiled.

This also removes a cost that predates the PR: resolveSchema() returns the cartesian product of its allOf members' resolutions, which the branch variants multiplied. A root allOf of six three-item allOfs went from 15,631 merges in 60.2s to 13 in under a millisecond, faster than the pre-PR baseline of 64ms.

Also fixed in the parser

  • Each oneOf/anyOf option is parsed rather than having its properties read off, so an option whose sub-schemas come from its own allOf, conditions or dependencies is reached.
  • Each option is also scored in the form MultiSchemaField scores it. That component scores options.map((opt) => retrieveSchema(opt, formData)), so the common oneOf: [{ $ref: Cat }] with Cat = { allOf: [...] } is validated as the merge of that allOf, which the parse never recorded. It threw before this PR as well as during it; resolveAnyOrOneOfSchemas() now scores each option's retrieved form too, as-is and relaxed.
  • The patternProperties and additionalProperties schemas are parsed, which is Precompiled validators miss sub-schemas under patternProperties and additionalProperties #5373. A key they describe is only stubbed into properties once the form data has it, and a parse has no form data, so nothing reached them; a oneOf under either threw as soon as a user added such a key. Both of that issue's variants are verified fixed here: at the v7 base they throw on the hash it names, -4c020a92, the patternProperties one through getDefaultFormState() and the additionalProperties one a step later, where MultiSchemaField scores the retrieved options.
  • A key the patternProperties match is rendered with the merge of every pattern matching it, which stubExistingAdditionalProperties() resolves as { allOf: [...matching] }. Each combination of patterns is parsed as that allOf rather than as the patterns themselves, since a parse has no form data to tell it which key matches which patterns. There are 2^n - 1 of those, so a schema with more than 16 patternProperties is now reported with a message saying why rather than parsed for minutes.
  • Every position of a tuple items is parsed, with additionalItems only for a tuple, matching what isFixedItems() gates in ArrayField.
  • A property that every oneOf/anyOf option redefines is parsed as the schema's own as well as as each option spells it. Fix 4385: pass customMergeAllOf and defaultFormStateBehavior through a SchemaContext #5340 fixed the neighbouring half of this; this is the remainder.
  • The schemas already walked are keyed by content rather than scanned for with deepEquals(), which cost quadratically in a schema's properties and options: 150 properties with a 3-option oneOf each and 20 top-level options parse in 32ms rather than 86ms, to the same 471 sub-schemas.

A derived schema carries an $id that names it, not the one it came from

A validator caches the function it compiles for a schema under that schema's $id, so a schema RJSF derives from one must not keep it unchanged or the two share a function:

  • getFirstMatchingOption() validates an object option against an augmented copy — an anyOf of its required keys added, required deleted — and relaxOptionsForScoring() scores it with additionalProperties: false widened to true. Both kept the option's $id, so each variant was answered by the function compiled for the first of them, which made the relaxed variant validate as strictly as the one it exists to relax. In the regular validator as much as the precompiled one.
  • Each variant now carries <the option's $id>?rjsf=<the variant's hash>. Simply dropping the $id, which is what the first pass at this did, loses the base URI that a relative $ref left inside the option resolves against — a $ref: 'b.json' under $id: 'http://example.com/a.json' stopped resolving, and the option silently failed to match. The query keeps the base while naming the variant.
  • The junk option is the exception, and keeps its $id unchanged: precompiledValidator.isValid() recognises it by that $id and answers it without a compiled function at all.
  • With the variants distinct, ParserValidator.addSchema() keeps failing loudly on a genuine key collision rather than needing to keep the first of two and warn.
  • A schema whose $id is the empty string was mapped under that $id, where the precompiled validators do not look. @rjsf/validator-ajv8 had the same bug in isValid(), where two such schemas shared one compiled function; @rjsf/validator-ata and @rjsf/validator-cfworker re-check the cached schema, so for them the shared key only made two such schemas evict each other. All now key by hash when the $id is empty, in rawValidation() as well as isValid().
  • The same ?? sat in handleSchemaUpdate() in ajv8 and ata: a root schema carrying $id: '' registered under the empty name while withIdRefPrefix() went on rewriting local $refs to resolve against __rjsf_rootSchema, so every $ref into such a root failed to compile and isValid() answered false after logging can't resolve reference. Both fall back to ROOT_SCHEMA_PREFIX now, which is what cfworker already did.

A property merged with its patternProperties is resolved with the branch expansion on

Merging a property with the patternProperties matching it calls retrieveSchemaInternal() again, and passed undefined for expandAllBranches even when the parser had asked for every branch. So that property's conditions collapsed to the else that ParserValidator.isValid() always returning false picks, and its dependencies were skipped for want of form data: a oneOf in the then branch was never compiled, and a form whose data meets the if threw.

expandAllBranches is passed through, and the branches are varied one property at a time. The first pass took the cartesian product of them, which cost 2 ** k parent variants for k two-branch properties (2.75s at k=16) and produced an identical parsed map, since isValid() is only ever handed an option, a condition or a dependency and never a whole parent. One at a time gives k + 1. The properties object is also rebuilt once rather than once per matched key, which had made the form's own resolution quadratic: 2000 properties against one ^p pattern went 5.6ms → 603ms → 7.4ms. With the expansion off, each inner call returns exactly one schema, so the result on every form path is unchanged.

An expansion that qualifies no option no longer crashes

Under expandAllBranches, withExactlyOneSubschema() returned [] when a dependency's oneOf has no option naming the dependency key, because the "ignore this oneOf" path was guarded on !expandAllBranches. The empty list reached getAllPermutationsOfXxxOf(), which pushed list[0] — undefined — and Object.getOwnPropertySymbols(undefined) threw Cannot convert undefined or null to object. Such a oneOf is now ignored, as it already is when the form data picks no single valid option.

Not fixed here

Both predate this change and want their own issues:

  • expandAllBranches never produces the variant of an if without an else, or of a dependencies entry, with the branch not applied, so a form whose data fails the if validates against a schema that was never compiled. Fixing it adds a variant per if and per dependency key — the combinatorial cost this PR removes — so it needs its own perf budget.
  • A oneOf option that carries an $id and resolves into different content — { $id: 'opt1', allOf: [...] }, say — makes schemaParser() throw Two different schemas exist with the same key opt1. Same family as the derived-$id fix above: the resolved option doesn't get an $id of its own the way the augmented and relaxed ones now do. Reproduces identically at the pre-PR base.

Dropped in the rebase onto v7

The optional trailing rootSchema on processRawValidationErrors(), and the precompiled validators passing their own, are gone: #5340's ensureSameRootSchema() resolves the validator's own root schema with its customMergeAllOf, and Form supplies getCustomValidateFormData() so the defaults handed to customValidate are computed by the form. With the parameter removed the whole ajv8 suite still passed, so it was unreachable. precompiledValidator.ts and processRawValidationErrors.ts are untouched by this PR in both packages.

Checklist

  • I'm updating documentation (precompiled validators in validation.md, the ajv8 and ata API reference, the SchemaContext section and "New types" list of the v7 upgrade guide)
  • I'm adding or updating code
    • I've added and/or updated tests
    • I've updated the changelog (CHANGELOG-v7.md)
  • I'm adding a breaking change

Testing

  • @rjsf/utils schemaParser tests: an allOf merged with a customMergeAllOf and with the default one, the entries of an allOf a merge leaves in place, the unmerged options alongside the merged ones, a merge that throws, a boolean allOf entry, a patternProperties match, a merged allOf whose patternProperties then applies, an option whose sub-schemas come only from its own allOf, option-only properties and items, a property every option redefines, tuple items and additionalItems, patternProperties/additionalProperties options, a schema with 17 patternProperties being reported, and a merge-count assertion that pins the nested-allOf cost at 2k + 1.
  • @rjsf/utils schemaParser tests for the derived $id: each variant of an $id-carrying option is compiled under a key of its own, both for an option with properties (where the augmentation is what reaches isValid()) and for one without (where the relaxed copy is), and the derived key keeps the option's $id as its base.
  • @rjsf/utils ParserValidator test: a schema whose $id is the empty string is mapped under its hash, as a validator looks it up.
  • @rjsf/utils getFirstMatchingOption test: isValid() is called with an $id derived from the schema it augments, and with the junk option's kept. It reads the schema from a recording validator that implements ValidatorType, so it holds for the real validators the shared suite also runs it against.
  • @rjsf/utils retrieveSchema tests: expanding all branches merges an allOf, drops one it cannot merge, expands the branches of a property merged with the patternProperties matching it, varies those branches one property at a time rather than in every combination, and ignores a dependency oneOf that qualifies no option.
  • @rjsf/validator-ajv8 validator tests: an option whose relative $ref resolves against its own $id still matches once augmented, and a schema whose $id is the empty string gets its own compiled function.
  • ajv8 and ata validator tests: a $ref into a root schema whose $id is the empty string resolves.
  • ajv8 and ata compileSchemaValidators tests: the options, customMergeAllOf included, reach compileSchemaValidatorsCode(), and default to {} without them.
  • ajv8 and ata compileSchemaValidatorsCode tests: the issue's repro through getDefaultFormState(), compiled without and with the customMergeAllOf; the same for a key only the patternProperties match; validateFormData() with a customValidate, both on submit and under live validation; and four end-to-end cases for the sub-schemas a form validates against unmerged — a nested allOf through getObjectDefaults(), the entries an identity merge leaves in place through omitExtraData(), an option retrieved from a $ref to an allOf through getClosestMatchingOption(), and a key two patternProperties both match. The shared fixtures live in utils/test/testUtils/customMergeAllOfData.ts and utils/test/testUtils/parsedSchemaData.ts.

Every fix is mutation-checked: removing it makes its test fail, with the hash the reported repro named — -4c020a92 for the unmerged nested allOf, -4fca741c for the entries left in place, -1ca39cdf for the retrieved $ref-to-allOf option, 73557177 for two patterns matching one key, and 1691cd08 for the original patternProperties case.

No snapshot changed — all 13 schemaParser() snapshots, superSchema and SCHEMA_WITH_ALLOF_CANNOT_MERGE included, are byte-identical.

Locally green: lint, knip, build (16 projects), the root typecheck, and test (15 projects), with @rjsf/utils and the validator packages at their enforced 100% coverage, plus cs-check.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Size limit report

Sizes are minified + brotli, measured by size-limit at a49fd2d.

A package listed twice is measured both as installed and, on the second row, as its own code with its dependencies excluded. Peer dependencies are always excluded.

Check Base PR Δ
@rjsf/antd 7.14 kB 7.14 kB =
@rjsf/antd (without dependencies) 6.73 kB 6.73 kB =
@rjsf/chakra-ui 18.69 kB 18.69 kB =
@rjsf/chakra-ui (without dependencies) 10.67 kB 10.67 kB =
@rjsf/core 26.84 kB 26.84 kB =
@rjsf/core/markdown 25.53 kB 25.53 kB =
@rjsf/core/testing 59.15 kB 59.18 kB +0.04 kB
@rjsf/core: Form 26.68 kB 26.68 kB =
@rjsf/daisyui 53.57 kB 53.57 kB =
@rjsf/daisyui (without dependencies) 10.74 kB 10.74 kB =
@rjsf/fluentui-rc 5.74 kB 5.74 kB =
@rjsf/mantine 14.29 kB 14.29 kB =
@rjsf/mantine (without dependencies) 9.95 kB 9.95 kB =
@rjsf/mui 6.54 kB 6.54 kB =
@rjsf/react-bootstrap 7.73 kB 7.73 kB =
@rjsf/react-bootstrap (without dependencies) 5.29 kB 5.29 kB =
@rjsf/shadcn 43.40 kB 43.40 kB =
@rjsf/shadcn (without dependencies) 9.10 kB 9.10 kB =
@rjsf/utils 26.61 kB 27.15 kB +0.54 kB
@rjsf/utils (without dependencies) 21.01 kB 21.54 kB +0.53 kB
@rjsf/utils: getUiOptions 0.54 kB 0.54 kB =
@rjsf/validator-ajv8 38.12 kB 38.11 kB -0.01 kB
@rjsf/validator-ajv8 (without dependencies) 2.65 kB 2.65 kB =
@rjsf/validator-ata 86.26 kB 86.24 kB -0.02 kB
@rjsf/validator-ata (without dependencies) 2.42 kB 2.41 kB -0.01 kB
@rjsf/validator-cfworker 7.22 kB 7.22 kB =
@rjsf/validator-cfworker (without dependencies) 2.32 kB 2.30 kB -0.02 kB

@jimmycallin jimmycallin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review of the fix-5338 changes against fix-4385-schema-context. I verified the first four inline comments with scratch vitest probes on this head. The last two are nits.

Comment thread packages/utils/src/schema/retrieveSchema.ts Outdated
Comment thread packages/utils/src/parser/schemaParser.ts Outdated
Comment thread packages/utils/src/schema/retrieveSchema.ts Outdated
Comment thread packages/utils/src/schema/retrieveSchema.ts Outdated
Comment thread packages/validator-ajv8/src/processRawValidationErrors.ts Outdated
Comment thread packages/validator-ata/src/processRawValidationErrors.ts Outdated
Comment thread packages/utils/src/parser/schemaParser.ts Outdated
Comment thread packages/utils/src/schema/retrieveSchema.ts Outdated
Comment thread packages/utils/src/types.ts Outdated
Comment thread packages/validator-ata/test/compileSchemaValidatorsCode.test.ts Outdated
@heath-freenome
heath-freenome force-pushed the fix-4385-schema-context branch 3 times, most recently from 63e7bf5 to 1fa40cf Compare September 30, 2026 19:26
Comment thread packages/utils/src/schema/retrieveSchema.ts
let merged: S;
try {
merged = context.customMergeAllOf(resolvedSchema);
} catch {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This catch swallows every error from the merge without a trace.

The form's own merge path logs could not merge subschemas in allOf before it falls back. Here the compile just leaves out every sub-schema that merge would have produced. If the user's customMergeAllOf has a bug that throws, say a TypeError on some allOf shape, the compile script succeeds. The form then fails later at runtime with No precompiled validator function was found, and nothing points back at the merge. A console.warn like the one in the non-expand branch would make it visible at compile time.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Gone. The merge now goes through mergeAllOf(), which already does logOnce('could not merge subschemas in allOf:\n', 'warn', e) and returns merged: false, so the compile warns in the same place and with the same message the form does, and drops the allOf the way the form drops it.

There's a test for the drop as well: a throwing merge keeps the rest of the schema's own sub-schemas and leaves the allOf's out, which is what the form renders.

Comment thread packages/utils/src/schema/retrieveSchema.ts Outdated
Comment thread packages/utils/src/parser/schemaParser.ts Outdated
@heath-freenome
heath-freenome force-pushed the fix-4385-schema-context branch 2 times, most recently from edaa1c2 to 33a06fa Compare October 1, 2026 02:06
Base automatically changed from fix-4385-schema-context to v7 October 1, 2026 03:35
heath-freenome added a commit that referenced this pull request Oct 1, 2026
Addresses the review of #5371.

A form always merges an `allOf`, so the sub-schemas it validates against are
the merged schema's. `expandAllBranches` returned the unmerged branches
instead, which hold neither the sub-schemas a `customMergeAllOf` rewrote nor
the ones that only exist once the merge has brought a property together with
the `patternProperties` matching it. Gating a second, merge-aware path on
`customMergeAllOf`, as this branch did, left the second case broken whenever no
custom merge was passed, and re-resolved the merged schema in a way the form
does not, which is what made the recursion guard necessary.

Expanding all branches now merges the `allOf`, and drops one it cannot merge,
through the same code the form uses, so the two cannot diverge. The branch
variants were also what `resolveSchema()`'s permutation of the `allOf` members
multiplied: a root `allOf` of six three-item `allOf`s went from 15,631 merges
in 60.2s to 7 in under a millisecond. Every `schemaParser()` snapshot is
unchanged, so the branches were contributing nothing to the parsed map.

Also in the parser:

- Each `oneOf`/`anyOf` option is parsed rather than having its `properties`
  read off, so an option whose sub-schemas come from its own `allOf`,
  conditions or dependencies is reached. A parsed set held by identity keeps a
  property the options share from being resolved once per option.
- The `patternProperties` and `additionalProperties` schemas are parsed. A key
  they describe is only stubbed into `properties` once the form data has it,
  which a parse has none of, so nothing reached them.
- Every position of a tuple `items` is parsed, with `additionalItems` only for
  a tuple, matching what `ArrayField` renders.
- Two schemas that share an `$id` and differ no longer fail the parse. The
  first is compiled and answers for the rest, which is how both the
  precompiled and the regular validators look a schema up.

`SchemaParserOptions` is a `Pick` of `SchemaContext` rather than an interface
`SchemaContext` extends, leaving the context type as v7 has it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@heath-freenome

Copy link
Copy Markdown
Member Author

Thanks @jimmycallin — this was a genuinely useful review. Every one of the eleven live findings reproduced, including your timings. Pushed as 18639b9, with per-thread replies above.

The review changed the shape of the fix. Your "the same gap exists with the default merge" comment is the root cause: a form always merges an allOf, so the sub-schemas it validates against are the merged schema's, and expandAllBranches was returning the unmerged branches. This branch had bolted a second, merge-aware path alongside them, gated on customMergeAllOf — which left the gap in place whenever no custom merge was passed, and re-resolved the merged schema in a way the form does not, which is what forced the recursion guard. Deleting the expand-all case instead makes the parser and the form the same code, so ALL_OF_KEY in merged, the swallowed catch and the pipeline mismatch stop being possible rather than being fixed one at a time:

     if (ALL_OF_KEY in resolvedSchema) {
-      // resolve allOf schemas
-      if (expandAllBranches) {
-        const { allOf, ...restOfSchema } = resolvedSchema;
-        return [...(allOf as S[]), restOfSchema as S];
-      }

retrieveSchema.ts is now a four-line deletion against v7 instead of a thirty-line addition.

Finding Outcome
Exponential compile with nested allOf Fixed — 15,631 merges / 60.2s → 7 / <1ms
Option's properties from its own allOf Fixed — each option is parsed, not read off
Duplicate $id fails the compile Fixed — first wins and warns; matches how AJV8Validator.isValid keys its cache
Same gap with the default merge Fixed — root cause
ALL_OF_KEY in merged matches undefined/[] Moot — guard deleted
if without else, and dependencies Deferred, see thread
catch swallows merge errors Moot — goes through mergeAllOf(), which warns
Merged schema re-run through the pipeline Fixed by construction — one code path
Tuple items / additionalItems Fixed, plus patternProperties/additionalProperties, which had the same gap
Parent property parsed N+1 times Fixed — identity Set before retrieveSchemaInternal()
Pick instead of a new interface Done — SchemaContext back to its v7 shape
Fixture copied into three files Done — shared in utils/test/testUtils

The two processRawValidationErrors comments are against 7dd953a48: that rootSchema plumbing was dropped in the rebase after mutation checking showed it unreachable, and both files are byte-identical to v7 again.

Why narrowing what gets compiled is safe

Dropping the branches removes schemas from the compiled map, and under-compiling throws at runtime, so this is the part I did not want to argue from reading alone:

  • Every schemaParser() snapshot is byte-identical, including superSchema (6 entries) and SCHEMA_WITH_ALLOF_CANNOT_MERGE (3). The branch expansion was contributing nothing to the parsed map on the existing corpus; it only cost permutations.
  • @rjsf/validator-ajv8 3242, @rjsf/validator-ata 1595 and @rjsf/utils 2232 tests pass at 100% coverage. Those suites drive real forms against precompiled maps, which is where a missing function shows up.
  • Removing the four lines again fails 9 tests.
  • The failure path is faithful too: when the merge throws, a form drops the allOf and renders the rest, so that — not the branches — is what expand-all returns.
  • expandAllBranches: true has exactly one caller, schemaParser.ts:41 (getDefaultFormState.ts:943 passes undefined), so none of this can change form behavior — only what gets compiled.

Two pre-existing defects this review surfaced

Both outside this PR; say the word and I'll open issues.

  1. if without else, and dependencies, never produce the unapplied variant — your thread. The fix doubles the expansion per if and per dependency key, which is the cost this commit removes, so it wants its own perf budget.

  2. relaxOptionsForScoring() keeps the $id (retrieveSchema.ts:877). It returns { ...schema, additionalProperties: true }, and AJV caches by $id first, so the relaxed clone is validated by the strict compiled function — in the regular validator, today:

    relaxed keeps $id: strict-opt   additionalProperties: true
    strict  isValid(extra key): false
    relaxed isValid(extra key): false   (should be true)
    relaxed w/o $id isValid(extra key): true
    

    So oneOf scoring silently doesn't relax for any $id-carrying option with additionalProperties: false. Stripping the $id from the clone fixes it, but changes option selection, so not here.

CI is green locally on this commit: lint, knip, build (16 projects), typecheck and test (15 projects), plus cs-check.

const valueSchemas: unknown[] = Object.values(schema[PROPERTIES_KEY] ?? {});
// An additional property is only stubbed into `properties` once the form data has a key for it, which the parse has
// none of, so the schema a form renders those keys with is reached here instead
valueSchemas.push(...Object.values(schema[PATTERN_PROPERTIES_KEY] ?? {}), schema[ADDITIONAL_PROPERTIES_KEY]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Pattern/additional property schemas are parsed raw, but the form merges them through customMergeAllOf first

When the form has data for an extra key, stubExistingAdditionalProperties() builds that key's schema with retrieveSchema({ allOf: Object.values(matchingProperties) }). That allOf goes through the context's customMergeAllOf, even when only one pattern matches. If several patterns overlap, they're merged into one schema. The parser here pushes each raw patternProperties value on its own and never builds that merged schema.

Repro: patternProperties: { '^x': { properties: { choice: CHOICE } } } with titleChoiceMergeAllOf, plus form data { x1: { choice: 'b' } }. The form validates against the titled options. Those options were never compiled, so you get No precompiled validator function was found (#5338 again, through patternProperties). Two overlapping patterns that each have a oneOf fail the same way, even with the default merge.

Suggested fix: parse { allOf: [pattern] } for each pattern, and { allOf: [...] } for each overlapping set, so the parser's path matches the form's.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed, and this was the important one. Fixed in 03d3185.

Your repro reproduces exactly. With the fix removed, the new end-to-end test in both validator packages fails with:

Error: No precompiled validator function was found for the given schema for "1691cd08"

the same hash #5338 itself reports, now reached through patternProperties instead of a top-level allOf.

parseValueSchemas() now pushes { allOf: [...combination] } for each non-empty combination of patternProperties, which is the same shape stubExistingAdditionalProperties() hands retrieveSchema(), down to the single pattern a lone match makes. The raw pattern values are no longer parsed, since a form resolves nothing from one on its own. A parse has no form data, so it cannot know which key matches which patterns and has to cover all 2^n - 1 of them. Measured cost, with a merge that counts its calls:

patterns merges ms
1 1 1
2 3 0
4 15 0
6 63 1
8 255 4

One correction on the second half: I could not get the default merge to fail this way. shallowAllOfMerge({ allOf: [p1, p2] }) where both define choice with a oneOf returns the first one's options:

merged = {"properties":{"choice":{"oneOf":[{"const":"a"},{"const":"b"}]}}}

so the merged schema's options are a single pattern's, which is parsed on its own either way. It is a customMergeAllOf rewriting the merge that produces options nothing else reaches. I enumerate the combinations regardless — a custom merge can rewrite any subset, and that is the case this PR exists for — but say the word if you would rather pay the 2^n only when a customMergeAllOf is present.

Tests: parses each combination of patternProperties a key can match, as the form merges every one it matches (asserts the merge is called with [first], [second], [first, second]), parses the allOf merged for a key its patternProperties match with it, and the end-to-end covers the sub-schemas of the merge the form makes for that key in both validators, off the shared SCHEMA_MERGED_FOR_PATTERN_KEY fixture.

if (!existing) {
this.schemaMap[key] = identifiedSchema;
} else if (!deepEquals(existing, identifiedSchema)) {
if (ownId !== undefined) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

A schema with an $id that comes in several variants now just warns, and the precompiled validator gives wrong answers for every variant after the first

getFirstMatchingOption() builds augmentedSchema = { ...option, ...requiresAnyOf } with required deleted. relaxOptionsForScoring() makes a copy with additionalProperties: false turned into true. Both keep the option's $id. Take oneOf: [{ $ref: '#/definitions/Cat' }] with Cat = { $id: 'Cat', properties: {...}, additionalProperties: false }. The parser sees two different Cat schemas. It used to throw. Now it keeps only the first (strict) one. At runtime getValidator() looks up by $id, so the relaxed scoring call from omitExtraData's handleOneOf runs against the strict function. That gives false negatives, which is exactly what relaxOptionsForScoring exists to prevent. The augmented variant also validates without its anyOf-of-required.

The only sign of this is a one-time warning. The cleaner fix is lower down: strip $id in the code that builds these variants (getFirstMatchingOption, relaxOptionsForScoring, mergeSchemas of option + parent), so each variant gets its own hash key, instead of dropping variants here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed, and your fix is the better one — I took it and reverted my drop-and-warn. Fixed in 03d3185.

getFirstMatchingOption() now drops the $id from the schema it augments, and relaxOptionsForScoring() from the relaxed copy. Each variant is keyed by its content, all of them get compiled, and ParserValidator.addSchema() is back to the loud Two different schemas exist with the same key rather than keeping the first. The doc paragraph and changelog bullet about first-wins are gone with it.

This also fixes the runtime side you point at, not just the parse. Your Cat example, parsed:

-42e24cca => {"type":"object","properties":{"name":{...}},"additionalProperties":false,"anyOf":[{"required":["name"]}]}
-3b33eb27 => {"type":"object","properties":{"name":{...}},"additionalProperties":true,"anyOf":[{"required":["name"]}]}

Both now exist, each under its own key, so the relaxed scoring call from omitExtraData's handleOneOf gets its own function instead of the strict one. No warning, nothing dropped.

One exception I had to carve out: the junk option. precompiledValidator.isValid() recognises it by schema[ID_KEY] === JUNK_OPTION_ID and returns false without a compiled function, and getClosestMatchingOption() puts it through getFirstMatchingOption(), where it takes the augmentation path (it has properties). A blanket delete made the augmented junk schema look like an ordinary one and threw No precompiled validator function was found. So:

if (augmentedSchema[ID_KEY] !== JUNK_OPTION_ID) {
  delete augmentedSchema[ID_KEY];
}

A symbol marker on JUNK_OPTION would survive the spread and remove the special case, but that changes a public constant and both validators' isValid(), so I left it for its own PR unless you want it here.

Not covered, and worth its own issue: MultiSchemaField calls isValid(retrievedOptions[selectedOption], ...) on a resolved option, which can carry a $ref'd definition's $id while differing from it, and the parser never captures that one at all. Different defect from this finding.

Tests: parses each variant of an option that has an $id under a key of its own (the parse throws without the fix), parses the relaxed variant of an $id option that has no properties under a key of its own (the case where the option reaches isValid() unaugmented, which is what makes the relaxOptionsForScoring() half load-bearing), and calls isValid() with the $id dropped from the option it augments, keeping only the junk option's.

}
// resolve allOf schemas. A form always merges an `allOf`, so even `expandAllBranches` merges it: the subschemas it
// validates against are the merged schema's, which the unmerged branches need not hold -- a `customMergeAllOf` can
// rewrite them, and only the merged schema has the properties its `patternProperties` apply to

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Merging a property with its matching patternProperties still resolves that property with expandAllBranches off

Now that expand mode merges allOf, more schemas reach the PROPERTIES_KEY && PATTERN_PROPERTIES_KEY block below (around line 770), which is exactly the case the PR wants to cover. That block calls retrieveSchemaInternal(context, { allOf: [prop, ...matching] }, rootSchema, getByPath(rawFormData, key), undefined, ...). Passing undefined means expandAllBranches is false. So for a pattern-matched property:

  • resolveCondition keeps only the branch picked by ParserValidator.isValid(), which always returns false, so it always takes else;
  • processDependencies skips every dependency, because the form data has no key for it.

Repro: properties: { p: { if: {...}, then: { properties: { c: { oneOf: [...] } } } } } with patternProperties: { '^p$': {...} }. The then branch's oneOf options are never compiled, so the precompiled validator throws once the user's data meets the if.

Fix: pass expandAllBranches through, and use flatMap over the results instead of [acc.properties[key]] =.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed. Fixed in 03d3185, both halves as you suggested.

Proof it reproduces — the same schema parsed with and without the patternProperties:

WITH pattern:    2 entries (the root, and the `if` condition)
WITHOUT pattern: 4 entries (the root, the condition, and both `then` oneOf options)

The then branch's options are compiled only when nothing merges the property, exactly as you describe.

expandAllBranches is passed through now, and the reduce fans the parent out over the property's variants instead of taking [0]:

withMergedProperties = Object.keys(resolvedSchema.properties!).reduce(
  (schemas: S[], key) =>
    schemas.flatMap((schemaSoFar) => {
      const matchingProperties = getMatchingPatternProperties(schemaSoFar, key);
      if (Object.keys(matchingProperties).length === 0) {
        return schemaSoFar;
      }
      return retrieveSchemaInternal<T, S, F>(
        context,
        { allOf: [schemaSoFar.properties![key], ...Object.values(matchingProperties)] } as S,
        rootSchema,
        getByPath<T>(rawFormData, key),
        expandAllBranches,
        ...
      ).map((mergedProperty) => ({
        ...schemaSoFar,
        properties: { ...schemaSoFar.properties, [key]: mergedProperty },
      }));
    }),
  [{ ...resolvedSchema, properties: { ...resolvedSchema.properties } } as S],
);

stubExistingAdditionalProperties() moved inside a flatMap over the result for the same reason. With expandAllBranches false each inner call returns exactly one schema, so every form path is unchanged — the fan-out is paid only by the parser, and only for a property that a patternProperties entry matches.

Tests: should expand the branches of a property merged with the patternProperties that match it in retrieveSchemaTest.ts, which asserts both branches come back, and parses the conditional branches of a property its patternProperties also match in the parser tests. Both fail with expandAllBranches put back to undefined.

// An option can hold an `allOf`, conditions or dependencies of its own, which only parsing the option resolves.
// The schema is parsed alongside its options rather than being taken as covered by them, since merging an option
// into the schema can replace one of the schema's own subschemas (a property's `oneOf`, say)
for (const option of resolveAnyOrOneOfSchemas<T, S, F>(context, localSchema, rootSchema, true)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Each option now runs retrieveSchemaInternal() again over the parent's properties, so the identity parsed set doesn't save anything when the parent has patternProperties

Each option is mergeSchemas(remaining, item), so it carries the parent's properties and patternProperties. parseSchema(option) runs the property/pattern merge again for every option. Each merge makes fresh property objects, so state.parsed (which checks identity) never matches them, and they get parsed once per option. Every merged option and property is also pushed onto recurseList, where findIndex(deepEquals) is O(n²) over large schemas.

Suggested fix: key recurseList/parsed by hashForSchema() in a Set<string>. That covers both content and identity, and turns the linear deepEquals scan into O(1) lookups.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed, with one deviation on the key. Fixed in 03d3185.

Measured on a schema with 150 properties each holding a 3-option oneOf, a patternProperties entry and 20 top-level options:

state ms entries
identity Set + recurseList.findIndex(deepEquals) 86 471
content-keyed Set<string> 32 471

Same 471 sub-schemas, 2.7x faster, and all 13 schemaParser snapshots byte-identical.

The deviation: I key by sortedJSONStringify() rather than hashForSchema(). Same O(1) lookup — it is the string hashForSchema() hashes, and it compares as deepEquals() does here since both ignore symbol keys — but the 32-bit hash can collide, and a collision in a recursion guard is silent: the colliding schema is treated as already walked and its sub-schemas are never parsed, surfacing much later as No precompiled validator function was found. addSchema() at least throws on a hash collision. The cost is memory, one string per walked schema instead of a reference; happy to switch to the hash if you would rather have that.

Both sets are content-keyed now, which also means the parsed guard catches an option that carries the parent's property objects even after a merge has rebuilt them — the case your comment describes, which identity missed.

* the hash of the schema to schema/sub-schema.
*
* @param rootSchema - The root schema to parse for sub-schemas used by `isValid()` calls
* @param [options={}] - The `SchemaParserOptions` to parse with; pass the same `customMergeAllOf` the form uses, so the

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The schemaParser() API reference wasn't updated

packages/docs/docs/api-reference/utility-functions.md (### schemaParser) still lists only rootSchema under Parameters. It also still says the parser recurses "through properties and items". It doesn't mention the new options/customMergeAllOf parameter or the newly parsed patternProperties, additionalProperties, tuple items and additionalItems. The ajv8/ata references and the upgrade guide were updated; this page was missed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed — missed it. Fixed in 03d3185.

### schemaParser now lists the options parameter, and the recursion sentence names what it actually walks: properties, the patternProperties and additionalProperties a form renders the keys they describe with, and items including every tuple position and additionalItems. It also documents that a key the patternProperties match is parsed as the merge of every pattern matching it, and corrects "keyed by the hash" to the $id-or-hash the lookup actually uses.

The two validator API references had the same stale [parserOptions={}] bullet; both now describe the single CompileValidatorOptionsType<S> from the next comment.

schema: S,
output: string,
options: CustomValidatorOptionsType = {},
parserOptions: SchemaParserOptions<S> = {},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

parserOptions is a second trailing options bag, right after this changelog's breaking change that removed exactly that kind of call shape

The createPrecompiledValidator() entry above says its options were folded into one object "so a call supplying only the merge no longer has to pass two undefineds". Here, a caller who only wants the merge has to write compileSchemaValidators(schema, output, {}, { customMergeAllOf }) (and compileSchemaValidatorsCode(schema, {}, {...})). The same customMergeAllOf then has to be passed separately to createPrecompiledValidator(). A customMergeAllOf field on the existing options (or one options object shared by compile and create) would make the call simpler and keep the two calls from drifting apart.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fair, and the contradiction with the entry right above it is the part that settles it. Fixed in 03d3185.

Both packages gained a CompileValidatorOptionsType<S> that extends their CustomValidatorOptionsType with the customMergeAllOf, and compileSchemaValidators() / compileSchemaValidatorsCode() take that one bag:

-compileSchemaValidators(yourSchema, output, {}, { customMergeAllOf });
+compileSchemaValidators(yourSchema, output, { customMergeAllOf });

I put it on a new type rather than on CustomValidatorOptionsType itself, since that one is also what customizeValidator() takes, where a customMergeAllOf means nothing — the regular validator gets it from the form's SchemaContext.

createPrecompiledValidator() still takes its own: one call runs in the compile script and the other in the app, so there is no one object to share. The docs now say to give the same customMergeAllOf to both, and the compile options' JSDoc says so at the field.

SchemaParserOptions stays as schemaParser()'s own parameter type; the validator packages just hold a customMergeAllOf and hand it over.

addSchema(schema: S, hash: string) {
const key = schema[ID_KEY] ?? hash;
const ownId = schema[ID_KEY];
const key = ownId ?? hash;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

$id: '' gets a different key here than at runtime

Here key = ownId ?? hash, so $id: '' maps to the key '', and because ownId !== undefined, its variants also take the new drop-and-warn path. The precompiled validator's getValidator() uses schema[ID_KEY] || hashForSchema(schema), so at runtime it looks up the hash. The compiled function sits under '' and the lookup misses, giving No precompiled validator function was found. || here (and if (ownId)) would match the runtime lookup.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed. Fixed in 03d3185 — it is || now, and the comment says why.

Proof, a oneOf option carrying $id: '', printing the key each schema is mapped under against the key a validator would look it up by:

before:  key=""          runtimeKey="-258051b4"
after:   key="511cd14b"  runtimeKey="511cd14b"

Every key matches its runtime lookup now. The if (ownId !== undefined) branch you mention is gone entirely with the drop-and-warn, so there is no second place for '' to take the wrong path.

Test: calling isValid() with an empty $id maps the schema under its hash, as a validator looks it up, which fails with ?? put back.

Comment thread CHANGELOG-v7.md Outdated
- Fixed `schemaParser()` skipping the sub-schemas that only a `oneOf`/`anyOf` option has: the `properties` and `items` of the option itself, and anything its own `allOf`, conditions or dependencies resolve to, since an option's `properties` were read off directly rather than the option being parsed. Each option is now parsed in full, and the parent schema's own properties are no longer parsed again for every option ([#5338](https://github.com/rjsf-team/react-jsonschema-form/issues/5338))
- Fixed `schemaParser()` parsing only an `items` that is a single schema, leaving the positions of a tuple `items` and the `additionalItems` schema unparsed even though `ArrayField` renders them ([#5338](https://github.com/rjsf-team/react-jsonschema-form/issues/5338))
- `schemaParser()` keeps the first of two schemas that share an `$id` and differ, warning once, rather than throwing `Two different schemas exist with the same key`. A precompiled validator function is looked up by `$id` when there is one, so the first schema compiled under it answers for every variant of it the resolution produces ([#5338](https://github.com/rjsf-team/react-jsonschema-form/issues/5338))
- Fixed `schemaParser()` leaving the `additionalProperties` and `patternProperties` schemas unparsed. A key they describe is only stubbed into `properties` once the form data has it, which a parse has none of, so a `oneOf` under either threw `No precompiled validator function was found for the given schema` as soon as a user added such a key. The `additionalItems` of an `items` that is not a tuple is no longer parsed, since `ArrayField` renders nothing from one ([#5338](https://github.com/rjsf-team/react-jsonschema-form/issues/5338))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The changelog says something that was never true

"The additionalItems of an items that is not a tuple is no longer parsed". The old parser never parsed additionalItems at all. It only followed a non-array, non-boolean items. So no behaviour was removed, and the sentence (and the matching test name) implies a change that didn't happen. Suggest dropping the sentence.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed and dropped. Fixed in 03d3185.

The old parser, at bb96e6ab5:

if (ITEMS_KEY in s && !Array.isArray(s.items) && typeof s.items !== 'boolean') {
  parseSchema<T, S, F>(context, recurseList, rootSchema, s.items as S);
}

additionalItems is never read, so nothing was removed and the sentence is gone.

I kept the test. Its name — parses no additionalItems of an items that is not a tuple, as a form renders none — states the behavior rather than claiming a change, and it locks the else if (ITEMS_KEY in schema) branch so a later "parse everything" edit cannot quietly start compiling a sub-schema ArrayField renders nothing from. Happy to rename it if it still reads as a changelog claim to you.

for (const valueSchema of valueSchemas) {
// A boolean or missing sub-schema describes no value of its own, so there is nothing in it to parse
if (isObject(valueSchema)) {
parseSchema<T, S, F>(context, state, rootSchema, valueSchema as S);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Avoidable type casts in new code

valueSchema as S here, and allOf as RJSFSchema[], mergeSchemas(acc, s) as RJSFSchema and o as RJSFSchema in test/testUtils/customMergeAllOfData.ts and schemaParser.test.ts. These casts are avoidable. Typing valueSchemas as (S | boolean | undefined)[] and narrowing with a type-guarding isObject (or typeof v === 'object') would remove the cast.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 03d3185, all but one.

parseValueSchemas() narrows with a local type guard, as you suggested, so there is no cast at the call:

function isSchemaObject<S extends StrictRJSFSchema = RJSFSchema>(valueSchema: unknown): valueSchema is S {
  return isObject(valueSchema);
}

isObject() on its own narrows to GenericObjectType, which is what forced the as S; this narrows to the schema the parser walks and carries the "a boolean or missing sub-schema describes no value of its own" rationale in one place. Pushing { allOf: patterns } through the same unknown[] means that one needs no cast either.

In the fixture and the test, allOf as RJSFSchema[] and o as RJSFSchema are gone — const { allOf = [], ... } plus isObject narrowing on the members, which also makes the boolean-subschema case explicit instead of implicitly casting it away.

The one I left is mergeSchemas(acc, s) as RJSFSchema. mergeSchemas() is declared (obj1: GenericObjectType, obj2: GenericObjectType) and returns the accumulator, so the cast is the signature's rather than the call's — retrieveSchema.ts casts it the same way (mergeSchemas(remaining, item) as S). Making it generic would be a nice cleanup but it is a public API and the comment inside it says the wider type causes "a bunch of type errors downstream", so not in this PR.

heath-freenome added a commit that referenced this pull request Oct 1, 2026
Addresses the review of #5371.

A form always merges an `allOf`, so the sub-schemas it validates against are
the merged schema's. `expandAllBranches` returned the unmerged branches
instead, which hold neither the sub-schemas a `customMergeAllOf` rewrote nor
the ones that only exist once the merge has brought a property together with
the `patternProperties` matching it. Gating a second, merge-aware path on
`customMergeAllOf`, as this branch did, left the second case broken whenever no
custom merge was passed, and re-resolved the merged schema in a way the form
does not, which is what made the recursion guard necessary.

Expanding all branches now merges the `allOf`, and drops one it cannot merge,
through the same code the form uses, so the two cannot diverge. The branch
variants were also what `resolveSchema()`'s permutation of the `allOf` members
multiplied: a root `allOf` of six three-item `allOf`s went from 15,631 merges
in 60.2s to 7 in under a millisecond. Every `schemaParser()` snapshot is
unchanged, so the branches were contributing nothing to the parsed map.

Also in the parser:

- Each `oneOf`/`anyOf` option is parsed rather than having its `properties`
  read off, so an option whose sub-schemas come from its own `allOf`,
  conditions or dependencies is reached. A parsed set held by identity keeps a
  property the options share from being resolved once per option.
- The `patternProperties` and `additionalProperties` schemas are parsed. A key
  they describe is only stubbed into `properties` once the form data has it,
  which a parse has none of, so nothing reached them.
- Every position of a tuple `items` is parsed, with `additionalItems` only for
  a tuple, matching what `ArrayField` renders.
- Two schemas that share an `$id` and differ no longer fail the parse. The
  first is compiled and answers for the rest, which is how both the
  precompiled and the regular validators look a schema up.

`SchemaParserOptions` is a `Pick` of `SchemaContext` rather than an interface
`SchemaContext` extends, leaving the context type as v7 has it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@heath-freenome

Copy link
Copy Markdown
Member Author

Thanks @jimmycallin — second round answered in 03d3185, pushed on top of the two commits you reviewed so the diff against what you read is clean. All nine reproduce; all nine are fixed.

# Finding Outcome
1 patternProperties parsed raw, not merged Fixed — each combination parsed as the allOf the form merges
2 $id variants warn and mis-validate Fixed your way — $id dropped where the variant is built; drop-and-warn reverted
3 Property/patternProperties merge resolves with expansion off Fixed — expandAllBranches passed through, flatMap over the results
4 Identity parsed set, O(n²) deepEquals scan Fixed — content-keyed sets, 86ms → 32ms
5 schemaParser() API reference not updated Fixed, plus the two validator references' stale parserOptions bullet
6 Second trailing options bag Fixed — one CompileValidatorOptionsType
7 $id: '' keyed differently than at runtime Fixed — ||, matching getValidator()
8 Changelog claims a behaviour that never existed Dropped
9 Avoidable casts Fixed, except mergeSchemas()'s own signature

The one that mattered most is #1. Removing the fix makes the new end-to-end test in both validator packages throw No precompiled validator function was found for the given schema for "1691cd08" — #5338's own hash, reached through patternProperties rather than a top-level allOf. Exactly the hole you described.

#2 made the PR smaller. You were right that the fix belongs where the variants are built, not where the parse gives up on them. getFirstMatchingOption() and relaxOptionsForScoring() now drop the $id, each variant is keyed by its content and compiled, and ParserValidator.addSchema() goes back to failing loudly — which took the warning, the doc paragraph and the changelog bullet with it. It also fixes the runtime mis-validation in the regular validator, which my version only papered over. The junk option is the one carve-out, since precompiledValidator.isValid() recognises it by its $id.

Two places I diverged, both argued in the inline replies:

  • Prepare for packaging. #1's "even with the default merge": I could not construct that. shallowAllOfMerge of two patterns that both define choice keeps the first one's oneOf, so the merged options are a single pattern's and are parsed anyway. It takes a customMergeAllOf to produce options nothing else reaches. I enumerate the combinations regardless, at 2^n - 1 merges per schema (n=8 → 255 merges, 4ms) — say so if you would rather pay it only when a customMergeAllOf is present.
  • Fields & widgets #4's key: sortedJSONStringify() rather than hashForSchema(). Same O(1), but a 32-bit collision in a recursion guard is silent — the schema is taken as walked and its sub-schemas never parsed — where addSchema() at least throws. Costs a string per walked schema.

Also surfaced, not fixed here: MultiSchemaField calls isValid() on a resolved option that can carry a $ref'd definition's $id while differing from it, and the parse never captures that form. Same family as #2, different call site. Added to "Not fixed here" alongside the if-without-else gap; happy to open issues for either.

Every fix is mutation-checked individually. lint, knip, build, the root typecheck and test (15 projects) are green, @rjsf/utils and both validators at their enforced 100% coverage, and all 13 schemaParser() snapshots are still byte-identical.

@heath-freenome

Copy link
Copy Markdown
Member Author

Thanks @jimmycallin — this round was the most useful yet. All 15 reproduce; six were real bugs, three of them mine from the last round. 478f18412.

# Finding Verdict
1 getObjectDefaults() reads an unmerged nested allOf Confirmed, mine. Parser records the entries and the rest alongside the merge
2 omitExtraData() reads the entries a merge leaves in place Confirmed, mine. Same fix
3 O(K²) on the form path Confirmed, mine. 2000 props: 603ms → 7.4ms
4 Empty expansion crashes the parser Confirmed, mine. Fixed in withExactlyOneSubschema()
5 Dropping $id drops the base URI for relative $refs Confirmed, mine. Derived ?rjsf=<hash> instead
6 MultiSchemaField scores retrieved options Confirmed, pre-existing. Fixed; description corrected
7 / 14 Parent fan-out is the product of branch counts Confirmed, mine. Your suggestion taken; 2 ** k → k + 1
8 2^n past ~16 patterns Taken: cap at 16 with a message saying why
9 Only the precompiled validators use || Half confirmed: ajv8 is a real bug; ata's cache re-checks, so no correctness issue there
10 Not a breaking change Correct. Labels and checkbox dropped
11 CompileValidatorOptionsType, not SchemaParserOptions Correct. Guide now names it
12 Diff-style comment, not constant time Correct. Both reworded
13 New as/! Correct, except one as S that is the base commit's own
15 Don't gate the 2^n on customMergeAllOf Correct. Not gated; your schema is a test in both packages

Mutation evidence, each with the hash you quoted:

Mutation Fails with
Remove the unmerged-allOf parse -4c020a92 and -4fca741c
Remove the retrieved-option scoring -1ca39cdf
Narrow the pattern loop to single patterns 73557177
Parse the raw patternProperties values 1691cd08
Delete the derived $id instead option scores 0 instead of 1
Restore the cartesian reduce 8 variants instead of 4

Two corrections to the findings, both narrow:

#9, the ata half. getOrBuild() re-checks the cached entry with deepEquals, so a shared '' key can't make ata answer with another schema's validator. I wrote the equivalent of the ajv8 test there and it passed under ?? too, so I deleted it rather than keep a test that can't fail. What the shared key does cost ata is cache thrash, so I changed the key anyway and the changelog says that rather than claiming a correctness fix. A review of this round then found the same ?? in validator-cfworker and in both packages' rawValidation(), which shares that cache — all four now fixed.

#2, one test I inverted rather than kept. parses the allOf with it dropped when it throws, as the form does asserted the entries of an unmergeable allOf are not parsed. They are now, because the parser records them before attempting the merge. That's over-collection in the throwing case; I took it deliberately, since over-collection costs bundle bytes and under-collection throws at runtime.

Two things I'd rather you decided than me, both on the cap:

  • 16 patterns still admits 65,535 combinations. A cap on combinations would express the intent better than a cap on patterns.
  • Most combinations are unreachable anyway — ^a with ^b can never co-match, and a key already named in properties is skipped by stubExistingAdditionalProperties(). A cheap reachability filter would buy more than lowering the number.

And one new pre-existing defect, now in "Not fixed here": a oneOf option carrying an $id that resolves into different content — { $id: 'opt1', allOf: [...] } — still throws Two different schemas exist with the same key opt1. Reproduces at 649e09837. Same family as the derived-$id fix: the resolved option doesn't get one the way the augmented and relaxed ones now do. Happy to open issues for that and for the if-without-else gap.

Locally green: lint, knip, build, test, cs-check, with the three enforcing packages at 100% coverage.

heath-freenome added a commit that referenced this pull request Oct 2, 2026
Addresses the review of #5371.

A form always merges an `allOf`, so the sub-schemas it validates against are
the merged schema's. `expandAllBranches` returned the unmerged branches
instead, which hold neither the sub-schemas a `customMergeAllOf` rewrote nor
the ones that only exist once the merge has brought a property together with
the `patternProperties` matching it. Gating a second, merge-aware path on
`customMergeAllOf`, as this branch did, left the second case broken whenever no
custom merge was passed, and re-resolved the merged schema in a way the form
does not, which is what made the recursion guard necessary.

Expanding all branches now merges the `allOf`, and drops one it cannot merge,
through the same code the form uses, so the two cannot diverge. The branch
variants were also what `resolveSchema()`'s permutation of the `allOf` members
multiplied: a root `allOf` of six three-item `allOf`s went from 15,631 merges
in 60.2s to 7 in under a millisecond. Every `schemaParser()` snapshot is
unchanged, so the branches were contributing nothing to the parsed map.

Also in the parser:

- Each `oneOf`/`anyOf` option is parsed rather than having its `properties`
  read off, so an option whose sub-schemas come from its own `allOf`,
  conditions or dependencies is reached. A parsed set held by identity keeps a
  property the options share from being resolved once per option.
- The `patternProperties` and `additionalProperties` schemas are parsed. A key
  they describe is only stubbed into `properties` once the form data has it,
  which a parse has none of, so nothing reached them.
- Every position of a tuple `items` is parsed, with `additionalItems` only for
  a tuple, matching what `ArrayField` renders.
- Two schemas that share an `$id` and differ no longer fail the parse. The
  first is compiled and answers for the rest, which is how both the
  precompiled and the regular validators look a schema up.

`SchemaParserOptions` is a `Pick` of `SchemaContext` rather than an interface
`SchemaContext` extends, leaving the context type as v7 has it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
heath-freenome added a commit that referenced this pull request Oct 3, 2026
Addresses the review of #5371.

A form always merges an `allOf`, so the sub-schemas it validates against are
the merged schema's. `expandAllBranches` returned the unmerged branches
instead, which hold neither the sub-schemas a `customMergeAllOf` rewrote nor
the ones that only exist once the merge has brought a property together with
the `patternProperties` matching it. Gating a second, merge-aware path on
`customMergeAllOf`, as this branch did, left the second case broken whenever no
custom merge was passed, and re-resolved the merged schema in a way the form
does not, which is what made the recursion guard necessary.

Expanding all branches now merges the `allOf`, and drops one it cannot merge,
through the same code the form uses, so the two cannot diverge. The branch
variants were also what `resolveSchema()`'s permutation of the `allOf` members
multiplied: a root `allOf` of six three-item `allOf`s went from 15,631 merges
in 60.2s to 7 in under a millisecond. Every `schemaParser()` snapshot is
unchanged, so the branches were contributing nothing to the parsed map.

Also in the parser:

- Each `oneOf`/`anyOf` option is parsed rather than having its `properties`
  read off, so an option whose sub-schemas come from its own `allOf`,
  conditions or dependencies is reached. A parsed set held by identity keeps a
  property the options share from being resolved once per option.
- The `patternProperties` and `additionalProperties` schemas are parsed. A key
  they describe is only stubbed into `properties` once the form data has it,
  which a parse has none of, so nothing reached them.
- Every position of a tuple `items` is parsed, with `additionalItems` only for
  a tuple, matching what `ArrayField` renders.
- Two schemas that share an `$id` and differ no longer fail the parse. The
  first is compiled and answers for the rest, which is how both the
  precompiled and the regular validators look a schema up.

`SchemaParserOptions` is a `Pick` of `SchemaContext` rather than an interface
`SchemaContext` extends, leaving the context type as v7 has it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
heath-freenome added a commit that referenced this pull request Oct 3, 2026
Addresses the review of #5371.

A form always merges an `allOf`, so the sub-schemas it validates against are
the merged schema's. `expandAllBranches` returned the unmerged branches
instead, which hold neither the sub-schemas a `customMergeAllOf` rewrote nor
the ones that only exist once the merge has brought a property together with
the `patternProperties` matching it. Gating a second, merge-aware path on
`customMergeAllOf`, as this branch did, left the second case broken whenever no
custom merge was passed, and re-resolved the merged schema in a way the form
does not, which is what made the recursion guard necessary.

Expanding all branches now merges the `allOf`, and drops one it cannot merge,
through the same code the form uses, so the two cannot diverge. The branch
variants were also what `resolveSchema()`'s permutation of the `allOf` members
multiplied: a root `allOf` of six three-item `allOf`s went from 15,631 merges
in 60.2s to 7 in under a millisecond. Every `schemaParser()` snapshot is
unchanged, so the branches were contributing nothing to the parsed map.

Also in the parser:

- Each `oneOf`/`anyOf` option is parsed rather than having its `properties`
  read off, so an option whose sub-schemas come from its own `allOf`,
  conditions or dependencies is reached. A parsed set held by identity keeps a
  property the options share from being resolved once per option.
- The `patternProperties` and `additionalProperties` schemas are parsed. A key
  they describe is only stubbed into `properties` once the form data has it,
  which a parse has none of, so nothing reached them.
- Every position of a tuple `items` is parsed, with `additionalItems` only for
  a tuple, matching what `ArrayField` renders.
- Two schemas that share an `$id` and differ no longer fail the parse. The
  first is compiled and answers for the rest, which is how both the
  precompiled and the regular validators look a schema up.

`SchemaParserOptions` is a `Pick` of `SchemaContext` rather than an interface
`SchemaContext` extends, leaving the context type as v7 has it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@heath-freenome

Copy link
Copy Markdown
Member Author

Follow-up in 330a8eb53, from a deeper review pass over the branch. Three things, one of which is a real bug in the same family as #9 above.

A root schema with $id: '' could not resolve its own $refs. I fixed the ?? in isValid() and rawValidation() last round but missed handleSchemaUpdate(), which registers the root. With rootSchema[ID_KEY] ?? ROOT_SCHEMA_PREFIX, a root carrying $id: '' registers under the empty name while withIdRefPrefix() goes on rewriting local $refs to resolve against __rjsf_rootSchema — nothing is registered there, so every ref into such a root fails to compile:

isValid({ $ref: '#/definitions/aStr' }, 'hello', { $id: '', definitions: { aStr: { type: 'string' } }, … })
// false, after logging: can't resolve reference __rjsf_rootSchema#/definitions/aStr

ajv8 and ata both fixed, with a regression test in each that fails on the old code. @rjsf/validator-cfworker always used ROOT_SCHEMA_PREFIX unconditionally, so it was never affected.

The retrieved-option scoring shared one recurseList across sibling options, so a $ref resolved while walking option 1 counted as already-seen for option 2. resolveAllReferences() already copies the list per child in its properties loop for exactly this reason, with a comment about the false positives a shared list causes; the new loop now does the same.

Dropped the ata-validator changelog bullet. The rebase onto v7 brought ^1.40.1 in through #5422, so this branch no longer touches packages/validator-ata/package.json and the bullet described a bump it doesn't make, with two stale version numbers. The _re1 problem it described is moot at 1.40.1.

Two more that reproduce identically at the base commit, so not from this PR, but worth recording:

  • A recursive $ref inside a oneOf on a schema that also has dependencies overflows the stack in schemaParser() — { type: 'object', properties: { a, b }, dependencies: { a: ['b'] }, oneOf: [{ $ref: '#' }, …] }.
  • A root schema with any non-empty $id hits the same __rjsf_rootSchema resolution failure as the empty one. The fix above only covers the empty case; the general one needs a decision about whether withIdRefPrefix() should prefix against the root's own $id.

Happy to open issues for those two alongside the if-without-else gap and the { $id: 'opt1', allOf: [...] } collision.

Still green locally: lint, knip, build, test, cs-check, with @rjsf/utils and all three validators at 100% coverage.

heath-freenome added a commit that referenced this pull request Oct 4, 2026
Addresses the review of #5371.

A form always merges an `allOf`, so the sub-schemas it validates against are
the merged schema's. `expandAllBranches` returned the unmerged branches
instead, which hold neither the sub-schemas a `customMergeAllOf` rewrote nor
the ones that only exist once the merge has brought a property together with
the `patternProperties` matching it. Gating a second, merge-aware path on
`customMergeAllOf`, as this branch did, left the second case broken whenever no
custom merge was passed, and re-resolved the merged schema in a way the form
does not, which is what made the recursion guard necessary.

Expanding all branches now merges the `allOf`, and drops one it cannot merge,
through the same code the form uses, so the two cannot diverge. The branch
variants were also what `resolveSchema()`'s permutation of the `allOf` members
multiplied: a root `allOf` of six three-item `allOf`s went from 15,631 merges
in 60.2s to 7 in under a millisecond. Every `schemaParser()` snapshot is
unchanged, so the branches were contributing nothing to the parsed map.

Also in the parser:

- Each `oneOf`/`anyOf` option is parsed rather than having its `properties`
  read off, so an option whose sub-schemas come from its own `allOf`,
  conditions or dependencies is reached. A parsed set held by identity keeps a
  property the options share from being resolved once per option.
- The `patternProperties` and `additionalProperties` schemas are parsed. A key
  they describe is only stubbed into `properties` once the form data has it,
  which a parse has none of, so nothing reached them.
- Every position of a tuple `items` is parsed, with `additionalItems` only for
  a tuple, matching what `ArrayField` renders.
- Two schemas that share an `$id` and differ no longer fail the parse. The
  first is compiled and answers for the rest, which is how both the
  precompiled and the regular validators look a schema up.

`SchemaParserOptions` is a `Pick` of `SchemaContext` rather than an interface
`SchemaContext` extends, leaving the context type as v7 has it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@heath-freenome heath-freenome changed the title Fix 5338: compile precompiled validators with the form's customMergeAllOf Fix 5338 & 5373: compile precompiled validators with the form's customMergeAllOf Oct 4, 2026
return;
}
state.parsed.add(parsedKey);
const { [ALL_OF_KEY]: allOf, ...withoutAllOf } = schema;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

An allOf reached through if/then or dependencies loses its unmerged entries (regression vs 9af375731)

The unmerged allOf walk only runs when the allOf sits on the schema handed to parseSchema(). Now that retrieveSchemaInternal() merges even when expanding all branches, an allOf that only appears after resolution (a then/else, a dependency) is merged inside it, and the parser never sees the entries. A merge that leaves them in place (your identityMergeAllOf) then throws at runtime. Before this PR, the expanded-branch return covered that case.

const c = { customMergeAllOf: identityMergeAllOf };
const viaCond = { type: 'object', properties: { u: { if: { required: ['zz'] }, then: SCHEMA_UNMERGED_ALL_OF, else: SCHEMA_UNMERGED_ALL_OF } } };
omitExtraData({ validator: precompiled(viaCond, c), ...c }, viaCond, viaCond, { u: UNMERGED_ALL_OF_FORM_DATA });
const viaDep = { type: 'object', properties: { t: { type: 'string' } }, dependencies: { t: SCHEMA_UNMERGED_ALL_OF } };
createSchemaUtils({ validator: precompiled(viaDep, c), ...c }, viaDep).omitExtraData(viaDep, { t: 'x', ...UNMERGED_ALL_OF_FORM_DATA });
                 9af375731   c16e1a7ba
if/then          ok          THREW No precompiled validator function ... for "-4fca741c"
dependencies     ok          THREW ... for "-4fca741c"

Suggested fix: also walk the allOf of each localSchema that retrieveSchemaInternal() returns (for example, parseSchema() each entry when ALL_OF_KEY in localSchema), rather than only the input's. That covers every way of reaching an allOf, and state.parsed already stops repeats.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed and fixed. Both variants threw -4fca741c at c16e1a7ba, exactly as you have them.

Taken as suggested, with one adjustment: the walk runs on each localSchema that retrieveSchemaInternal() returns as well as on the schema handed in, not instead of it. Your ALL_OF_KEY in localSchema check only fires for a merge that leaves the entries in place — identityMergeAllOf here — and with the default merge the resolved schema has no allOf at all while the declared one does, so dropping the input-level walk loses the case the existing parses the entries of an allOf alongside the merge test covers. Both now go through one parseUnmergedAllOf(), and state.parsed stops the repeats as you said it would.

New test: parses the entries of an allOf only reached through a condition or a dependency, which runs both your shapes. Removing the localSchema call fails it.

const retrievedOptions = anyOrOneOf.flatMap((item) =>
// Each option gets its own copy of the list, since a `$ref` one of them resolves is not one a sibling has
// already been through, the same reason the `properties` loop of `resolveAllReferences()` copies it
retrieveSchemaInternal<T, S, F>(context, item, rootSchema, formData, true, [...recurseList]),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

A retrieved option keeps the declared option's $id but not its content, so ParserValidator rejects it (regression for ata)

retrievedOptions are passed to getFirstMatchingOption(). An option without properties goes through the plain isValid(option) branch with its $id unchanged. If retrieval changes the option (merges its allOf, or picks an if branch), ParserValidator.addSchema() sees two different schemas under one key and throws.

{ type: 'object', properties: { v: { oneOf: [{ $id: 'str', allOf: [{ type: 'string' }, { minLength: 1 }] }, { type: 'number' }] } } }
{ type: 'object', properties: { v: { oneOf: [{ $id: 'str', type: 'string', if: { minLength: 3 }, then: { maxLength: 9 }, else: { pattern: 'a' } }, { type: 'number' }] } } }
                          9af375731         c16e1a7ba
schemaParser()            ok                THREW Two different schemas exist with the same key str!
ata compile...Code()      ok                THREW (same)

ajv8 already fails these at compile with schema with key or id already exists on both commits, so for ajv8 this isn't new. ata compiled them before this PR.

Suggested fix: derive the $id for retrieved options too. Run withVariantId() on a retrieved option that isn't deep-equal to the declared one, or apply it in the isValid(option) branch of getFirstMatchingOption(). That's the same reasoning withVariantId() already uses for the augmented and relaxed variants.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed and fixed your way. Both shapes threw Two different schemas exist with the same key str! at c16e1a7ba.

withVariantId() is now applied in the isValid(option) branch of getFirstMatchingOption(), so the plain branch derives an $id the way the augmented one already did, with the junk option still carved out.

That surfaced a second-order problem worth recording: relaxOptionsForScoring() already derives one, so scoring a relaxed option derived a variant of a variant and produced bare?rjsf=99e78ad?rjsf=99e78ad. Consistent between the parse and the runtime, since both go through this function, but nonsense. withVariantId() now replaces a suffix it has already added rather than appending, so it is idempotent and the two paths name one schema.

This changed an existing test. parses the relaxed variant of an $id option that has no properties under a key of its own asserted schemaMap.bare exists, on the premise your comment overturns — that an option with no properties is scored as it stands so its own $id names it. It now asserts both the strict and the relaxed forms are keyed bare?rjsf=… and that the bare key is gone.

getFirstMatchingOption<T, S, F>(context, formData, relaxed, rootSchema, discriminator);
// `MultiSchemaField` scores the options it has retrieved rather than the ones the schema declares, so an option
// that resolves into something else -- one that is an `allOf`, say -- is scored in that resolved form too
const retrievedOptions = anyOrOneOf.flatMap((item) =>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Retrieved options are scored with {} form data, so an option with additionalProperties still throws once the user adds a key (pre-existing, but this block claims to cover it)

MultiSchemaField retrieves each option with the real form data. stubExistingAdditionalProperties() then adds every additional key to properties, and getFirstMatchingOption() adds that key to the requiresAnyOf augmentation. So the hash (or, with $id, the ?rjsf= suffix) depends on what keys the user has, and a parse that has no form data can't enumerate them.

const rootSchema = { type: 'object', properties: { pet: { oneOf: [
  { type: 'object', properties: { meow: { type: 'string' } }, additionalProperties: { type: 'string' } },
  { type: 'object', properties: { bark: { type: 'string' } } },
] } } };
const formData = { meow: 'x', extra: 'y' };
schemaUtils.getClosestMatchingOption(formData, options.map((o) => schemaUtils.retrieveSchema(o, formData)), 0);
// THREW No precompiled validator function was found for the given schema for "9461a84"
// with `$id: 'cat'` on the first option: ... for "cat?rjsf=9461a84"

This throws the same way on 9af375731, so it isn't a regression. But the comment here says retrieved options are covered, and they aren't for this shape. Either narrow the comment and the changelog bullet, or open a follow-up issue. Scoring options with stubbed additional keys can't be precompiled without leaving the stubbed keys out of the augmentation.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed, pre-existing, and not fixed — the comment and the changelog are narrowed instead, which is the first of the two options you offered.

I could not see a way to precompile it. The stubbed keys are in the option's own properties by the time it is scored, so the schema itself varies with the user's data, not just the augmentation; leaving the stubs out of the anyOf would not change the hash. A parse has no form data and cannot enumerate them.

The code comment now ends:

It retrieves with the real form data, which this has none of, so an option whose retrieved form depends on the data is still reached only in the forms an empty retrieval produces: a key an option's additionalProperties describes is stubbed into its properties once the user adds one, and no parse can enumerate those.

and the changelog bullet carries the same caveat. Happy to open the follow-up issue — it belongs with the other four still outstanding.

context,
{ allOf: [properties[key], ...Object.values(matchingProperties)] } as S,
rootSchema,
getByPath<T>(rawFormData, key),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

recurseList resets to [] here, so a recursive schema with patternProperties overflows the stack (pre-existing; this call was rewritten in the PR)

const schema = {
  definitions: { node: { type: 'object', properties: { child: { $ref: '#/definitions/node' } }, patternProperties: { '^c': { type: 'object' } } } },
  $ref: '#/definitions/node',
};
schemaParser(schema);                                        // Maximum call stack size exceeded
retrieveSchema({ validator }, schema, schema, {});           // Maximum call stack size exceeded

child matches ^c. The { allOf: [child, pattern] } resolves the $ref back to node, which has child and the pattern again, and the undefined here starts every level with an empty recursion list. It fails the same way on 9af375731, and the form hits it as well as the parser. This line was rewritten anyway, so passing recurseList through costs nothing.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed — overflowed in both schemaParser() and retrieveSchema() at c16e1a7ba, and at the base.

Passing recurseList through does not work, and it took two tries to find out why. Worth writing down, because the trap is not visible from this line.

resolveAllReferences() gives each property its own [...recurseList] (line 402) and then merges every child's list back into the caller's (lines 417–421). resolveSchema() then re-enters retrieveSchemaInternal() with that same array. So by the time a property is merged with its matching patterns, the list holds what its siblings resolved, and a copy taken here copies the contamination:

{ type: 'object',
  definitions: { x: { type: 'string' }, y: { maxLength: 7 } },
  properties: { aa: { $ref: '#/definitions/x' }, other: { $ref: '#/definitions/y' } },
  patternProperties: { '^a': { $ref: '#/definitions/y' } } }
seed properties.aa
v7 (fresh list) { type: 'string', maxLength: 7 }
recurseList { type: 'string', $ref: '#/definitions/y' }
[...recurseList] { type: 'string', $ref: '#/definitions/y' }
shipped { type: 'string', maxLength: 7 }

other resolves #/definitions/y first, so aa's pattern reads as a cycle and a literal $ref reaches SchemaField and customMergeAllOf with the constraint dropped. That is a rendering regression, strictly worse than the overflow it was fixing.

What shipped seeds the merge with the one reference this schema was itself reached through — its RJSF_REF_KEY — and a fresh copy of it per key, nothing from recurseList:

case v7 shipped
sibling-resolved $ref in a pattern correct correct
self-recursive $ref under a matched key overflow ok
mutual recursion (a.cb → b, b.ca → a, both ^c) overflow overflow

No case is worse than v7 and the one you reported is fixed. Mutual recursion still overflows, identically at the base — I'd rather that went in the issue alongside the recursive-$ref-in-oneOf overflow than get a second seeding rule here.

Three tests, one per property of the fix: resolves a $ref in a patternProperties entry that a sibling property resolved first, …for every key it matches, not just the first (the per-key copy — the nested call mutates the seed), and parses a recursive $ref under a key its patternProperties match. Each of the three seeds above fails a different one.

Comment thread packages/validator-ajv8/src/types.ts Outdated
/** The options a schema is compiled into precompiled validator functions with: everything `customizeValidator()` takes,
* plus the form's `customMergeAllOf`, since the sub-schemas that get compiled are the ones that merge produces
*/
export interface CompileValidatorOptionsType<

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

CompileValidatorOptionsType is defined twice and repeats SchemaParserOptions

This interface is copied word for word into validator-ata/src/types.ts:63, and its one added field is the SchemaParserOptions<S> this PR adds to @rjsf/utils. Using type CompileValidatorOptionsType<S> = CustomValidatorOptionsType & SchemaParserOptions<S> in both packages, or one shared definition, keeps the doc comment and the field in one place. Otherwise the next option added to SchemaParserOptions has to be mirrored by hand in two validators.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Correct, done. Both packages now have

export type CompileValidatorOptionsType<S extends StrictRJSFSchema = RJSFSchema> = CustomValidatorOptionsType &
  SchemaParserOptions<S>;

so the field and its doc comment live in SchemaParserOptions only, and the next option added there needs no mirroring. The changelog bullet in each package says so rather than carrying a separate bullet for the type's shape.

const key = schema[ID_KEY] ?? hash;
// An empty `$id` names nothing, and a validator looks a schema up by `schema[ID_KEY] || hashForSchema(schema)`, so
// one is mapped under its hash here too or the lookup finds nothing compiled
const key = schema[ID_KEY] || hash;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The "$id or hash" key rule is now restated in 8 places across 4 packages

The same rule now lives as schema[ID_KEY] || hashForSchema(schema) plus a near-identical comment in ajv8 validator.ts:263 and precompiledValidator.ts:95, ata validator.ts:141/235 and precompiledValidator.ts:96, cfworker validator.ts:144/227, and the comment here. The bug this round fixed came from those copies drifting (?? in some, nothing in others). A schemaKey(schema) helper exported from @rjsf/utils, next to hashForSchema, would enforce what this comment only describes, and the copied comments could go.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Correct, done — this is the one I'd have regretted skipping, since you are right that the drift is where the ?? family came from.

schemaKey() is a new @rjsf/utils export next to hashForSchema:

export function schemaKey<S extends StrictRJSFSchema = RJSFSchema>(schema: S): string {
  return schema[ID_KEY] || hashForSchema<S>(schema);
}

All seven code sites call it — ajv8's two, ata's three, cfworker's two — and the copied comments are gone with them. ParserValidator.addSchema() is the eighth: it took a precomputed hash only to apply the same rule, so it now takes addSchema(schema) and calls schemaKey() itself. The class is not exported from the package index and nothing outside src/parser/ calls it, so I treated that as internal; say if you'd rather keep the signature.

The doc comment states the rule your comment described — same $id ⇒ same validation, and a derivation that changes meaning derives its own — and points at withVariantId(), which is the other half you asked for in the third pass. It's in the API reference and the v7 upgrade guide too.

ajv8's rawValidation() is deliberately not on the list: it keys AJV's own cache by $id or object reference, which is a different mechanism.

const retrievedOptions = anyOrOneOf.flatMap((item) =>
// Each option gets its own copy of the list, since a `$ref` one of them resolves is not one a sibling has
// already been through, the same reason the `properties` loop of `resolveAllReferences()` copies it
retrieveSchemaInternal<T, S, F>(context, item, rootSchema, formData, true, [...recurseList]),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

An option with a schema dependencies is only scored with the dependency applied, so data without the key still throws

retrieveSchemaInternal(item, …, true) goes through processDependencies(), which applies every dependency when expanding (expandAllBranches || getByPath(formData, dependencyKey) !== undefined, retrieveSchema.ts:999) and never returns the option with the dependency left out. MultiSchemaField retrieves the option with the real data, so when the key is absent it scores the option with dependencies dropped and nothing merged. That schema doesn't match the hash of the declared option (which still carries dependencies) or of the expanded one (which carries c).

const schema = {
  oneOf: [
    { type: 'object', properties: { a: { type: 'string' } }, dependencies: { a: { properties: { c: { type: 'number' } } } } },
    { type: 'object', properties: { d: { type: 'string' } } },
  ],
};
// formData { d: 'x' }: options.map((o) => retrieveSchema(o, formData)), then getClosestMatchingOption(formData, retrieved, 0)

schemaParser(schema) doesn't contain this, so the precompiled validator throws No precompiled validator function was found while rendering:

{"type":"object","properties":{"a":{"type":"string"}},"anyOf":[{"required":["a"]}]}

On 9af3757 the same schema misses this one and the applied variant, so it isn't a regression. It is the case this block is meant to cover, though. When expanding, processDependencies() could return the schema without the dependency next to the applied one. That would cover this without a second retrieval here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed and fixed, but not in processDependencies() — that shape is unaffordable.

Returning the unapplied variant next to the applied one multiplies per key, since each key branches into both states and the next key branches off both:

dependency keys schemaParser() map entries
8 7 ms 1
12 106 ms 1
16 2147 ms 1

One entry at every size. The 2 ** k intermediate merges reach no isValid() call at all — a oneOf in the base schema augments from its own properties, so which dependencies merged in doesn't change it, and a oneOf inside a dependency value is now reached directly (your next comment). It is pure cost.

So resolveDependencies() adds the all-unapplied variant once, after processDependencies() has returned, and only while expanding:

if (!expandAllBranches || applied.some((appliedSchema) => deepEquals(appliedSchema, resolvedSchema))) {
  return applied;
}
return [...applied, resolvedSchema];

16 keys: 1 ms. That covers your repro and the common case — a form before the user has filled any dependency key in. The subsets in between are the 2 ** k and are not covered; the comment says so rather than implying they are.

New test: parses an option with its dependency left unapplied, as a form scores it before the key is filled in. It asserts the exact schema, because objectContaining matched the declared option through its leftover dependencies key and passed under mutation — my mistake, caught on the mutation check.

const { [ALL_OF_KEY]: allOf, ...withoutAllOf } = schema;
if (allOf) {
// A form does not validate only against the merge of an `allOf`: `getObjectDefaults()` reads a nested object's
// unmerged `properties`, and `omitExtraData()` reads the entries a merge leaves in place. So the entries and what

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

omitExtraData()'s scoring of a dependency's oneOf is still never parsed (pre-existing)

This walk is justified by what omitExtraData() reads. But handleDependencies() → handleOneOf() (omitExtraData.ts:318 / 252) also scores a dependency's oneOf options through getClosestMatchingOption(), which validates each one augmented. The parser reaches those options only through withExactlyOneSubschema(). That validates the { type: 'object', properties: { [key]: … } } condition schemas, never the options themselves.

The playground's schemaDependencies sample with its own formData misses all three:

{"properties":{"Do you have any pets?":{"enum":["No"]}},"anyOf":[{"required":["Do you have any pets?"]}]}
{"properties":{"Do you have any pets?":{"enum":["Yes: One"]},"How old is your pet?":{"type":"number"}},"anyOf":[…]}
{"properties":{"Do you have any pets?":{"enum":["Yes: More than one"]},"Do you want to get rid of any?":{"type":"boolean"}},"anyOf":[…]}

So a precompiled form with omitExtraData throws on submit. It's the same on 9af3757, so it's not from this PR and probably belongs in a follow-up issue. I'm noting it because this comment and the changelog read as though omitExtraData() is now covered.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed and fixed. Your playground repro missed all three options at c16e1a7ba and omitExtraData() threw -5c27b707 on submit.

parseSchema() now parses each schema dependencies value in its own right, before resolution gets to it:

// `omitExtraData()` applies a schema dependency by walking the dependency's own schema and scoring any `oneOf` it
// declares, where resolution only ever validates the conditions `withExactlyOneSubschema()` builds out of it
for (const dependencyValue of Object.values(schema[DEPENDENCIES_KEY] ?? {})) {

That routes the dependency's oneOf through resolveAnyOrOneOfSchemas()'s expand branch, which is the same augmented and relaxed scoring handleOneOf() does, so all three options are collected.

This is the one change that moves snapshots: two of the thirteen grow, by exactly those augmented options. The snapshot diff for the whole PR is insertions only (154 0) — the other eleven are byte-identical and nothing left a compiled map.

Tests: parses the options of a dependency's oneOf, which omitExtraData() scores in @rjsf/utils, and an end-to-end omitExtraData() through a precompiled validator in both validator packages.

);
const schemaUtils = createSchemaUtils({ validator }, rootSchema);
const formData = { meow: 'x' };
const options = (rootSchema.properties!.pet as RJSFSchema).oneOf as RJSFSchema[];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

! and as are back in the new tests

(rootSchema.properties!.pet as RJSFSchema).oneOf as RJSFSchema[] shows up here, in validator-ata/test/compileSchemaValidatorsCode.test.ts:237 and in validator-ajv8/test/validator.test.ts:271 (.pick). The fixtures live in parsedSchemaData.ts, so exporting each option list as an RJSFSchema[] and building the root schema from it would remove all three, the same way the earlier test casts were removed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Correct, done, and the fixtures route you suggested is what I took.

ONE_OF_ALL_OF_REF_OPTIONS is exported from parsedSchemaData.ts as an RJSFSchema[] and SCHEMA_ONE_OF_ALL_OF_REF is built from it, so both compileSchemaValidatorsCode.test.ts sites just map over the export. The third, in validator-ajv8/test/validator.test.ts, has a schema local to the file, so the same shape applies locally: a const pickOptions: RJSFSchema[] the root schema is built from.

No ! or as left in any of the three. grep -rn "properties!\." packages/validator-*/test is empty.

heath-freenome and others added 6 commits October 5, 2026 15:29
…llOf

A precompiled validator only has functions for the sub-schemas
schemaParser() collected, keyed by their hash. The parser had no way to
receive a customMergeAllOf, so a form whose merge produced different
sub-schemas threw "No precompiled validator function was found for the
given schema".

schemaParser() takes an optional SchemaParserOptions, holding the
customMergeAllOf, and parses with a SchemaContext built from it.
SchemaContext now extends SchemaParserOptions. compileSchemaValidators()
and compileSchemaValidatorsCode() in @rjsf/validator-ajv8 and
@rjsf/validator-ata take the same options as a trailing argument. When
given a customMergeAllOf, retrieveSchemaInternal()'s expand-all path also
returns each allOf merged with it and resolved the rest of the way, so
the parser collects the sub-schemas the form validates against.

Also fixed while here:
- schemaParser() took a schema as covered by the options it resolves to,
  so a property that every oneOf/anyOf option redefines was parsed only
  as each option spells it, never as the schema's own.
- Upgraded ata-validator to ^1.32.0. The ^1.23.0 v7 pins resolves to
  standalone bundles that throw "_re1 is not defined" for a schema with
  patternProperties, so the precompiled ata validator could not validate
  one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	CHANGELOG-v7.md

# Conflicts:
#	CHANGELOG-v7.md

# Conflicts:
#	packages/validator-ata/package.json
#	pnpm-lock.yaml

# Conflicts:
#	CHANGELOG-v7.md

# Conflicts:
#	CHANGELOG-v7.md
Addresses the review of #5371.

A form always merges an `allOf`, so the sub-schemas it validates against are
the merged schema's. `expandAllBranches` returned the unmerged branches
instead, which hold neither the sub-schemas a `customMergeAllOf` rewrote nor
the ones that only exist once the merge has brought a property together with
the `patternProperties` matching it. Gating a second, merge-aware path on
`customMergeAllOf`, as this branch did, left the second case broken whenever no
custom merge was passed, and re-resolved the merged schema in a way the form
does not, which is what made the recursion guard necessary.

Expanding all branches now merges the `allOf`, and drops one it cannot merge,
through the same code the form uses, so the two cannot diverge. The branch
variants were also what `resolveSchema()`'s permutation of the `allOf` members
multiplied: a root `allOf` of six three-item `allOf`s went from 15,631 merges
in 60.2s to 7 in under a millisecond. Every `schemaParser()` snapshot is
unchanged, so the branches were contributing nothing to the parsed map.

Also in the parser:

- Each `oneOf`/`anyOf` option is parsed rather than having its `properties`
  read off, so an option whose sub-schemas come from its own `allOf`,
  conditions or dependencies is reached. A parsed set held by identity keeps a
  property the options share from being resolved once per option.
- The `patternProperties` and `additionalProperties` schemas are parsed. A key
  they describe is only stubbed into `properties` once the form data has it,
  which a parse has none of, so nothing reached them.
- Every position of a tuple `items` is parsed, with `additionalItems` only for
  a tuple, matching what `ArrayField` renders.
- Two schemas that share an `$id` and differ no longer fail the parse. The
  first is compiled and answers for the rest, which is how both the
  precompiled and the regular validators look a schema up.

`SchemaParserOptions` is a `Pick` of `SchemaContext` rather than an interface
`SchemaContext` extends, leaving the context type as v7 has it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r it

A form renders a key its `patternProperties` match with the merge of every
pattern matching it, which `stubExistingAdditionalProperties()` resolves as
`{ allOf: [...matching] }`. The parser pushed each pattern's own schema
instead, so a `customMergeAllOf` that rewrites a sub-schema of that merge
never saw it and the form threw `No precompiled validator function was
found for the given schema`. Each combination of patterns is now parsed as
that `allOf`, since a parse has no form data to tell it which key matches
which patterns.

Merging a property with the `patternProperties` matching it also resolved
that property with the branch expansion off, even while expanding all
branches for the parser, so its conditions collapsed to their `else` and
its dependencies were skipped. `expandAllBranches` is passed through and
the parent fans out over the property's variants.

`getFirstMatchingOption()` and `relaxOptionsForScoring()` kept the `$id` of
the option they derive a schema from. A validator caches the function it
compiles under that `$id`, so every variant was answered by the function
compiled for the first of them -- the relaxed variant validating as
strictly as the one it was meant to relax. Both drop it now, so each
variant is keyed by its content and each is compiled, and `ParserValidator`
goes back to failing loudly on a genuine key collision rather than keeping
the first and warning. The junk option keeps its `$id`, which the
precompiled validators answer it by.

A schema whose `$id` is the empty string was mapped under that `$id`, where
no validator looks for it: both the precompiled and the regular ones fall
back to the hash for one.

`compileSchemaValidators()` and `compileSchemaValidatorsCode()` take the
`customMergeAllOf` in their existing options rather than in a second
trailing bag, as the new `CompileValidatorOptionsType`.

The parser keys the schemas it has walked by content rather than scanning a
list with `deepEquals()`, which cost quadratically in a schema's properties
and options: 150 properties and 20 options parse in 32ms rather than 86ms,
to the same 471 sub-schemas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s both

A form does not validate only against the merge of an `allOf`:
`getObjectDefaults()` reads a nested object's unmerged `properties`, and
`omitExtraData()` reads the entries a merge leaves in place, scoring the
`oneOf` options it finds in each. Merging the `allOf` when expanding all
branches therefore stopped compiling sub-schemas a form still asks about,
which threw `No precompiled validator function was found for the given
schema` for a rewriting or a partial `customMergeAllOf`. `parseSchema()`
now parses the entries and the rest of the schema alongside the merge.

`resolveAnyOrOneOfSchemas()` scored only the options a schema declares,
where `MultiSchemaField` scores the ones it has retrieved, so an option
that is a `$ref` to an `allOf` definition was never compiled in the form
that gets validated. Each option's retrieved form is now scored too.

`withExactlyOneSubschema()` returned no schema at all when expanding a
dependency's `oneOf` that no option qualifies for, and the empty list
reached `getAllPermutationsOfXxxOf()` as `undefined`. Such a `oneOf` is
now ignored, as it already is when the form data picks no single option.

The variants of the properties merged with their matching
`patternProperties` are varied one property at a time rather than in
every combination, so `k` two-branch properties make `k + 1` variants
and not `2 ** k`, and the `properties` object is rebuilt once rather
than once per matched key, which had cost the form's own resolution
quadratically in them: 2000 properties went from 5.6ms to 603ms, and is
now 7.4ms.

`getFirstMatchingOption()` and `relaxOptionsForScoring()` derive the
`$id` of the variant they build rather than dropping the option's:
`<the option's>?rjsf=<the variant's hash>` names the variant while
keeping the option's as the base a relative `$ref` left inside it
resolves against, which dropping it broke.

`schemaParser()` reports a schema with more than 16 `patternProperties`
rather than enumerating the `2 ** n - 1` combinations of them.

The regular validators key a schema whose `$id` is the empty string by
its hash, as the precompiled ones do. For ajv8 that stopped two such
schemas sharing one compiled function; for ata and cfworker, which
re-check the cached schema, it stops them evicting each other, in
`rawValidation()` as well as `isValid()`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`handleSchemaUpdate()` registered the root schema under `rootSchema[ID_KEY]
?? ROOT_SCHEMA_PREFIX`, so a root carrying `$id: ''` registered under the
empty name while `withIdRefPrefix()` went on rewriting every local `$ref`
to resolve against `__rjsf_rootSchema`. Nothing was registered there, so
each `$ref` into such a root failed to compile and `isValid()` answered
`false` after logging `can't resolve reference`. An empty `$id` names
nothing, so the root falls back to the prefix as one with no `$id` does.
`@rjsf/validator-cfworker` always used the prefix and was unaffected.

Each `oneOf`/`anyOf` option gets its own copy of the `recurseList` when
it is retrieved for scoring: a `$ref` one option resolves is not one a
sibling has already been through, which is why the `properties` loop of
`resolveAllReferences()` copies it too.

Drops the `ata-validator` changelog bullet: the rebase onto v7 brought
`^1.40.1` in through #5422, so this branch no longer bumps the dependency
and the versions the bullet named were both stale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fourth review round. Six fixes, two narrowed claims and one cleanup.

An `allOf` is merged inside `retrieveSchemaInternal()`, so one reached
through a `then`/`else` or a dependency was merged before the parse saw
it and the entries `getObjectDefaults()` and `omitExtraData()` read
unmerged were never collected. They are now reached on what resolution
returns as well as on the schema the parse was handed.

`getFirstMatchingOption()` scored an option with no `properties` as it
stands, keeping the option's `$id`. `MultiSchemaField` scores the
options it has retrieved, so an option whose retrieval changes it is a
different schema under one key. Deriving the `$id` is also idempotent
now, so relaxing an option and then scoring the relaxed one names one
schema rather than stacking a second `?rjsf=` suffix on.

Merging a property with its matching `patternProperties` restarted the
list of resolved references, so a recursive `$ref` under a key one of
those patterns matches overflowed the stack, in the form's own
resolution as well as the parser's. The merge is seeded with the one
reference this schema was itself reached through, and with a fresh copy
of it per key. Not with `recurseList`: `resolveAllReferences()` merges
the references each property resolved back into the list it was given,
so that list -- and a list shared between the keys -- holds what another
key resolved, which reads as a cycle and leaves this key holding a
literal `$ref` in place of what it resolves to. Mutual recursion
between two schemas through their `patternProperties` still overflows,
as it does on v7; this makes no case worse than v7 and fixes the
self-recursive one.

Expanding all branches applied every dependency, where a form leaves one
unapplied until its key has a value and scores an option in that form.
The unapplied variant is returned alongside the applied ones. Doing this
per key, as suggested, is the product of the two states: 16 dependency
keys took 1ms -> 2147ms and produced no further schema, since the
intermediate merges reach no `isValid()` call. Only the all-unapplied
variant is added, which is 1ms again; the subsets in between are not
covered and the comment says so.

`omitExtraData()` scores the options of a dependency's `oneOf` itself,
where resolving the dependency only validates the conditions
`withExactlyOneSubschema()` builds out of them, so each schema
dependency is now parsed in its own right.

`schemaKey()` replaces the eight copies of `schema[$id] || hash` across
`ParserValidator` and the three validators, which is where the `??`
family of bugs came from, and `CompileValidatorOptionsType` is
`CustomValidatorOptionsType & SchemaParserOptions<S>` in both validators
rather than an interface restating the parser's options in each.

Two claims narrowed rather than fixed, both pre-existing. An option
whose `additionalProperties` stubs the keys a user adds into its
`properties` hashes differently per key, which no parse can enumerate;
the comment and the changelog say so instead of claiming retrieved
options are covered. The tuple-`items` casts are gone from the three
tests that had them, the option lists being exported from the fixtures.

Every new test fails when its fix is reverted. All 13 `schemaParser()`
snapshots are insertions only -- 11 byte-identical, 2 grown by exactly
the dependency `oneOf` options -- so nothing left a compiled map. lint,
knip, build, test and cs-check green, `@rjsf/utils` and all three
validators at 100% coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@heath-freenome

Copy link
Copy Markdown
Member Author

Thanks @jimmycallin — fourth round answered in a49fd2d0e, with per-thread replies above. All nine reproduce; six were real bugs, two of them regressions from this PR.

# Finding Verdict
1 allOf via if/then or dependencies loses its unmerged entries Confirmed, regression. Entries reached on what resolution returns as well as on the input
2 Retrieved option keeps the declared $id Confirmed, regression for ata. Fixed your way, in the plain scoring branch
3 Retrieved options scored with {} vs stubbed additional keys Confirmed, pre-existing. Not fixed — comment and changelog narrowed instead
4 recurseList reset → stack overflow Confirmed, pre-existing. Fixed, but not by passing recurseList through — see below
5 CompileValidatorOptionsType defined twice Correct. CustomValidatorOptionsType & SchemaParserOptions<S> in both
6 The $id-or-hash rule in 8 places Correct. schemaKey() exported from @rjsf/utils; all eight call it
7 Option's dependencies only scored applied Confirmed, pre-existing. Fixed, but not in processDependencies() — see below
8 A dependency oneOf's options never parsed Confirmed, pre-existing. Each schema dependency now parsed in its own right
9 ! and as back in the new tests Correct. Option lists exported from the fixtures; all three gone

Mutation evidence, each with the error you quoted:

Mutation Fails with
Drop the resolved-schema allOf walk -4fca741c, both your shapes
Score the plain option without a derived $id Two different schemas exist with the same key str!
Seed the pattern merge from recurseList sibling $ref left unresolved
Share one merge seed across keys second matching key left unresolved
Drop the merge seed entirely Maximum call stack size exceeded
Return only the applied dependency 511cd14b
Stop parsing schema dependencies -5c27b707, your playground sample

Two I did not take as written

#4 — passing recurseList through is a rendering regression, and I shipped it before catching that. resolveAllReferences() merges every property's resolved references back into the list it was given (retrieveSchema.ts:417), and resolveSchema() re-enters retrieveSchemaInternal() with that same array, so a copy taken at the merge copies what the siblings resolved. With properties.other and a matching patternProperties entry both pointing at one definition, properties.aa came back as { type: 'string', $ref: '#/definitions/y' } — constraint dropped, literal $ref handed to SchemaField. The merge is now seeded with the single reference the schema was itself reached through, and a fresh copy of it per key:

case v7 recurseList shipped
sibling-resolved $ref in a pattern correct broken correct
self-recursive $ref under a matched key overflow ok ok
mutual recursion through two patterns overflow overflow overflow

No case is worse than v7. Mutual recursion still overflows, identically at the base.

#7 — the unapplied variant per key multiplies. Returning it from processDependencies() branches each key into both states: 16 dependency keys went 1 ms → 2147 ms, and produced one map entry at every size, because the 2 ** k intermediate merges reach no isValid() call. resolveDependencies() adds the all-unapplied variant once instead, after the applied chain: 16 keys, 1 ms. The subsets in between are not covered and the comment says so.

Also in this round

The changelog went from 30 bullets to 11 — nine schemaParser() bullets were one story ending in No precompiled validator function was found for the given schema, so they are one bullet listing what it now reaches. schemaKey() is documented in the API reference and the v7 upgrade guide.

Still not fixed here

Three for issues, with #3 above: mutual recursion through patternProperties, and a root schema with any non-empty $id hitting the __rjsf_rootSchema resolution failure — the empty-$id fix only covered the empty case, and validator-cfworker already hard-codes ROOT_SCHEMA_PREFIX with a comment saying why. Happy to open all three alongside the four still outstanding from earlier rounds.

lint, knip, build, test (15 projects) and cs-check green, @rjsf/utils and all three validators at their enforced 100% coverage. The schemaParser() snapshot diff is insertions only (154 0): eleven of thirteen byte-identical, two grown by exactly the dependency oneOf options from #8, so nothing left a compiled map.

This branch has not been deployed

No deployments
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.

2 participants