Skip to content

fix(theme): hoist schema normalization out of render path#1527

Draft
sserrata wants to merge 17 commits into
mainfrom
fix/schema-render-perf-1525
Draft

fix(theme): hoist schema normalization out of render path#1527
sserrata wants to merge 17 commits into
mainfrom
fix/schema-render-perf-1525

Conversation

@sserrata

@sserrata sserrata commented Jun 29, 2026

Copy link
Copy Markdown
Member

Summary

Fixes #1525 — browser hangs on deeply recursive OpenAPI specs (e.g. Komga's ~17K-node search-filter schema) after upgrading to v5.1.0.

Root cause: Three O(subtree) recursive walkers added between v4.7.1 and v5.1.0 ran on every SchemaNode render, turning O(N) total work into O(N²):

Fix:

  • New Schema/normalize.ts with normalizeSchema() — single O(N) pass via useMemo that merges allOf, strips conflicting additionalProperties, and folds siblings into oneOf/anyOf branches
  • Revert SchemaNode to 4.7.1's O(1) schema.discriminator check (normalization promotes discriminators from allOf members to top level)
  • Revert DiscriminatorNode to 4.7.1's O(1) schema.properties?.[name] lookup
  • Add WeakMap cache to build-time stripConflictingAdditionalProps in createSchema.ts

Test plan

  • Verified against Komga repro (gotson/komga-website@docusaurus_3.10) — both previously-hanging pages ("List series", "List series groups") now render within ~3 seconds
  • Circular detection in schema model — isCircularMarker guard clauses handle circular markers in property, items, additionalProperties, and SchemaEdge positions
  • Regression tests with recursive fixture — circularRef.yaml (6 endpoints: self-ref, mutual recursion, additionalProperties, allOf composition, indirect recursion) and recursiveFilter.yaml (Komga-shaped discriminated recursive filter)
  • Verify existing allOf/oneOf/discriminator fixtures still render correctly — full demo site builds successfully

🤖 Generated with Claude Code

Three O(subtree) recursive walkers added between v4.7.1 and v5.1.0 ran
on every SchemaNode render, turning O(N) total work into O(N²) and
hanging the browser on deeply recursive specs like Komga's (~17K nodes):

- `findDiscriminator` (#1303) searched the entire subtree from every
  SchemaNode; in 4.7.1 this was `schema.discriminator` (O(1)).
- `findProperty` (#1303) searched the entire subtree from every
  DiscriminatorNode; in 4.7.1 this was `schema.properties?.[name]` (O(1)).
- `stripConflictingAdditionalProps` (#1463) deep-walked every value
  inside `mergeAllOf`, compounding with `foldSiblingsIntoBranches`
  (#1457) which called `mergeAllOf` per oneOf/anyOf branch.

Fix: introduce `normalizeSchema` (new `Schema/normalize.ts`) which does
a single O(N) pass via `useMemo` — merging allOf, stripping conflicting
additionalProperties, and folding siblings into oneOf/anyOf branches.
Revert SchemaNode to 4.7.1's O(1) discriminator check (normalization
promotes discriminators from allOf members to top level) and
DiscriminatorNode to O(1) property lookup. Add WeakMap cache to the
build-time `stripConflictingAdditionalProps` in createSchema.ts.

Closes #1525

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown

Size Change: +31.3 kB (+1.34%)

Total Size: 2.36 MB

📦 View Changed
Filename Size Change
demo/.docusaurus/globalData.json 81.7 kB +4.29 kB (+5.55%) 🔍
demo/.docusaurus/registry.js 118 kB +6.22 kB (+5.56%) 🔍
demo/.docusaurus/routes.js 112 kB +5.94 kB (+5.61%) 🔍
demo/.docusaurus/routesChunkNames.json 46.5 kB +2.48 kB (+5.64%) 🔍
demo/build/assets/js/main.********.js 697 kB +11.1 kB (+1.62%)
demo/build/assets/js/runtime~main.********.js 26.7 kB +1.22 kB (+4.81%) 🔍
ℹ️ View Unchanged
Filename Size
demo/.docusaurus/codeTranslations.json 2 B
demo/.docusaurus/docusaurus.config.mjs 16.4 kB
demo/.docusaurus/i18n.json 372 B
demo/.docusaurus/site-metadata.json 1.58 kB
demo/build/assets/css/styles.********.css 173 kB
demo/build/index.html 99.5 kB
demo/build/petstore/add-pet/index.html 30 kB
demo/build/petstore/create-user/index.html 24.7 kB
demo/build/petstore/create-users-with-array-input/index.html 24.8 kB
demo/build/petstore/create-users-with-list-input/index.html 24.8 kB
demo/build/petstore/delete-order/index.html 24.5 kB
demo/build/petstore/delete-pet/index.html 24.8 kB
demo/build/petstore/delete-user/index.html 25 kB
demo/build/petstore/find-pets-by-status/index.html 25.5 kB
demo/build/petstore/find-pets-by-tags/index.html 26.2 kB
demo/build/petstore/get-inventory/index.html 23.8 kB
demo/build/petstore/get-order-by-id/index.html 24.8 kB
demo/build/petstore/get-pet-by-id/index.html 25.6 kB
demo/build/petstore/get-user-by-name/index.html 25.1 kB
demo/build/petstore/login-user/index.html 25.6 kB
demo/build/petstore/logout-user/index.html 24.4 kB
demo/build/petstore/new-pet/index.html 25 kB
demo/build/petstore/pet/index.html 23.2 kB
demo/build/petstore/place-order/index.html 24 kB
demo/build/petstore/schemas/apiresponse/index.html 25.2 kB
demo/build/petstore/schemas/cat/index.html 39.1 kB
demo/build/petstore/schemas/category/index.html 26.3 kB
demo/build/petstore/schemas/dog/index.html 39.3 kB
demo/build/petstore/schemas/honeybee/index.html 39.4 kB
demo/build/petstore/schemas/id/index.html 23.4 kB
demo/build/petstore/schemas/order/index.html 27.4 kB
demo/build/petstore/schemas/pet/index.html 38.9 kB
demo/build/petstore/schemas/tag/index.html 24.7 kB
demo/build/petstore/schemas/user/index.html 40.8 kB
demo/build/petstore/store/index.html 22.2 kB
demo/build/petstore/subscribe-to-the-store-events/index.html 30.9 kB
demo/build/petstore/swagger-petstore-yaml/index.html 30.9 kB
demo/build/petstore/update-pet-with-form/index.html 25 kB
demo/build/petstore/update-pet/index.html 25.4 kB
demo/build/petstore/update-user/index.html 25 kB
demo/build/petstore/upload-file/index.html 24.8 kB
demo/build/petstore/user/index.html 22.9 kB

compressed-size-action

@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown

Visit the preview URL for this PR (updated for commit abf6870):

https://docusaurus-openapi-36b86--pr1527-9bwck37p.web.app

(expires Thu, 09 Jul 2026 23:08:25 GMT)

🔥 via Firebase Hosting GitHub Action 🌎

Sign: bf293780ee827f578864d92193b8c2866acd459f

Minimal OpenAPI spec modeled after Komga's search-filter schema: a
SearchCondition with a discriminator on `operator` whose AND/OR/NOT
branches recurse back into SearchCondition. After $ref dereferencing
this produces the same recursive oneOf + allOf + discriminator shape
that caused the browser hang.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
normalizeSchema was calling mergeAllOf on allOf arrays containing
circular-reference string markers (e.g. `allOf: ["circular(Title)"]`)
produced by loadAndResolveSpec's JSON.stringify roundtrip. allof-merge
doesn't handle string members and would discard the marker, breaking
SchemaEdge's existing circular reference rendering.

Skip the merge when any allOf member is a string.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Six endpoints testing circular $ref patterns:
- Self-referencing tree node (items position)
- Self-referencing category (property + items positions)
- Mutual recursion between Person and Address
- Circular ref via additionalProperties
- Circular ref inside allOf composition
- Deeply nested circular ref through intermediate schema

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Covers allOf merging, sibling folding, additionalProperties stripping,
circular reference marker preservation, recursive normalization, and
identity caching.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the separate WeakSet (normalizedSet) + per-call WeakMap with a
single module-level WeakMap that serves both purposes. Self-register
each result via cache.set(result, result) so re-normalization is an
identity no-op. Avoids stale entries persisting across unrelated renders.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
sserrata and others added 3 commits June 29, 2026 15:44
The renderer only handled circular string markers (e.g.
"circular(TreeNode)") when they appeared inside allOf. Markers in
property, items, and additionalProperties positions were silently
dropped.

Add early-return handlers in SchemaEdge (property position), Items
(array items position), and AdditionalProperties for circular string
markers. Also add a SchemaEdge route for arrays whose items is a
circular string so they reach SchemaNodeDetails -> Items.

Update circularRef.yaml fixture to include inline schema snippets in
endpoint descriptions, matching the convention of other test specs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
foldSiblingsIntoBranches returned only the branch key, dropping parent
metadata like title, description, and discriminator. Destructure out
the sibling keys (properties, required, type) that get merged into
branches and spread the remaining metadata onto the result.

Fixed in both normalize.ts and the inline copy in Schema/index.tsx.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Remove the inline copies of stripConflictingAdditionalProps, mergeAllOf,
and foldSiblingsIntoBranches from Schema/index.tsx and import them from
Schema/normalize.ts instead. The plugin-side copy in createSchema.ts
remains separate since the plugin and theme are different npm packages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Remove dead findProperty and findDiscriminator from Schema/index.tsx
- Wire cached getDiscriminator into SchemaNode as fallback for
  discriminators that allof-merge doesn't promote to top level
- Fix foldSiblingsIntoBranches to use METADATA_KEYS allowlist instead
  of hardcoded destructuring, preventing non-metadata keys from leaking
- Rename mergeAllOf parameter from allOf to schema for clarity
- Trim excessive multi-line comments across normalize.ts, index.tsx,
  and createSchema.ts
- Add tests for foldSiblingsIntoBranches and metadata/extension handling

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… scope

- Extract isCircularMarker() predicate to replace 4 scattered typeof
  checks with a stricter guard that validates the "circular(" prefix
- Remove the `&& working.properties` narrowing on the sibling folding
  gate so normalizeSchema folds all sibling keys (matching the original
  foldSiblingsIntoBranches behavior)
- Add tests for discriminator property defined only in oneOf branches

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…Discriminator

- Wrap normalizeSchema with a clean public signature, moving the
  WeakMap cache parameter into a private normalizeSchemaImpl function
- Collapse three identical oneOf/anyOf/allOf loops in getDiscriminator
  into a single loop over BRANCH_KEYS
- Document foldSiblingsIntoBranches call chain in JSDoc

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@sserrata sserrata marked this pull request as ready for review June 29, 2026 21:41
foldSiblingsIntoBranches was merging the parent discriminator (and other
metadata) into each oneOf/anyOf branch via allOf-merge. The merged
branches then carried their own discriminator, causing the inner
SchemaNode to render a SECOND DiscriminatorNode inside each tab with an
empty mapping ("Possible values: []"). Shared properties at the top
level (e.g. CommonProps.id, CommonProps.createdAt in
nested-discriminator-in-all-of) also went missing because folding
stripped them from the top-level schema, and DiscriminatorNode's
PropertyDiscriminator only renders shared props from schema.properties
at the top level.

Fix: skip foldSiblingsIntoBranches entirely when the schema has a
discriminator — DiscriminatorNode already handles top-level shared
properties via PropertyDiscriminator. Additionally exclude METADATA_KEYS
from the siblings set even for non-discriminator schemas, so metadata
never leaks into folded branches.

Affected visual regressions (7 of 15 diffs):
- discriminator-with-shared-properties-without-mapping
- discriminator-with-nested-schemas-without-mapping
- nested-discriminator-in-all-of
- deeply-nested-discriminator-in-all-of-chains
- doubly-nested-discriminators-issue
- all-of-with-multiple-one-of-constraints
- any-of-with-nested-one-of-and-properties-at-same-level

Update affected tests to assert the new correct behavior: when a
discriminator is present, top-level properties stay at the top level
and branches retain their own properties without sibling pollution.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@sserrata sserrata marked this pull request as draft June 30, 2026 14:48
…nators

Eager allOf merge in normalizeSchema caused cartesian product expansion
for allOf with multiple oneOf members (e.g. allof-multiple-oneof: 2 x 3
= 6 cartesian branches instead of two separate tab strips). Eager
foldSiblingsIntoBranches caused double-folding when SchemaNode's
renderChildren ran fold again — for any-of-with-nested-oneOf the
cascade stripped top-level properties and lost the second anyOf group.

Both eager operations were added to promote the discriminator property
to the top level so DiscriminatorNode's O(1) schema.properties lookup
would work. Replace this with a more surgical fix in DiscriminatorNode:

1. Eagerly merge top-level allOf at the start of DiscriminatorNode so
   shared properties contributed by allOf members (e.g. CommonProps in
   nested-discriminator-in-all-of) are accessible to
   PropertyDiscriminator's top-level rendering.
2. Move discriminatorProperty calculation AFTER the merge loop. The
   loop populates schema.properties[discriminator.propertyName] from
   branches, but the previous code captured the empty pre-loop value,
   causing missing "string" type annotations on discriminator headers
   in doubly-nested cases.

normalizeSchema is now an identity pass-through. The perf win for
#1525 still comes from getDiscriminator's WeakMap cache replacing the
O(subtree) findDiscriminator walk, plus the WeakMap cache in
stripConflictingAdditionalProps preventing redundant strip walks.
SchemaNode's existing allOf/renderChildren paths handle merging and
folding conditionally, matching pre-PR behavior for visual rendering.

Visual regressions fixed (3 of the 4 schema diffs):
- doubly-nested-discriminators-issue (missing type annotations)
- all-of-with-multiple-one-of-constraints (cartesian expansion)
- any-of-with-nested-one-of-and-properties-at-same-level (missing fields)

The 4th diff (discriminator-with-all-of-and-mapping) is an
improvement — preview now renders sharedProp that prod was missing.

Rewrite the test suite to assert the new behavior: normalizeSchema is
identity, mergeAllOf and foldSiblingsIntoBranches are tested directly,
getDiscriminator covers all branch types plus cycle handling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…is nested

Match prod parity: SchemaNode in main only calls mergeAllOf when the
discriminator is nested inside allOf (`schema.allOf && !schema.discriminator
&& resolvedDiscriminator`). When the discriminator is at the raw top level,
prod passes the unmerged schema to DiscriminatorNode, so shared properties
from other allOf members never appear at the top level.

Previous fix eagerly merged whenever `rawSchema.allOf` existed, which
pulled sharedProp from BaseAllOfMapping's second allOf member into the
top-level rendering (discriminator-with-all-of-and-mapping). While
technically an improvement (prod had a bug), the user wants prod parity.

Condition the merge on `!rawSchema.discriminator`:
- Discriminator at top level (BaseAllOfMapping): skip eager merge, schema
  stays as raw with just `properties: {type}` → matches prod
- Discriminator nested (NestedDiscriminatorBase, DoublyNestedBase): do
  eager merge, so `properties: {id, name, createdAt}` from allOf members
  are accessible → matches prod's `workingSchema = mergeAllOf(schema)`

When discriminator is at top level AND schema still has allOf, do a
temporary merge just to extract discriminatedSchemas (oneOf/anyOf) without
replacing schema — matches prod's original DiscriminatorNode behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previous attempts to fix doubly-nested-discriminators-issue by eagerly
merging allOf inside DiscriminatorNode still produced visual diffs vs
prod. Root cause: the eager merge in DiscriminatorNode created a
different object identity/mutation timing than prod's approach of
merging in SchemaNode before passing workingSchema down.

Revert to main's exact structure:
- SchemaNode: `workingSchema = mergeAllOf(schema)` when discriminator is
  nested in allOf (matches main line 1194-1211)
- DiscriminatorNode: no internal merge, uses schema directly and looks
  up discriminatorProperty via recursive walk (matches main line 447)

To preserve the #1525 perf fix, replace the O(subtree) findProperty walk
with a new cached `findPropertyDeep` (WeakMap keyed by object + property
name, sentinel-based cycle detection). Same O(1) amortized guarantee as
getDiscriminator. Both replace main's per-render recursive walks that
caused the O(N^2) render cost.

Add tests for findPropertyDeep: top-level lookup, oneOf/anyOf/allOf
recursion, missing property, primitive input, cycle handling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…le mutation reads

DiscriminatorNode's merge loop deletes the discriminator property from
each subschema.properties to prevent duplicate rendering inside tabs.
The WeakMap cache in stripConflictingAdditionalProps was returning the
pre-mutation deep clone on subsequent mergeAllOf calls, causing the
deleted properties to reappear as an extra "type" row inside nested
tab content (visible in doubly-nested-discriminators-issue between
timestamp and id).

Removing the cache matches prod's per-call behavior. Perf impact is
minimal — stripConflictingAdditionalProps is only called inside
mergeAllOf, which is invoked at most a handful of times per rendered
schema (once per DiscriminatorNode, once per SchemaEdge.allOf branch),
and the O(subtree) walk cost is dominated by allof-merge itself, not
the strip pass.

The cache was originally added as part of the perf fix, but the real
perf wins for #1525 come from getDiscriminator's cache (replacing the
per-render findDiscriminator walk) — not from caching mergeAllOf.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@sserrata sserrata deployed to preview July 2, 2026 23:07 — with GitHub Actions Active
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.

Some pages hang the browser

1 participant