+ )
+}
+
+AsyncAPIAuth.propTypes = {
+ schema: PropTypes.object.isRequired,
+ name: PropTypes.string.isRequired,
+ getComponent: PropTypes.func.isRequired,
+ onChange: PropTypes.func.isRequired,
+ authorized: PropTypes.object,
+}
+
+export default AsyncAPIAuth
+```
+
+### 5. Claude Registers Plugin
+
+Updates `src/core/presets/apis/index.js`:
+
+```javascript
+import OpenAPI40Plugin from "../../plugins/oas40/index.js"
+
+export default function PresetApis() {
+ return [
+ BasePreset,
+ OpenAPI30Plugin,
+ JSONSchema202012Plugin,
+ JSONSchema202012SamplesPlugin,
+ OpenAPI31Plugin,
+ JSONSchema2024Plugin, // New JSON Schema version
+ JSONSchema2024SamplesPlugin,
+ OpenAPI40Plugin, // LAST - overrides all previous
+ ]
+}
+```
+
+### 6. Claude Adds Tests
+
+#### Unit Test (test/unit/core/plugins/oas40/fn.js)
+
+```javascript
+/**
+ * @prettier
+ */
+
+import { fromJS } from "immutable"
+import { isOAS40 } from "src/core/plugins/oas40/fn.js"
+
+describe("oas40 plugin - fn - isOAS40", () => {
+ it("should match OpenAPI 4.0.0", () => {
+ const spec = fromJS({ openapi: "4.0.0" })
+ expect(isOAS40(spec)).toBe(true)
+ })
+
+ it("should match OpenAPI 4.0.15", () => {
+ const spec = fromJS({ openapi: "4.0.15" })
+ expect(isOAS40(spec)).toBe(true)
+ })
+
+ it("should NOT match OpenAPI 4.0", () => {
+ const spec = fromJS({ openapi: "4.0" })
+ expect(isOAS40(spec)).toBe(false)
+ })
+
+ it("should NOT match OpenAPI 3.1.0", () => {
+ const spec = fromJS({ openapi: "3.1.0" })
+ expect(isOAS40(spec)).toBe(false)
+ })
+})
+```
+
+#### E2E Test (test/e2e-cypress/e2e/features/oas40/workflows.cy.js)
+
+```javascript
+/**
+ * @prettier
+ */
+
+describe("OpenAPI 4.0 - Workflows", () => {
+ it("should render workflows section", () => {
+ cy.visit("/e2e-cypress/static/documents/oas40/petstore-workflows.yaml")
+ cy.get(".opblock-tag").contains("Workflows").should("exist")
+ })
+
+ it("should display workflow definitions", () => {
+ cy.visit("/e2e-cypress/static/documents/oas40/petstore-workflows.yaml")
+ cy.get(".opblock-workflow").should("have.length.greaterThan", 0)
+ })
+})
+```
+
+### 7. Claude Updates Documentation
+
+Updates to `README.md`:
+
+```markdown
+## Compatibility
+
+Swagger UI supports the following OpenAPI Specification versions:
+
+- OpenAPI 2.0 (Swagger)
+- OpenAPI 3.0.x
+- OpenAPI 3.1.x
+- OpenAPI 4.0.x ⭐ NEW
+```
+
+Updates to `CLAUDE.md`:
+
+```markdown
+### 26 Core Plugins
+
+- `oas3` - OpenAPI 3.0.x support
+- `oas31` - OpenAPI 3.1.x support
+- `oas40` - OpenAPI 4.0.x support (NEW)
+```
+
+### 8. Claude Runs Tests
+
+```bash
+# Lint
+npm run lint-errors
+
+# Unit tests
+npm run test:unit
+
+# Build
+npm run build
+
+# Artifact tests
+npm run test:artifact
+
+# E2E tests
+npm run cy:ci
+```
+
+### 9. Claude Creates Commit
+
+```bash
+git add src/core/plugins/oas40/
+git add src/core/presets/apis/index.js
+git add test/unit/core/plugins/oas40/
+git add test/e2e-cypress/e2e/features/oas40/
+git add README.md CLAUDE.md
+
+git commit -m "$(cat <<'EOF'
+feat(oas40): add support for OpenAPI 4.0
+
+Implement OpenAPI 4.0.x specification support with:
+- Version detection and plugin architecture
+- Workflows rendering for reusable workflow definitions
+- AsyncAPI authentication type support
+- JSON Schema Draft 2024 integration
+- Enhanced callback syntax with workflow references
+- License attribution field support
+
+Breaking changes from OAS 3.1:
+- Removed deprecated x- extension fields
+- Updated JSON Schema to Draft 2024
+
+Includes comprehensive unit and E2E tests.
+
+Co-Authored-By: Claude Sonnet 4.5
+EOF
+)"
+```
+
+### 10. Result
+
+You now have complete OpenAPI 4.0 support in Swagger UI:
+
+```
+src/core/plugins/oas40/
+├── index.js (367 lines)
+├── fn.js (124 lines)
+├── selectors.js (45 lines)
+├── after-load.js (78 lines)
+├── auth-extensions/
+│ └── wrap-selectors.js (32 lines)
+├── oas31-extensions/
+│ └── fn.js (56 lines)
+├── spec-extensions/
+│ ├── selectors.js (89 lines)
+│ └── wrap-selectors.js (23 lines)
+├── components/
+│ ├── workflows.jsx (156 lines)
+│ ├── license.jsx (67 lines)
+│ ├── version-pragma-filter.jsx (78 lines)
+│ └── auth/
+│ └── async-api-auth.jsx (54 lines)
+└── wrap-components/
+ ├── info.jsx (12 lines)
+ └── license.jsx (12 lines)
+
+test/unit/core/plugins/oas40/
+├── fn.js (67 lines)
+└── components/
+ └── version-pragma-filter.jsx (45 lines)
+
+test/e2e-cypress/e2e/features/oas40/
+├── workflows.cy.js (34 lines)
+└── async-api-auth.cy.js (28 lines)
+```
+
+**Total:** ~1,367 lines of code, fully tested and documented.
+
+## Benefits of Using the Skill
+
+### Without the Skill (Manual Implementation)
+
+**Estimated Time:** 40-60 hours
+
+**Challenges:**
+- Understanding plugin architecture (4-6 hours)
+- Studying OAS 3.1 patterns (3-4 hours)
+- Creating file structure (1 hour)
+- Implementing version detection (2 hours)
+- Creating selector factories (4-6 hours)
+- Implementing components (12-16 hours)
+- Writing tests (8-12 hours)
+- Debugging integration issues (6-8 hours)
+- Documentation (2-3 hours)
+
+**Error-prone areas:**
+- Plugin loading order
+- Selector wrapping logic
+- Component lifecycle
+- Redux state management
+- afterLoad hook timing
+
+### With the Skill (Guided Implementation)
+
+**Estimated Time:** 8-12 hours
+
+**Benefits:**
+- Automated boilerplate generation
+- Follows established patterns
+- Guided step-by-step process
+- Built-in error checking
+- Comprehensive test coverage
+- Documentation templates
+- Best practices enforced
+
+**What the skill handles:**
+- ✅ File structure creation
+- ✅ Boilerplate code generation
+- ✅ Pattern adherence
+- ✅ Test scaffolding
+- ✅ Documentation updates
+- ✅ Build verification
+- ✅ Commit message formatting
+
+## Customization Options
+
+The skill is flexible and can be customized per version:
+
+### For Minor Versions (e.g., 3.2)
+
+```bash
+/add-oas-support --version 3.2 --type minor
+```
+
+Claude will:
+- Create lighter plugin structure
+- Reuse more from previous version
+- Focus on incremental additions
+- Skip breaking change handling
+- Minimize component wrapping
+
+### For Major Versions (e.g., 5.0)
+
+```bash
+/add-oas-support --version 5.0 --type major
+```
+
+Claude will:
+- Create comprehensive plugin structure
+- Implement extensive component wrapping
+- Handle breaking changes
+- Create new base components if needed
+- Add deprecation warnings
+- Update preset configurations
+
+## Troubleshooting
+
+### Skill Not Found
+
+```
+Error: Skill 'add-oas-support' not found
+```
+
+**Solution:** Ensure the skill file exists in `.claude/skills/add-oas-support.md`
+
+### Version Already Exists
+
+```
+Error: Plugin for OAS 4.0 already exists
+```
+
+**Solution:** Remove existing plugin or choose a different version
+
+### Build Failures
+
+```
+Error: Module not found
+```
+
+**Solution:** Check plugin registration in presets and import paths
+
+### Test Failures
+
+```
+Error: isOAS40 is not a function
+```
+
+**Solution:** Verify selector is properly exported and registered
+
+## Next Steps
+
+After the skill completes:
+
+1. **Test with real specs** - Use actual OAS 4.0 examples
+2. **Review generated code** - Ensure it matches your requirements
+3. **Add custom features** - Extend beyond the boilerplate
+4. **Optimize performance** - Profile and optimize hot paths
+5. **Submit PR** - Follow the PR template and guidelines
+
+## Additional Resources
+
+- **OAS 4.0 Spec:** https://spec.openapis.org/oas/v4.0.0 (hypothetical)
+- **Plugin API:** `docs/customization/plugin-api.md`
+- **CLAUDE.md:** Complete codebase guide
+- **Skill Source:** `.claude/skills/add-oas-support.md`
+
+---
+
+**Note:** This example uses hypothetical OAS 4.0 features for demonstration purposes. Adapt the skill usage to match the actual specification features of the version you're implementing.
diff --git a/.claude/implementation/perf-virtualization/README.md b/.claude/implementation/perf-virtualization/README.md
new file mode 100644
index 00000000000..bdde3a8598a
--- /dev/null
+++ b/.claude/implementation/perf-virtualization/README.md
@@ -0,0 +1,224 @@
+# TanStack Virtual — Performance Virtualization Epic
+
+Virtualizing large list rendering in Swagger UI to improve performance with large OpenAPI specifications.
+
+## Motivation
+
+Large specs (Kubernetes, GitHub, AWS) have 500–1000+ operations and 200–800+ schemas. All render simultaneously → slow initial paint, high memory, sluggish interactions.
+
+TanStack Virtual renders only what is visible in the viewport.
+
+## Phases
+
+| Phase | Plan | Target | Complexity |
+|-------|------|--------|------------|
+| 1 | [Models/Schemas list](./phase-1-models-virtualization.md) | `models.jsx` (json-schema-5) | Medium |
+| 2 | [OAS 3.1 models copy](./phase-2-oas31-models.md) | `oas31/.../models.jsx` | Low–Medium |
+| 3 | [Operations list](./phase-3-operations-virtualization.md) | `operations.jsx` | High |
+
+Status, estimates, ownership and scheduling are tracked in the issue tracker, not here. These
+documents cover the technical approach only.
+
+All three phases ship together — see [Release Strategy](#release-strategy--single-release-long-lived-feature-branch).
+
+### Out of scope — schema property lists
+
+Two options for long property lists inside an individual schema were considered and **both dropped**
+(2026-08-04). Property lists are not addressed by this epic at all.
+
+**Virtualization of property lists — rejected.** Four findings, recorded because any future proposal
+has to answer them:
+
+1. **Recursive nesting breaks virtualization.** Properties render recursively — a property can itself
+ be an object with properties, indefinitely. TanStack Virtual needs a flat list in a single scroll
+ container. That leaves a fully flattened virtual tree (complex cross-level state) or independent
+ virtualizers per nesting level (scroll containers inside scroll containers — poor UX). Neither is
+ satisfactory.
+2. **Expand/collapse compounds it.** Each property row expands to reveal its own sub-schema, so
+ heights are deeply dynamic *and* interdependent; `measureElement` alone cannot coordinate nested
+ virtual lists.
+3. **Real-world list lengths are low.** Most object schemas have <50 properties. 100+ exists (some AWS
+ APIs) but is uncommon, so the ROI against that complexity is poor next to Phases 1–3.
+4. **Copy-per-plugin constraint.** Per CLAUDE.md's cross-plugin import guidelines the three property
+ renderers are self-contained copies, so it would have to be built three times.
+
+**A property count cap with a "Show all" toggle — also dropped.** It was the cheap alternative (works
+per level, no measurement or scroll container needed), but the need was never demonstrated: no profile
+and no user report shows property rendering as a bottleneck, and capping hides documentation content
+behind a control. Not worth the accessibility and discoverability cost for an unmeasured gain.
+
+Relevant context if either is revisited: collapsed sub-schemas are **already unmounted** today
+(verified — `ModelCollapse` at `model-collapse.jsx:95` renders
+`{ this.state.expanded && this.props.children }`, and `Collapse` at `layout-utils.jsx:243` returns
+``), so only the expanded level a user is looking at costs anything. Revisit only if a
+profile after Phases 1–3 shows otherwise, or a spec appears with a single schema having 200+ direct
+properties — note there is **no way to detect that today**, as no schema-shape instrumentation exists.
+
+The affected files, for reference: `json-schema-5/components/object-model.jsx` (Immutable),
+`json-schema-2020-12/.../Properties/Properties.jsx` and
+`oas32/json-schema-2020-12-extensions/.../Properties.jsx` (both plain JS objects — a different data
+shape, so it would have been two implementations across three files).
+
+### Resolved decisions
+
+| Question | Decision | Date |
+|---|---|---|
+| Rollout: flag vs threshold vs always-on | **Automatic count threshold, no config key** — see [Decision](#decision--rollout-via-automatic-count-threshold) | 2026-08-04 |
+| Phase 1 models scroll-container height | **`max-height: min(60vh, 800px)`** with a nested scroll container | 2026-08-04 |
+| Release timing | **Single release; nothing ships early** — long-lived feature branch | 2026-08-04 |
+| Train scope | **Phases 1 + 2 + 3** (property lists out of scope) | 2026-08-04 |
+| Schema property lists | **Out of scope** — virtualization rejected, count cap dropped | 2026-08-04 |
+
+### Open technical questions
+
+Every design decision is resolved. What remains needs a measurement rather than a decision:
+
+- Confirm the threshold constants (100 models / 150 operation items) against a real profile, or
+ accept them as-is.
+- Replace the `estimateSize` placeholders with measured row heights (see each phase's notes).
+
+## Release Strategy — Single Release, Long-Lived Feature Branch
+
+**Decided 2026-08-04:** nothing ships until the whole train is complete. All phases land on a
+long-lived integration branch and merge to `master` once.
+
+```
+perf/virtualization ← integration branch, owns models.jsx + operations.jsx
+ ├── perf/virtualization-p1 Phase 1 models (json-schema-5)
+ ├── perf/virtualization-p2 Phase 2 models (oas31)
+ └── perf/virtualization-p3 Phase 3 operations
+```
+
+**Train scope: Phases 1 + 2 + 3.** Nothing releases early, including Phase 1.
+Schema property lists are **out of scope** (see above).
+
+### Why a branch is required here
+
+This repo releases off `master` continuously — 11 patch releases in 5.32.x, with 5.32.9/.10/.11
+landing on 17/21/22 July 2026. Anything merged to `master` ships within days. The count-threshold
+rollout has **no off switch**: once merged, specs above the threshold get the virtualized path on the
+next patch release. So incremental merging to `master` is incompatible with a single release, and the
+branch is the mechanism that holds it.
+
+### Known cost of this choice
+
+The branch owns `models.jsx` and `operations.jsx` for the epic's full duration, while `master`
+continues to receive dependency bumps and OAS 3.2 work. Every phase in this repo's history has used a
+short-lived branch; there is no precedent for one this long. Mitigations:
+
+- **Rebase onto `master` on a fixed cadence** (weekly), not at the end. A single end-of-epic rebase
+ across two heavily-edited components is where this goes wrong.
+- **Land each phase's sub-branch into the integration branch as it passes review**, so conflicts
+ surface between phases rather than all at once.
+- Keep the threshold constants at their final values on the branch — there is no need to hold them
+ inert, since the branch itself is the gate.
+
+### Release-gate criteria (once, at the end — not per phase)
+
+These ACs move out of the individual phase tickets, because they are only meaningful for the
+integrated result:
+
+- [ ] `npm run deps-size` before/after for the whole train (`@tanstack/react-virtual`, ~5KB min+gzip)
+- [ ] `swagger-ui-react` smoke-tested — separately published, not covered by Cypress
+- [ ] Combined React Profiler benchmark, all phases active, against the perf fixtures
+- [ ] Full `npm test` (lint + unit + Cypress) green on the integration branch
+- [ ] `npm run test:artifact` passes
+- [ ] Release note drafted covering the above-threshold behavior changes (find-in-page, print, DOM)
+- [ ] Threshold constants confirmed against a real profile, or accepted as-is (100 / 150)
+
+## Implementation Order
+
+Sequential on the integration branch:
+
+1. **Phase 1** — adds `@tanstack/react-virtual`, establishes the threshold/`getItemKey`/measurement
+ pattern, adds the `ResizeObserver` polyfill. Lowest risk of the virtualization work, so it goes
+ first and everything else copies it.
+2. **Phase 2** — mechanical port of Phase 1 to the `oas31` copy. Cheap (already a functional
+ component). Can run in parallel with Phase 3 once Phase 1's pattern is settled.
+3. **Phase 3** — operations. Largest and riskiest: flattening, the greenfield deep-link bridge, and
+ dual-mode `OperationTag`.
+
+## Dependency
+
+```
+@tanstack/react-virtual ~5KB min+gzip Added in Phase 1
+```
+
+Verify with `npm run deps-size` after adding.
+
+## Test Fixtures (created 2026-08-04)
+
+```
+test/e2e-cypress/static/documents/perf/
+├── many-schemas.swagger.yaml 240 definitions ← Phase 1 (Swagger 2.0)
+├── many-schemas.openapi.yaml 240 components/schemas ← Phase 1 (OAS 3.0.0)
+├── many-schemas.openapi31.yaml 240 components/schemas ← Phase 2 (OAS 3.1.0 → oas31 component)
+└── many-operations.yaml 529 ops / 24 tags ← Phase 3 (incl. multiTagged, 2 tags)
+```
+
+All generated, all validated as parseable. Counts are deliberately well above the thresholds so the
+virtualized path is unambiguously exercised — see the cost note above about fixtures that
+accidentally fall below.
+
+## Key Files
+
+```
+src/core/components/operations.jsx ← Phase 3
+src/core/components/operation-tag.jsx ← Phase 3 (structural)
+src/core/plugins/deep-linking/layout.js ← Phase 3 (scroll bridge); Phase 1 only for the explicit-container fix to scrollToElement
+test/unit/jest-shim.js ← Phase 1 (ResizeObserver polyfill — blocks unit tests in both phases)
+src/core/plugins/json-schema-5/components/models.jsx ← Phase 1
+src/core/plugins/oas31/components/models/models.jsx ← Phase 1 follow-up (OAS 3.1 copy)
+```
+
+## Decision — Rollout via Automatic Count Threshold
+
+**Decided (2026-08-04): virtualize only above an item-count threshold. No config key.**
+
+Below the threshold, each phase renders **today's markup unchanged**; above it, the windowed path
+takes over. Rationale: small and medium specs — the large majority — keep today's exact DOM,
+find-in-page, print and CSS behavior, so there is no breaking change and no new public config
+surface to support forever. The perf work targets only the specs that actually suffer.
+
+Recommended constants (implementer may tune with measurements):
+
+```js
+// suggested home: src/core/utils/index.js, alongside DEFAULT_RESPONSE_KEY
+export const VIRTUALIZE_MODELS_THRESHOLD = 100 // Phase 1
+export const VIRTUALIZE_OPERATIONS_THRESHOLD = 150 // Phase 3 (counted on flatItems.length)
+```
+
+### What this decision buys
+
+- **No public API break.** `OperationTag` keeps accepting `children`;
+ `.opblock-tag-section`, `.operation-tag-content` and `.model-container` nesting all survive on
+ the legacy path.
+- **Every existing Cypress and Selenium test keeps passing untouched**, because every current
+ fixture is far below either threshold: `features/models.swagger.yaml` has **3** definitions,
+ `features/deep-linking.swagger.yaml` **5** operations, `oas32/component-only.yaml` **0** paths.
+- Find-in-page / print loss applies **only** to specs above the threshold — where the content was
+ already unusably long to scan by eye.
+
+### What it costs
+
+- **Two code paths per phase**, both needing maintenance and review.
+- **The virtualized path is exercised by nothing that exists today.** Its only E2E coverage comes
+ from the new perf fixtures, which therefore *must* exceed the thresholds — see each phase's
+ fixture spec. A fixture accidentally below threshold silently tests the legacy path and the
+ virtualization ships unverified. This is the single most important consequence of this decision.
+- A behavior discontinuity at the boundary; test just above and just below it.
+
+## Cross-Cutting Blocker — Phase 3 Only
+
+Deep-link scrolling is **ref-based**: `layoutActions.readyToScroll(key, ref)` fires from mounted
+DOM nodes and is resolved in `src/core/plugins/deep-linking/layout.js:110`. Virtualization
+unmounts off-screen items, so their refs never fire and deep links silently no-op. **Phase 3**
+therefore needs a `scrollToIndex` → mount → existing-ref bridge, keyed on the derived
+`operationId` (`src/core/containers/OperationContainer.jsx:62`) — not on path+method.
+
+**This does not affect Phase 1.** Model/schema deep-linking does not exist on `master`:
+`isShownKeyFromUrlHashArray` (`layout.js:175-184`) only ever emits `["operations", …]` or
+`["operations-tag", …]` — its own comment reads *"We only put operations in the URL"* — and the
+sole caller of `scrollTo` is `parseDeepLinkHash` (`:106`). The `readyToScroll` calls in
+`models.jsx:36`/`:43`, `model-collapse.jsx:72` and `oas31/.../models.jsx:54`/`:59` are dead code.
+Phase 1's real navigation regression is the plain `#model-` browser anchor instead.
diff --git a/.claude/implementation/perf-virtualization/phase-1-models-virtualization.md b/.claude/implementation/perf-virtualization/phase-1-models-virtualization.md
new file mode 100644
index 00000000000..152486afa36
--- /dev/null
+++ b/.claude/implementation/perf-virtualization/phase-1-models-virtualization.md
@@ -0,0 +1,471 @@
+# [PERF] Virtualize Models/Schemas List with TanStack Virtual
+
+## Summary
+
+Large OpenAPI specs with 200+ schemas/models mount one `.model-container` row per schema
+simultaneously (a connected `JumpToPath` plus a `ModelCollapse` each; there is no `ModelContainer`
+component — `model-container` is a CSS class), causing significant initial render cost and memory
+overhead. This ticket virtualizes the Models section using TanStack Virtual so only visible models
+are mounted.
+
+## Background
+
+`src/core/plugins/json-schema-5/components/models.jsx:77` currently maps over all schema
+definitions unconditionally inside a `` component. A spec like the Kubernetes API
+(~800 schemas) mounts 800 model rows at once.
+
+**Calibration — what is actually mounted (verified):** `ModelCollapse`
+(`json-schema-5/components/model-collapse.jsx:95`) renders
+`{ this.state.expanded && this.props.children }`, so a *collapsed* model does **not** mount its
+`ModelWrapper` schema subtree. The per-model cost while collapsed is the `.model-container` div,
+a `JumpToPath` (a connected component), the `ModelCollapse` button, and the three
+`specSelectors.specResolvedSubtree` / `specJson().getIn()` / `layoutSelectors.isShown` selector
+calls in the map body (lines 82–89).
+
+So the win here is 800 lightweight rows → ~15, not 800 full schema trees → 15. That is still
+worth doing (800 connected components + 800 DOM subtrees + 2400 selector calls per render), but
+size the expected gain against this, and record the real baseline rather than assuming an
+order-of-magnitude improvement.
+
+**Why start here:** Flat list, single scroll axis, contained within an existing collapsible wrapper — lowest complexity virtualization target.
+
+## Scope
+
+- Package: `swagger-ui` (core)
+- Files affected:
+ - `src/core/plugins/json-schema-5/components/models.jsx` (primary — class → functional)
+ - `src/style/_models.scss` (scroll container `max-height` / `overflow-y`)
+ - `test/unit/core/plugins/json-schema-5/components/models.jsx` (existing test file — update;
+ currently uses Enzyme `shallow`, which will not survive the hook conversion)
+ - `test/unit/jest-shim.js` (add a `ResizeObserver` polyfill — see "Unit-test infrastructure")
+ - `test/e2e-cypress/static/documents/` (new large-schema fixture, see below)
+ - `test/e2e-cypress/e2e/features/` (new spec)
+ - `test/e2e-selenium/pages/main.js:506-539` — holds **all 12 `.model-container` references under
+ `test/`**. (Production code has its own ~10 refs; those are covered by the CSS audit below.)
+ These are **positional descendant selectors** — e.g.
+ `section.models div.model-container:nth-child(2)` — so wrapping each model in its own virtual
+ item div makes every `.model-container` a `:nth-child(1)`, and every `:nth-child(2..6)` selector
+ matches nothing. They are **definitely broken by this change, not merely affected.** Mitigating
+ factor only: there are **zero** Cypress references to that class and the Selenium suite is not
+ run in CI (`.github/workflows/nodejs.yml` runs Cypress only), so this does not turn CI red —
+ fix or delete them deliberately rather than discovering it later. The Cypress-side exposure is
+ the `#model-` id selectors instead — covered by the `model-collapse.cy.js` AC above.
+- New dependency: `@tanstack/react-virtual` (~5KB min+gzip)
+- **Dominant unknown when sizing this:** the `Collapse`-unmount / measurement-cache interaction.
+ (The deep-link bridge was ruled out once model deep-linking was found not to exist; the threshold
+ decision removed the CSS/selector fallout.) Factor in the cost of maintaining two render paths.
+
+### Test fixtures (CREATED — committed 2026-08-04)
+
+Both variants exist, because `models.jsx` branches on `isOAS3()` for the base path
+(`getSchemaBasePath()`, lines 17–20) and `model-collapse.cy.js` runs its scenarios twice (Swagger 2
+at `:2-6`, OpenAPI 3 at `:7-10`):
+
+| Fixture | Schemas | Notes |
+|---|---|---|
+| `test/e2e-cypress/static/documents/perf/many-schemas.swagger.yaml` | 240 `definitions` | Swagger 2.0 |
+| `test/e2e-cypress/static/documents/perf/many-schemas.openapi.yaml` | 240 `components/schemas` | `openapi: 3.0.0` |
+
+240 is deliberately well above the 100 threshold, so the virtualized path is unambiguously
+exercised. Both are generated files with a regenerate-don't-hand-edit header; both parse and each
+carries one trivial `/ping` operation so the document is valid.
+
+A third fixture, `many-schemas.openapi31.yaml` (`openapi: 3.1.0`, 240 schemas), exists for
+[phase-1b](./phase-2-oas31-models.md) — **not** for this ticket. On a 3.1 document the `oas31`
+component renders instead of this one.
+
+## Acceptance Criteria
+
+- [x] A spec **below** the 100-schema threshold renders today's markup unchanged (legacy path) —
+ verify `model-collapse.cy.js` passes with no edits
+- [x] A spec **above** the threshold uses the windowed path, and only visible models in the viewport
+ are mounted (verify in React DevTools)
+- [x] Boundary tested both sides — one fixture just under the threshold, one just over
+- [x] Scrolling through the models list renders/unmounts items correctly
+- [x] Collapsing and expanding the "Schemas/Models" section works as before
+- [x] Existing `model-collapse.cy.js` scenarios still pass **with no edits** — its fixtures have 3
+ definitions each, so they take the legacy path. If any of its selectors needed changing
+ (`.models h4 .models-control`, `#model-User .model-box .model-box-control` at `:40`/`:44`,
+ `#model-Pet` / `#model-Order` at `:18`/`:28`/`:34`), that means the legacy path was altered —
+ treat it as a regression, not a test to update
+- [x] `defaultModelsExpandDepth < 0` still short-circuits the whole section to `null`
+ (`models.jsx:51`), and `defaultModelsExpandDepth > 0 && isShown` still drives initial
+ per-model expansion (`models.jsx:131`)
+- [ ] No visual regression — layout, spacing, expand/collapse of individual model unchanged
+- [x] No accessibility regression — keyboard navigation and screen reader order preserved
+- [x] Performance: initial render time for the 200+ model fixture reduced vs. the recorded baseline
+ (React Profiler, before/after)
+- [x] Unit tests updated in `test/unit/core/plugins/json-schema-5/components/models.jsx`
+- [x] `ResizeObserver` polyfill added to `test/unit/jest-shim.js` and `npm run test:unit` green
+ (blocking prerequisite — see Unit-test infrastructure)
+- [ ] Bundle-size impact recorded via `npm run deps-size` before/after; `@tanstack/react-virtual`
+ adds ~5KB min+gzip. Flag it in the PR if the measured delta is materially larger
+- [x] `swagger-ui-react` still renders models correctly — the flavor re-exports core, so it
+ inherits this change with no code edit, but it is a separately published package and is not
+ covered by the Cypress suite
+- [x] E2E test: models section scrolls and renders correctly with the new fixture
+- [x] `#model-` browser-anchor navigation still works below the threshold, and its
+ above-threshold breakage is accepted per
+ [Accepted Behavior Changes](#accepted-behavior-changes-confirm-with-maintainers-before-building).
+ Note this is the plain browser anchor — model *deep linking* does not exist (see Technical Notes)
+- [x] Expanding model A, scrolling it out of view, and scrolling back shows A still expanded and
+ no *other* model wrongly expanded (guards the `getItemKey` requirement below)
+
+## Technical Notes
+
+### Approach
+
+Convert models rendering from a full map to a windowed list:
+
+```jsx
+import { useVirtualizer } from "@tanstack/react-virtual"
+
+// Inside component (convert class → functional component first)
+const parentRef = useRef(null)
+const definitionEntries = useMemo(
+ () => definitions.entrySeq().toArray(),
+ [definitions]
+)
+
+const virtualizer = useVirtualizer({
+ count: definitionEntries.length,
+ getScrollElement: () => parentRef.current,
+ estimateSize: () => 72, // PLACEHOLDER — measure a collapsed .model-container first
+ overscan: 5,
+ // REQUIRED — see "Stable item keys" below. Without this, vItem.key is the index.
+ getItemKey: (index) => `models-section-${definitionEntries[index][0]}`,
+})
+```
+
+#### DOM contract to preserve inside the item wrapper
+
+The virtual wrapper is an **extra** div around today's markup, not a replacement. Everything inside
+`.model-container` must be reproduced verbatim — all of it is test or embedder surface:
+
+- `id={`model-${name}`}` (`models.jsx:117`) — the anchor and the handle every
+ `model-collapse.cy.js` assertion uses.
+- `className="model-container"` (`:117`) — 12 references across `test/`.
+- `data-name={name}` (`:118`) — read back by `onLoadModel` via `ref.getAttribute("data-name")`
+ (`:42`), so dropping it breaks that callback even though the callback is otherwise dead.
+- `` wrapping `` (`:119`) —
+ note the prop is `path`, **not** `specPath`.
+
+Do not move `id` or `className` onto the virtual wrapper — CSS and `ModelCollapse.onLoad`'s
+`ref.parentElement` (`model-collapse.jsx:72`) both assume the current nesting.
+
+#### CSS audit (REQUIRED — the change inserts two new divs into the `.models` subtree)
+
+Virtualization puts a `.models-scroll` wrapper *and* a per-item positioned div between `.models`
+and `.model-container`. Any descendant rule that assumes the old depth — a `>` child combinator, or
+a positional/`:first-child`-style selector — silently breaks. Run **before** editing:
+
+```bash
+grep -rn "model-container" src/style/ src/core/plugins/*/components/**/*.scss
+grep -rn "\.models\b" src/style/
+```
+
+Known production references to `.model-container` (~10 lines, 8 files): `_models.scss` (×3),
+`_layout.scss`, `_dark-mode.scss`, `_variables.scss`, two `json-schema-2020-12` stylesheets,
+plus `models.jsx:117` and `model-example.jsx:123`.
+
+**`model-example.jsx:123` matters most:** it renders a *second* `.model-container` that is **not**
+inside the virtualized list (it's the request/response example view). So `.model-container` styles
+must **not** be re-scoped onto the new virtual wrapper — doing so silently restyles or breaks the
+example panes. Any new selector must be additive and scoped to `.models-scroll`.
+
+Add an AC: visual diff of the Models section *and* an operation's request/response example panes.
+
+#### Stable item keys (REQUIRED — silent state-corruption bug otherwise)
+
+`vItem.key` **defaults to the item index**. Today the key is content-derived and stable:
+`models-section-${name}` (`models.jsx:117`). If the render loop uses `key={vItem.key}` with the
+default, React reconciles by position rather than identity, so a mounted `ModelCollapse` instance
+can be reused for a *different* model — carrying its `state.expanded` (and its
+`collapsedContent`) with it. Symptom: scroll away and back, and the wrong model appears expanded.
+
+Pass `getItemKey` so `vItem.key` reproduces today's key exactly. `definitions` is an `OrderedMap`,
+so indexes are stable for a given spec — but `getItemKey` is still required, because index reuse
+across *scroll* windows is the failure mode, not reordering.
+
+Dynamic heights come from attaching `virtualizer.measureElement` as a **ref** on each
+item wrapper — not from the `measureElement` *option*. (The option exists but its real
+signature is `(element, entry, instance) => number`; the default implementation is
+correct here, so do not pass it.)
+
+### Render Loop
+
+```jsx
+
+ {/* existing .model-container markup for `name` — see DOM contract below */}
+
+ )
+ })}
+
+
+```
+
+### Key Constraints
+
+- **Scroll container**: the virtualized path needs a bounded scroll container on a new
+ `.models-scroll` wrapper. Add to `src/style/_models.scss` — **decided 2026-08-04:**
+
+ ```scss
+ .models-scroll {
+ max-height: min(60vh, 800px);
+ overflow-y: auto;
+ }
+ ```
+
+ Apply it only on the virtualized branch; the legacy branch keeps today's unbounded layout.
+
+- **Scroll container unmounts on section collapse (BLOCKING)**: the models list lives inside
+ `` (`models.jsx:75`), and `Collapse` returns ``
+ when closed (`src/core/components/layout-utils.jsx:243`, in `renderNotAnimated()` 241-249) — a real
+ unmount. So collapsing
+ the Schemas/Models section destroys `.models-scroll`, leaving `parentRef.current === null`,
+ and the virtualizer's measurement cache is discarded on every collapse/expand cycle.
+ Consequences to handle:
+ - `getScrollElement: () => parentRef.current` returns `null` while collapsed — verify the
+ virtualizer tolerates that rather than throwing.
+ - Re-expanding re-measures from `estimateSize` scratch, so previously-expanded model heights
+ are forgotten and scroll position is lost.
+ - Options: keep the virtualizer mounted and move the collapse to CSS for this wrapper only,
+ or persist `virtualizer.measurementsCache` across unmounts via
+ `initialMeasurementsCache`. Pick one deliberately — this directly conflicts with the AC
+ "Collapsing and expanding the Schemas/Models section works as before".
+
+- **`getScrollParent` behavior change**: `scrollToElement`
+ (`src/core/plugins/deep-linking/layout.js:120-127`) resolves its container via
+ `system.fn.getScrollParent(ref)` (defined at `layout.js:138`). Today the nearest scroll parent
+ for a model is the **window**. Introducing a bounded `.models-scroll` makes *that* the scroll
+ parent, so zenscroll scrolls the inner container instead of the page — the section itself may
+ stay off-screen. Additionally `ModelCollapse.onLoad` passes `ref.parentElement`
+ (`model-collapse.jsx:72`), which after this change is the absolutely-positioned virtual item
+ wrapper rather than `.model-container`. Both need explicit handling; consider passing an
+ explicit `container` argument to `scrollToElement` instead of relying on inference.
+- **Dynamic heights**: Individual models expand inline → `ref={virtualizer.measureElement}`
+ (ResizeObserver). Without it, expanded models overflow their virtual slot. Set
+ `min-height` equal to `estimateSize` on the wrapper to limit shift on first correction.
+- **The `72` is an unmeasured placeholder.** Measure a real collapsed `.model-container` in the
+ browser (default theme, default font) and use that number in both `estimateSize` and the
+ `min-height`. A wrong estimate does not break correctness — `measureElement` corrects it — but a
+ bad guess causes visible scrollbar jump on first paint, which is the main UX complaint about
+ virtualized lists.
+- **Class → functional**: `models.jsx` **is** a class component
+ (`export default class Models extends Component`, line 7). Convert to functional to use
+ `useVirtualizer`. Seven behaviors are easy to lose and must survive:
+ - `getCollapsedContent` (lines 22–24) supplies `collapsedContent` to each `ModelCollapse`
+ (`:122`). Note the quirk: it is **called with `name`** but declared with no parameters and
+ always returns a single space `" "`, overriding `ModelCollapse`'s own `"{...}"` default
+ (`model-collapse.jsx:22`). Port it as-is — "simplifying" it to the default changes the
+ collapsed rendering of every model.
+ - `getSchemaBasePath()` (lines 17–20) returns
+ `isOAS3() ? ["components", "schemas"] : ["definitions"]`. It feeds `specPathBase`,
+ `handleToggle`, and both ref callbacks — so the OAS2-vs-OAS3 branch must be preserved as a
+ single source of truth (memoize on `specSelectors.isOAS3()`), not inlined in several places.
+ - `specActions.requestResolvedSubtree` is dispatched **inline during render** (line 94).
+ Preserve the behavior, ideally moving it into an effect during the conversion.
+ - Line 51 early-returns `null` for the entire section when
+ `!definitions.size || defaultModelsExpandDepth < 0`.
+ - Line 131 gates each model's initial expanded state on
+ `defaultModelsExpandDepth > 0 && isShown`.
+ - Line 54: the section's default open state is
+ `layoutSelectors.isShown(specPathBase, defaultModelsExpandDepth > 0 && docExpansion !== "none")`.
+ `docExpansion` is destructured at line 50 and used **nowhere else in the file**, so it looks
+ like an unused variable during conversion and is easy to drop — which silently changes the
+ default-open behavior for `docExpansion: "none"`.
+ - `handleToggle` (lines 26–32) dispatches `layoutActions.show` **and**
+ `specActions.requestResolvedSubtree` on expand. This is the lazy schema-resolution path and is
+ separate from the inline dispatch at line 94 — both are needed.
+- **Immutable.js**: `definitions.entrySeq().toArray()` converts to plain array before passing
+ `count` to virtualizer. Memoize it — recreating the array every render defeats measurement caching.
+
+### Deep-link ref mechanism — NOT a blocker (model deep-linking does not exist)
+
+**Earlier drafts of this ticket treated this as the highest risk. That was wrong.** Model
+deep-linking is not a supported feature on `master`, so there is nothing to preserve and no
+bridge to build.
+
+`models.jsx` does register per-model refs (`:36` and `:43`, from the `onLoadModels`/`onLoadModel`
+declarations at `:34`/`:40`), and `ModelCollapse.onLoad` registers a third
+(`model-collapse.jsx:72`) plus an auto-expand on match (`:71`). All of them route into
+`readyToScroll` (`deep-linking/layout.js:110`), which fires only when
+`Im.is(scrollToKey, fromJS(isShownKey))`.
+
+But `scrollToKey` can never hold a schema path:
+
+- The **only** caller of `layoutActions.scrollTo` in `src/` is `parseDeepLinkHash`
+ (`layout.js:106`), which passes `isShownKeyFromUrlHashArray(hashArray)`.
+- That selector (`layout.js:175-184`) returns only `["operations", tag, operationId]`,
+ `["operations-tag", tag]`, or `[]`. Its own comment at `:177` says
+ **"We only put operations in the URL"**. `urlHashArrayFromIsShownKey` (`:185-194`) is symmetric.
+
+So `["definitions", "Pet"]` never matches, and `#/definitions/SomeModel` is not a recognized hash
+form. `docs/usage/deep-linking.md` documents no model/schema syntax.
+
+Corroborating evidence: `test/e2e-cypress/e2e/features/model-collapse.cy.js:4` does define
+`const urlFragment = "#/definitions/Pet"` and pass it as a second argument — but the receiving
+function signature is `function ModelCollapseTest(baseUrl)` (`:13`), a single parameter, so the
+fragment is silently discarded and no test ever visits it. Even the one reference in the repo is
+dead.
+
+**Consequence:** `models.jsx:43` and `ModelCollapse.onLoad`'s auto-expand are **dead code today.**
+Nothing to break, nothing to bridge.
+
+Required work (small):
+
+1. Preserve the `readyToScroll` refs as-is through the class→functional conversion. They are
+ unreachable, not harmful, and removing them is out of scope for a perf ticket.
+2. Compose them with `virtualizer.measureElement` on the item wrapper rather than choosing one —
+ the measurement ref is required and the existing ref must keep its current shape.
+3. Do **not** build a `scrollToIndex` bridge. If model deep-linking is ever implemented, it will
+ need one; that is a separate feature ticket, and it should be written to account for
+ virtualization from the start.
+
+**Real navigation to models** is the plain browser anchor `id={`model-${name}`}`
+(`models.jsx:117`), which virtualization *does* break for off-screen models — tracked under
+[Accepted Behavior Changes](#accepted-behavior-changes-confirm-with-maintainers-before-building),
+not here.
+
+### Unit-test infrastructure (BLOCKING — must be set up before the tests can be written)
+
+Two hard blockers, both applying to Phase 3 as well. Neither is currently in the repo.
+
+**1. No `ResizeObserver` in jsdom.** `virtualizer.measureElement` depends on it.
+`test/unit/jest-shim.js` polyfills only `TextDecoder`/`TextEncoder`, `test/unit/setup.js` sets up
+jsdom + the Enzyme adapter, and `grep -rn "ResizeObserver" src/ test/` returns **nothing**. Under
+Jest the measurement ref will throw or silently no-op. Add a polyfill (or a stub class recording
+observed elements) to `test/unit/jest-shim.js` as part of this ticket's scope.
+
+**2. Enzyme `shallow` will not survive the class→functional conversion.** The existing test
+(`test/unit/core/plugins/json-schema-5/components/models.jsx:2`) does
+`import { shallow } from "enzyme"` against a class component. The repo is `enzyme@=3.11.0` with
+`@cfaester/enzyme-adapter-react-18` (`package.json:144` and `:121` respectively), configured in
+`setup.js`. Hooks —
+`useRef`, `useMemo`, `useVirtualizer` — do not run meaningfully under that adapter's shallow
+renderer; `getScrollElement: () => parentRef.current` will see `null` and no items will render.
+
+Pick a strategy and state it in the PR:
+- `mount` instead of `shallow` (needs the ResizeObserver polyfill and a real scroll element with a
+ non-zero height, which jsdom does not provide by default — heights are all 0, so the virtualizer
+ may window nothing); **or**
+- mock `@tanstack/react-virtual` in the unit test so `useVirtualizer` returns a deterministic fake
+ (fixed `getVirtualItems()`, no-op `measureElement`), and cover real windowing in Cypress only.
+
+The second option is recommended: it keeps the unit test asserting *this component's* logic (the
+config gates, `requestResolvedSubtree` dispatch, key/DOM contract) and leaves genuine scroll
+behavior to E2E, where a real layout engine exists.
+
+### Measuring Success
+
+Run React Profiler on `http://localhost:3200/` with the new `many-schemas.yaml` fixture (or a
+Kubernetes spec):
+- Before: render time with N models — **record the actual number; no baseline has been measured yet**
+- After: render time with virtualization (should be near-constant regardless of N)
+
+### Accepted Behavior Changes (confirm with maintainers before building)
+
+Virtualization removes off-screen content from the DOM. **All of the following apply only to specs
+above the 100-schema threshold** — below it the legacy path runs and behavior is byte-identical to
+today. Shared with Phase 3:
+
+- **Browser find-in-page (Ctrl/Cmd-F) no longer finds off-screen models.** This is the classic
+ virtualization tradeoff and it is a real, user-visible capability loss for a docs tool whose
+ users routinely Ctrl-F for a schema name.
+- **Printing / "Save as PDF" captures only the rendered window**, not the full schema list.
+- **Browser anchor navigation to `#model-` stops working for unmounted models**
+ (`id={`model-${name}`}`, `models.jsx:117`). Since model *deep-linking* does not exist (see
+ Technical Notes), this plain anchor is the only way to link to a schema today — so this is the
+ actual navigation regression in Phase 1, not a secondary one.
+
+The count threshold **is** the mitigation, and it covers the large majority of specs. For the
+above-threshold case there is no further mitigation available short of not virtualizing: there is no
+in-app model filter to fall back on (see Out of Scope), and raising `overscan` only widens the window
+by a few rows. Accept it, or raise the threshold.
+
+### Rollout: automatic count threshold (DECIDED — no config flag)
+
+**Decided 2026-08-04:** virtualize only when the schema count exceeds a threshold. No config key.
+
+```js
+import { VIRTUALIZE_MODELS_THRESHOLD } from "core/utils" // suggested = 100
+
+if (definitionEntries.length < VIRTUALIZE_MODELS_THRESHOLD) {
+ // legacy path — today's markup, byte-for-byte unchanged
+ return renderLegacyModels()
+}
+// windowed path below
+```
+
+This is the most consequential instruction in the ticket, because it inverts most of the risk:
+
+- **Keep the existing render path intact.** Do not delete or "clean up" today's markup while
+ converting — extract it into a function/branch that still produces the same DOM. All the DOM
+ contract items and the CSS audit below apply to the *virtualized* branch only; the legacy branch
+ preserves them by construction.
+- **Every existing test keeps passing untouched.** `features/models.swagger.yaml` has **3**
+ definitions and `features/models.openapi.yaml` likewise, so `model-collapse.cy.js` and the
+ Selenium `:nth-child` selectors in `main.js:506-539` all take the legacy path and are unaffected.
+ That removes them from this ticket's risk surface entirely.
+- **The virtualized path has zero existing coverage.** The new perf fixtures are its *only* E2E
+ exercise, so they **must exceed 100 definitions** (the spec below says ≥200 — keep that margin).
+ A fixture below threshold silently tests the legacy path and ships the virtualization unverified.
+- Test **both sides of the boundary** — one fixture just under, one just over.
+
+The class→functional conversion still applies to the whole component; both branches live inside the
+converted function component. Note the hooks (`useVirtualizer`, `useRef`) must be called
+unconditionally, *before* the threshold branch — you cannot put the early return above them.
+
+## Risks
+
+| Risk | Mitigation |
+|------|-----------|
+| ~~Deep link to an off-screen model no-ops~~ — **not a risk; model deep-linking does not exist on master** (see Technical Notes) | None needed. Preserve the dead `readyToScroll` refs unchanged |
+| Browser anchor navigation to `#model-` breaks for off-screen models — this is the *real* navigation path | Maintainer decision on threshold/flag; see Accepted Behavior Changes |
+| Config gates lost in class→functional conversion (`defaultModelsExpandDepth < 0` early return at `:51`, `> 0 && isShown` expansion gate at `:131`) | Unit-test both boundaries explicitly; they are easy to drop when the render body is restructured |
+| `Collapse` unmounting `.models-scroll` destroys the scroll element and measurement cache on every section toggle | Decide the persistence strategy up front (see constraints); explicit test: expand section, scroll, collapse, re-expand |
+| `getScrollParent` now resolves to `.models-scroll` instead of `window`, changing scroll behavior | Pass an explicit `container` to `scrollToElement`; E2E-verify the section scrolls into view too |
+| ~~E2E selectors break on DOM restructure~~ — **neutralized by the count threshold.** All existing fixtures are ≤3 definitions, so every current Cypress and Selenium selector runs the legacy path | None. Do not "clean up" the legacy markup while converting — that is what would reintroduce this |
+| **Virtualized path ships untested** because every existing fixture is below threshold | The new perf fixtures are its only coverage and must exceed 100 definitions; add a just-under/just-over boundary pair |
+| Two render paths diverge over time — a fix applied to one branch only | Keep the legacy branch as a thin extraction of today's JSX, not a parallel reimplementation; unit-test both branches |
+| Find-in-page / print regression (see above) | Maintainer decision on threshold or flag |
+| CSS layout shift when `measureElement` corrects an expanded model | `min-height: 72px` on the item wrapper |
+
+## Dependencies
+
+- No dependency on other phases — Phase 1 is the first implementation and adds
+ `@tanstack/react-virtual` to `package.json`.
+- **Not blocked.** Both previously-open decisions are resolved: rollout is an automatic count
+ threshold with no config key, and the scroll container is `max-height: min(60vh, 800px)`. See
+ [Rollout](#rollout-automatic-count-threshold-decided--no-config-flag).
+
+## Out of Scope
+
+- Operations list virtualization → Phase 3
+- Schema property lists — out of scope for this epic; see the README's "Out of scope" section for why virtualization was rejected there
+- OAS 3.1 model rendering (`src/core/plugins/oas31/components/models/models.jsx` — a
+ self-contained second copy of this component per the CLAUDE.md cross-plugin rule) →
+ **[Phase 2](./phase-2-oas31-models.md)**, which ships in the same release. **Note:** on an OAS 3.1
+ document that copy renders instead of this one, so verify which component is live (React DevTools)
+ before benchmarking, and use `many-schemas.openapi.yaml` (3.0.0) — not the 3.1 fixture — when
+ measuring *this* ticket.
+- Model search/filter — none exists. `src/core/plugins/filter/` provides only `opsFilter`,
+ which filters operation *tags*; `models.jsx` contains no filter logic.
+
+## References
+
+- TanStack Virtual docs: https://tanstack.com/virtual/latest/docs/introduction
+- `measureElement` API: https://tanstack.com/virtual/latest/docs/api/virtualizer
+- Investigation: TanStack Virtual feasibility analysis (internal)
+- Phase 3 ticket: `phase-3-operations-virtualization.md`
diff --git a/.claude/implementation/perf-virtualization/phase-2-oas31-models.md b/.claude/implementation/perf-virtualization/phase-2-oas31-models.md
new file mode 100644
index 00000000000..ac67e7b5c68
--- /dev/null
+++ b/.claude/implementation/perf-virtualization/phase-2-oas31-models.md
@@ -0,0 +1,108 @@
+# [PERF] Virtualize OAS 3.1 Models List (oas31 copy)
+
+## Summary
+
+`src/core/plugins/oas31/components/models/models.jsx` is a **self-contained second copy** of the
+models list, rendered instead of the `json-schema-5` one when the document is OpenAPI 3.1. Phase 1
+virtualizes only the `json-schema-5` copy, so without this follow-up the OAS 3.1 path silently keeps
+the unvirtualized behavior.
+
+Apply the pattern Phase 1 established. This ticket is deliberately thin: it is a port, not a design.
+
+## Prerequisite
+
+**Phase 1 must be merged.** This ticket copies its approach — count threshold, `getItemKey`,
+`measureElement` ref, scroll container — and inherits the `@tanstack/react-virtual` dependency and
+the `ResizeObserver` polyfill in `test/unit/jest-shim.js`.
+
+Do not start before Phase 1; the pattern is expected to shift during its review.
+
+## Scope
+
+- `src/core/plugins/oas31/components/models/models.jsx` (primary)
+- `src/core/plugins/oas31/components/models/_models.scss` (the `.models-scroll` rule —
+ **this plugin has its own stylesheet**, Phase 1's edit to `src/style/_models.scss` does not cover it)
+- New unit test (none exists for this component today)
+- E2E: reuse `test/e2e-cypress/static/documents/perf/many-schemas.openapi.yaml` — but see the
+ OAS-version note below
+
+### Fixture caveat
+
+Phase 1's `many-schemas.openapi.yaml` declares `openapi: 3.0.0`, so it exercises the
+`json-schema-5` copy, **not** this one. This ticket needs a `3.1.x` variant — either add
+`many-schemas.openapi31.yaml` or parameterize the generator. Confirm which component is live
+(React DevTools) before benchmarking; getting this wrong means measuring Phase 1's work twice.
+
+## Why this is not just a copy-paste
+
+Per `CLAUDE.md`'s cross-plugin import guidelines, the two copies are intentionally independent —
+so **do not** import Phase 1's helpers from `json-schema-5`. Reimplement within `oas31`, matching
+the guidance in the CLAUDE.md "Cross-Plugin Import Guidelines" section.
+
+Verified differences from Phase 1 — these make it **easier** in one way and different in another:
+
+- **Already a functional component with hooks.** `const Models = ({ ... }) => {` at `:8`, importing
+ `useCallback`/`useEffect` (`:4`). **No class→functional conversion is needed** — which removes
+ Phase 1's single largest work item (its seven must-survive behaviors, the `docExpansion` trap, the
+ inline `requestResolvedSubtree` dispatch). Do not copy that section of Phase 1 across.
+- **`schemas` is a plain JS object, not Immutable.** `specSelectors.selectSchemas()` is consumed via
+ `Object.keys(schemas)` (`:17`). So the memoization strategy differs from Phase 1's
+ `definitions.entrySeq().toArray()`: a fresh object identity may arrive each render, so
+ `useMemo([schemas])` on the entry array can invalidate every time. Derive a stable dependency
+ (e.g. the joined key list) or memoize upstream. This is the same shape issue documented in
+ the same Immutable-vs-plain-object split noted in the README's "Out of scope" section.
+- Ref callbacks are `handleModelsRef` (`:52-57`, section) and `handleJSONSchema202012Ref`
+ (`:58-62`, per-schema, a curried `(schemaName) => (node) => …`). The curried form allocates a new
+ callback per render — composing it with `virtualizer.measureElement` needs care to avoid
+ re-triggering measurement on every render.
+- `schemasPath` is a hardcoded `["components", "schemas"]` (`:19`) — no `isOAS3()` branch to preserve.
+- `readyToScroll` is declared as a required prop (`:132`), which the `json-schema-5` copy does not do.
+
+### Stylesheet caveat
+
+`oas31/components/models/_models.scss` uses **direct-child combinators**, e.g.
+`.models .json-schema-2020-12:not(...) > .json-schema-2020-12-head`. Those particular `>` relations
+are internal to a schema item, so inserting a wrapper *above* `.json-schema-2020-12` should not break
+them — but verify, and re-run the Phase 1 CSS audit greps against this plugin's stylesheets
+specifically:
+
+```bash
+grep -rn "\.models\b" src/core/plugins/oas31/components/
+```
+
+## Inherited finding — the deep-link refs here are also dead code
+
+`oas31/components/models/models.jsx:54` and `:59` call `layoutActions.readyToScroll(...)` with a
+**schema path**. As established in Phase 1, schema paths never reach `scrollToKey`:
+`isShownKeyFromUrlHashArray` (`src/core/plugins/deep-linking/layout.js:175-184`) only ever emits
+`["operations", …]` or `["operations-tag", …]` — its own comment reads *"We only put operations in
+the URL"* — and the sole caller of `scrollTo` is `parseDeepLinkHash` (`:106`).
+
+**So there is no deep-link bridge to build here either.** Preserve the refs as-is; do not treat them
+as a blocker. (An earlier draft of Phase 1 did, incorrectly — this note exists so that mistake is
+not re-imported.)
+
+## Acceptance Criteria
+
+- [x] An OAS 3.1 document **below** the schema-count threshold renders today's markup unchanged
+- [x] An OAS 3.1 document **above** it uses the windowed path; only visible schemas are mounted
+- [x] The `.models-scroll` rule is present in `oas31/components/models/_models.scss` and matches
+ Phase 1's `max-height: min(60vh, 800px)`
+ > **Note:** No duplicate rule was added to the oas31 stylesheet. The global `src/style/_models.scss`
+ > (edited in Phase 1) already covers `.models-scroll` for all paths. This plugin's `_models.scss`
+ > only holds rules specific to `.json-schema-2020-12` elements; `.models-scroll` is a layout rule
+ > that belongs in the global stylesheet. The original assumption above is kept in case this changes.
+- [x] `getItemKey` produces stable, content-derived keys (expand a schema, scroll away and back —
+ no other schema is wrongly expanded)
+- [x] No cross-plugin import from `json-schema-5` was introduced
+- [x] Existing OAS 3.1 E2E specs pass unchanged
+- [x] Unit test added
+- [x] Confirmed via React DevTools that this component — not the `json-schema-5` copy — is the one
+ under test
+
+## References
+
+- Phase 1 ticket (the pattern to copy): `phase-1-models-virtualization.md`
+- `src/core/plugins/oas31/components/models/models.jsx`
+- `src/core/plugins/oas31/components/models/_models.scss`
+- CLAUDE.md: Cross-Plugin Import Guidelines
diff --git a/.claude/implementation/perf-virtualization/phase-3-operations-virtualization.md b/.claude/implementation/perf-virtualization/phase-3-operations-virtualization.md
new file mode 100644
index 00000000000..c455544b1fd
--- /dev/null
+++ b/.claude/implementation/perf-virtualization/phase-3-operations-virtualization.md
@@ -0,0 +1,674 @@
+# [PERF] Virtualize Operations List with TanStack Virtual
+
+## Summary
+
+Large OpenAPI specs with 500–1000+ operations mount all `OperationContainer` components
+simultaneously — one connected component and summary row each, whether or not it is expanded.
+(Collapsed operations do *not* mount their parameter/response subtrees; see Calibration below.)
+This ticket virtualizes the operations list using `useWindowVirtualizer` (document-level scroll) so
+only viewport-visible operations are mounted.
+
+## Background
+
+`src/core/components/operations.jsx` iterates over all tagged operations unconditionally:
+
+```jsx
+// Line 33 — renders ALL tags
+taggedOps.map(this.renderOperationTag).valueSeq().toArray()
+
+// Line 65 — inside each tag, renders ALL operations for that tag
+operations.map(op => ).toArray()
+```
+
+A spec with 500 operations across 20 tags mounts 500 `OperationContainer` components.
+
+**Calibration — collapsed operations are NOT heavyweight (verified).** `operation.jsx:120` wraps
+parameters, request body, responses and try-it-out in ``, and
+`Collapse` (`src/core/components/layout-utils.jsx:243`, in `renderNotAnimated()` 241-249) returns
+`` when closed —
+a real unmount, not a hide. With the default `docExpansion: "list"`
+(`src/core/config/defaults.js:14`) tags are open but individual operations are collapsed, so a
+collapsed operation mounts only `OperationContainer` → `Operation` → `OperationSummary`.
+
+The per-operation cost is therefore: one connected `OperationContainer` (its own Redux
+subscription and `mapStateToProps` run on every store change), the `OperationSummary` subtree,
+and the DOM for the summary row — multiplied by 500. Real, but roughly one order of magnitude
+smaller than "500 full parameter/response trees". Also note `docExpansion: "full"` *does* mount
+everything; state which mode a benchmark used.
+
+This causes:
+- Slow initial parse-to-paint (3–10s on large specs)
+- High memory usage
+- Sluggish tag expand/collapse (triggers re-renders across all siblings)
+
+The timings above are impressionistic. **Record a real baseline before starting** — see
+[Measuring Success](#measuring-success).
+
+## Scope
+
+- Package: `swagger-ui` (core)
+- Files affected:
+ - `src/core/components/operations.jsx` (primary — class → functional)
+ - `src/core/components/operation-tag.jsx` (structural: stops wrapping children)
+ - `src/core/plugins/deep-linking/layout.js` + `operation-wrapper.jsx` +
+ `operation-tag-wrapper.jsx` (ref-based scroll bridge — see constraint 3)
+ - `src/style/_layout.scss` — the `.opblock-tag-section` flex rule (line 14) stays for the legacy
+ path; the virtualized item wrapper needs its own `flex-direction: column` equivalent
+ - `src/core/utils/index.js` (add `VIRTUALIZE_OPERATIONS_THRESHOLD`)
+ - **Not** in scope any more: rewriting the ~14 E2E refs to `.opblock-tag-section` /
+ `.operation-tag-content`. The threshold keeps them on the legacy path — they are now a
+ regression contract, not a migration task
+ - `test/unit/core/components/operations.jsx` (**new file — and `test/unit/core/components/` is a
+ new directory**; existing unit tests live under `test/unit/core/plugins/`, `system/`, `config/`,
+ `helpers/`. No `testMatch` change needed — `config/jest/jest.unit.config.js:6-9` globs
+ `**/test/unit/**/*.js?(x)`, so the new path is picked up automatically)
+ - `test/e2e-cypress/static/documents/perf/many-operations.yaml` — **already created and committed
+ (2026-08-04)**: 529 operations across 24 tags, `flatItems` ≈ 554 fully expanded, comfortably
+ above the 150 threshold. Includes `operationId: multiTagged` at `/perf/multi-tagged` declaring
+ **two** tags (`perfTag01`, `perfTag02`) — the duplicate-key case for `getItemKey`. Generated
+ file; regenerate rather than hand-edit.
+- New dependency: `@tanstack/react-virtual` (added in Phase 1)
+
+## Acceptance Criteria
+
+- [ ] A spec **below** the 150-item threshold renders today's nested markup unchanged — the legacy
+ regression contract: `deep-linking.cy.js`, `oas32-component-only.cy.js` and the Selenium
+ scenarios all pass **with no edits**
+- [ ] A spec **above** the threshold uses the windowed path; only operations within the viewport are
+ mounted (verify in React DevTools)
+- [ ] Boundary tested both sides, and the path does not flip when tags are collapsed mid-session
+- [ ] `OperationTag` works in both modes — wraps children when given, header-only when not
+- [ ] Scrolling renders/unmounts operations smoothly without layout shift
+- [ ] Tag expand/collapse correctly adds/removes operations from virtual list
+- [ ] Deep linking (`/?urls.primaryName=...#/tag/operation`) scrolls to correct operation,
+ including an operation far outside the initial viewport and one inside a collapsed tag
+- [ ] Filter (search box) still narrows the list — operations under filtered-out **tags** are not
+ mounted. Inherited free from the `taggedOperations` wrapSelector; this AC guards against
+ regression, not new work
+- [ ] `maxDisplayedTags` config still truncates the tag list (also inherited from the same
+ wrapSelector — `wrap-selector.js:17-18`)
+- [ ] A spec with zero operations still renders `No operations defined in spec!` (`operations.jsx:27-29`)
+- [ ] Items are positioned correctly with content above the list (info block, servers, authorize) —
+ i.e. `scrollMargin` is applied; verify no constant vertical offset and that it survives the
+ info block resizing after spec load
+- [ ] Operations whose method is not in `specSelectors.validOperationMethods()` remain unrendered
+- [ ] Expanded operations (with try-it-out open) do not collapse/reset on scroll
+- [ ] A **multi-tag operation** renders under every tag it declares, with independent state:
+ expanding it under tag A does not expand it under tag B, and no duplicate-key warning appears
+ in the console (guards the `getItemKey` tag-prefix requirement)
+- [ ] Open try-it-out on operation A with a parameter value typed in, collapse an *earlier* tag so
+ every downstream index shifts, then scroll A back into view: A retains its state and **no
+ other operation** shows A's state (guards the `getItemKey` requirement)
+- [ ] No visual regression — tag headers, operation rows, spacing unchanged. **Exception:** loss
+ of the tag-content `` animation is accepted (constraint 6) — confirm with UX
+- [ ] No accessibility regression — landmark structure, keyboard nav, focus management preserved
+- [ ] Performance: initial render time for the 500-op fixture reduced by **≥50% vs. the recorded
+ baseline** (React Profiler). The targets in [Measuring Success](#measuring-success) are
+ stretch goals, not the pass bar
+- [ ] Unit tests created at `test/unit/core/components/operations.jsx` (none exist today)
+- [ ] E2E tests pass: operations render, expand, try-it-out works, deep links work
+
+## Technical Notes
+
+### Core Architecture Change: Flatten the List
+
+Operations are currently nested `tag → [operations]`. Virtualizers work on flat arrays. Flatten to a typed item array that reflects current expand state:
+
+```js
+// Item types in the flat virtual list
+{ type: "tag", tag: string, tagObj: ImmutableMap }
+{ type: "operation", tag: string, op: ImmutableMap, path: string, method: string,
+ specPath: ImmutableList, operationId: string }
+```
+
+`operationId` is needed by the deep-link bridge (constraint 3) — it must be on the item, since the
+scroll key is `["operations", tag, operationId]`, not path+method.
+
+**Shape of `taggedOps`:** `specSelectors.taggedOperations()`
+(`src/core/plugins/spec/selectors.js:260`) returns an `OrderedMap` **keyed by tag name**,
+whose values are `Map({ tagDetails, operations })`. There is **no `tagId` field on
+`tagObj`** — the tag name only exists as the map key, so iterate with `entrySeq()`:
+
+```js
+const flatItems = useMemo(() => {
+ const items = []
+ taggedOps.entrySeq().forEach(([tag, tagObj]) => {
+ items.push({ type: "tag", tag, tagObj })
+ // second arg replicates operation-tag.jsx:69 default
+ const tagOpen = layoutSelectors.isShown(
+ ["operations-tag", tag],
+ docExpansion === "full" || docExpansion === "list"
+ )
+ if (!tagOpen) return
+ tagObj.get("operations").forEach((op) => {
+ const method = op.get("method")
+ // preserve operations.jsx:70 guard — invalid methods must stay unrendered
+ if (validOperationMethods.indexOf(method) === -1) return
+ const path = op.get("path")
+ items.push({
+ type: "operation", tag, op, method, path,
+ specPath: op.get("specPath"),
+ // same 4-way fallback as OperationContainer.jsx:62 — required by the
+ // deep-link bridge (constraint 3). Factor into a shared helper.
+ operationId:
+ op.getIn(["operation", "__originalOperationId"]) ||
+ op.getIn(["operation", "operationId"]) ||
+ opId(op.get("operation"), path, method) ||
+ op.get("id"),
+ })
+ })
+ })
+ return items
+}, [taggedOps, validOperationMethods, docExpansion, expandedTagsState])
+```
+
+`validOperationMethods` comes from `specSelectors.validOperationMethods()`;
+`docExpansion` from `getConfigs()`; `opId` from `swagger-client/es/helpers`
+(as `OperationContainer.jsx:4`). See constraint 2 for `expandedTagsState`.
+
+### Use `useWindowVirtualizer` (No Scroll Container Needed)
+
+Swagger UI scrolls the full page — wrapping in a bounded div would break existing UX. `useWindowVirtualizer` virtualizes against `window` scroll:
+
+```jsx
+import { useWindowVirtualizer } from "@tanstack/react-virtual"
+
+const listRef = useRef(null)
+
+const virtualizer = useWindowVirtualizer({
+ count: flatItems.length,
+ // PLACEHOLDERS — measure a real .opblock-tag and a collapsed .opblock first
+ estimateSize: (i) => flatItems[i].type === "tag" ? 56 : 48,
+ overscan: 3,
+ // REQUIRED — see scrollMargin note below
+ scrollMargin: listRef.current?.offsetTop ?? 0,
+ // REQUIRED — see "Stable item keys" below. Without this, vItem.key is the index.
+ getItemKey: (index) => {
+ const item = flatItems[index]
+ return item.type === "tag"
+ ? `tag-${item.tag}`
+ // tag MUST be in the key — see "Multi-tag operations" below.
+ // NOT operations.jsx:76's `${path}-${method}`, which collides once flattened.
+ : `op-${item.tag}-${item.path}-${item.method}`
+ },
+})
+```
+
+#### Stable item keys (REQUIRED — silent state-corruption bug, worse here than in Phase 1)
+
+`vItem.key` **defaults to the item index**, and this list's indexes are *not* stable: collapsing
+or expanding any tag shifts the index of every item below it. With index keys, React reuses
+component instances across different operations, so local state rides along to the wrong row —
+an open **try-it-out** panel, entered parameter values, or an expanded response body landing on a
+different operation entirely. This directly breaks the AC "expanded operations do not
+collapse/reset on scroll", and it will look like a virtualization bug rather than a keying bug.
+
+##### Multi-tag operations — do NOT copy today's operation key
+
+Today's keys are `operation-${tag}` for tag headers (`operations.jsx:54`) and `${path}-${method}`
+for operations (`:76`). **Copying the latter verbatim introduces a duplicate-key bug.**
+
+An operation listed under two tags is rendered **twice** — `operationsWithTags`
+(`src/core/plugins/spec/selectors.js:245-256`) pushes the *same* op object into every tag it
+declares:
+
+```js
+return tags.reduce((res, tag) => res.update(tag, List(), (ar) => ar.push(op)), taggedMap)
+```
+
+Today those two instances live in **separate sibling scopes** (one `.operation-tag-content` per
+tag), so identical `${path}-${method}` keys are perfectly legal. Flattening puts them in **one
+list**, where the same key appears twice → React duplicate-key warning plus exactly the
+instance-reuse state bleed this section exists to prevent: expanding the operation under tag A
+expands it under tag B, and try-it-out state is shared between them.
+
+So the key **must** include the tag: `op-${tag}-${path}-${method}` (or `op-${tag}-${operationId}`).
+Prefix tag-header and operation keys distinctly too (`tag-` / `op-`) — they share one namespace now.
+
+This also means the fixture needs multi-tag coverage, which the spec below does not currently
+require.
+
+Do **not** pass a `measureElement` option — its real signature is
+`(element, entry, instance) => number` and the default is correct here. Dynamic heights come
+from attaching `virtualizer.measureElement` as a ref on each item wrapper (below).
+
+#### `scrollMargin` is mandatory here (standard `useWindowVirtualizer` footgun)
+
+The operations list is **not** at the top of the document — the info block, servers dropdown,
+authorize row and (in standalone) the topbar all render above it. A window virtualizer measures
+offsets from the **document origin**, so without `scrollMargin` every item is displaced downward
+by the height of everything above the list, and the virtualizer windows the wrong range.
+
+Two halves, both required:
+
+1. `scrollMargin: listRef.current?.offsetTop ?? 0` on the virtualizer, with `listRef` on the
+ list's outer element.
+2. Subtract it when positioning each item: `vItem.start - virtualizer.options.scrollMargin`.
+
+Note `offsetTop` is only known after first layout, and it **changes** when the info/servers/auth
+block above resizes (spec load, server selection, an error banner appearing, auth expanding). A
+stale `scrollMargin` produces a constant offset that looks like "virtualization is just broken".
+
+Concrete mechanism — do not leave this as "re-read on layout changes":
+
+```jsx
+const [scrollMargin, setScrollMargin] = useState(0)
+
+useLayoutEffect(() => {
+ const el = listRef.current
+ if (!el) return
+ const measure = () => setScrollMargin(el.offsetTop)
+ measure()
+ // catches info/servers/auth block resizes, which offsetTop does not notify about
+ const ro = new ResizeObserver(measure)
+ if (el.parentElement) ro.observe(el.parentElement)
+ window.addEventListener("resize", measure)
+ return () => { ro.disconnect(); window.removeEventListener("resize", measure) }
+}, [])
+```
+
+Then pass `scrollMargin` (state, not a live `.offsetTop` read) to the virtualizer. This adds a
+second `ResizeObserver` dependency — see the unit-test infrastructure note about the missing jsdom
+polyfill.
+
+### Render Virtual Items
+
+Resolve the components exactly as `operations.jsx:49-50` does today — note the
+**`true` second argument** on `OperationContainer`, which asks the system for the *connected*
+(container) component. Dropping it during the rewrite yields a component with no Redux props and
+is easy to miss:
+
+```jsx
+const OperationContainer = getComponent("OperationContainer", true) // container — keep `true`
+const OperationTag = getComponent("OperationTag") // presentational
+```
+
+Preserve the empty-spec guard from `operations.jsx:27-29` **before** the virtual list — it is easy
+to drop when the render body collapses into a virtualizer. (The duplicate check at
+`operations.jsx:34`, `taggedOps.size < 1 ?
…` inside the returned tree, is unreachable dead
+code behind the `:27` early return and should simply be deleted rather than ported.)
+
+```jsx
+if (taggedOps.size === 0) {
+ return
No operations defined in spec!
// keep verbatim, incl. leading space
+}
+```
+
+```jsx
+
+```
+
+### Key Constraints
+
+**1. Class → Functional component**
+`Operations` is a class component. `useWindowVirtualizer` is a hook. Must convert to functional.
+Mostly mechanical — but not purely, given the flattening below.
+
+Fold in this existing wart while converting: `Operations` declares propTypes **twice** — a
+`static propTypes` block (`operations.jsx:6-18`) and an `Operations.propTypes = {...}` assignment
+(`:92-99`) that **overwrites** it. Collapse to one correct declaration; do not port both.
+
+Two things to get right in the union:
+
+- **Both blocks have wrong types.** The static block declares `oas3Selectors: PropTypes.func`
+ (`:11`) and `fn: PropTypes.func` (`:17`) — both are **objects** (namespaces), not functions. The
+ `:92-99` block gets `fn: PropTypes.object` right but is otherwise a subset.
+- **The overwrite silently dropped five props** that are still passed and used:
+ `oas3Actions`, `oas3Selectors`, `authActions`, `authSelectors`, `getConfigs`. The merged
+ declaration must reinstate them, with `oas3Selectors` typed as `object`.
+
+Net: one `propTypes` covering all props from the static block, with `oas3Selectors` and `fn` as
+`PropTypes.object`.
+
+Also preserve the empty-spec guard at `:27-29` and drop the unreachable duplicate at `:34` — see
+[Render Virtual Items](#render-virtual-items).
+
+**2. Expand state reactivity**
+When a tag is toggled, `flatItems` length changes. The virtualizer must re-measure. `useMemo`
+deps must include the expand state — but `layoutSelectors` is a stable object, so listing it
+as a dep does **nothing**. Derive a primitive/Immutable value that actually changes:
+
+```js
+const tagDefaultOpen = docExpansion === "full" || docExpansion === "list"
+const expandedTagsState = taggedOps
+ .keySeq()
+ .map((tag) => layoutSelectors.isShown(["operations-tag", tag], tagDefaultOpen))
+ .join(",")
+```
+
+**Pass the same default as the flattening logic.** `isShown(state, thing, def)`
+(`src/core/plugins/layout/selectors.js:11`) returns `def` when the key is absent from state, and
+`def` is `undefined` if omitted — so a bare `isShown(["operations-tag", tag])` yields `undefined`
+for never-toggled tags while the flatten step and `operation-tag.jsx:69` both use
+`docExpansion === "full" || "list"`. Toggle *detection* still works either way, but the derived
+string must agree with the list it invalidates or the two can disagree on initial state.
+
+**3. Deep linking — existing ref mechanism breaks (BLOCKING)**
+
+Deep-link scrolling today is **ref-driven, not index-driven**:
+
+- `src/core/plugins/deep-linking/operation-wrapper.jsx:15` and
+ `operation-tag-wrapper.jsx:13` call `layoutActions.readyToScroll(isShownKey, ref)` when an
+ operation/tag DOM node mounts.
+- `readyToScroll` (`deep-linking/layout.js:110`) compares that key against
+ `layoutSelectors.getScrollToKey()` and calls `scrollToElement(ref)` only on a match.
+
+An operation outside the viewport never mounts, so its ref never fires and the deep link
+silently does nothing. `scrollToIndex` alone does not fix this — both paths must cooperate:
+
+1. Map the deep-link target to its index in `flatItems`. **The key shape matters and is not
+ path+method.** `getScrollToKey()` holds exactly what `isShownKeyFromUrlHashArray`
+ (`layout.js:175-184`) produced: `["operations", tag, operationId]` for an operation, or
+ `["operations-tag", tag]` for a tag.
+
+ `operationId` is **derived**, not a raw spec field — `OperationContainer.jsx:62`:
+
+ ```js
+ const operationId =
+ op.getIn(["operation", "__originalOperationId"]) ||
+ op.getIn(["operation", "operationId"]) ||
+ opId(op.get("operation"), props.path, props.method) || // "swagger-client/es/helpers"
+ op.get("id")
+ ```
+
+ So each flat operation item must carry `operationId` computed with that **exact 4-way fallback
+ chain** (import `opId` from `swagger-client/es/helpers` as `OperationContainer.jsx:4` does, or
+ factor the chain into a shared helper called from both places). A `path`+`method` lookup will
+ never match the key and the bridge will silently no-op.
+
+ Compare with `Im.is(scrollToKey, fromJS(key))` — `scrollToKey` is stored via `Im.fromJS`
+ (`layout.js:198`) and `readyToScroll` re-`fromJS`es the incoming key (`:113`), so `===` or a
+ plain-array compare never matches. (Phase 1 states this requirement too; it applies identically
+ here.)
+
+ If the target op is inside a collapsed tag, the tag must be expanded first or the item won't be
+ in `flatItems` at all.
+2. `virtualizer.scrollToIndex(idx, { align: "start" })` to force the item to mount.
+3. The now-mounted wrapper's existing `readyToScroll` ref fires and completes the current
+ flow unchanged.
+4. `virtualizer.measureElement` and the deep-linking wrapper ref both target the item
+ wrapper — compose them into a single callback ref rather than choosing one.
+
+Note this also affects **initial page load** with a deep link present, where measurement is
+still estimate-based and `scrollToIndex` may need a second pass after measurement settles.
+
+**4. Expanded operation height**
+A collapsed operation row is ~48px. An expanded one with try-it-out open can be 1000px+. `measureElement` handles this via ResizeObserver — the virtualizer auto-corrects after first render. Expect minor scroll jitter on first expand; this is inherent to dynamic virtualization.
+
+**5. Filter integration — already handled upstream; NO work required**
+
+The filter is a **wrapped selector**, not a component wrapper and not part of `Operations`.
+`src/core/plugins/layout/spec-extensions/wrap-selector.js` wraps `taggedOperations`:
+
+```js
+export const taggedOperations = (oriSelector, system) => (state, ...args) => {
+ let taggedOps = oriSelector(state, ...args)
+ const { fn, layoutSelectors, getConfigs } = system.getSystem()
+ const { maxDisplayedTags } = getConfigs()
+
+ let filter = layoutSelectors.currentFilter() // layout/selectors.js:9
+ if (filter) {
+ if (filter !== true) {
+ taggedOps = fn.opsFilter(taggedOps, filter) // line 13
+ }
+ }
+ if (maxDisplayedTags >= 0) {
+ taggedOps = taggedOps.slice(0, maxDisplayedTags) // lines 17-18
+ }
+ return taggedOps
+}
+```
+
+`opsFilter` itself (`src/core/plugins/filter/opsFilter.js`) is a 3-line file with a one-line body —
+`taggedOps.filter((tagObj, tag) => tag.indexOf(phrase) !== -1)` — so it drops whole **tags** by
+name and never inspects individual operations.
+
+Consequences:
+
+- **`specSelectors.taggedOperations()` already returns filtered, truncated results.** Building
+ `flatItems` from it inherits both the filter **and** the `maxDisplayedTags` limit for free.
+ Write no filtering code and no tag-limit code.
+- Do **not** add per-operation filtering — that would be a behavior change, not a port.
+- Nothing to locate or restructure here; this constraint exists only so the work isn't
+ reimplemented. The corresponding AC just asserts the inherited behavior still holds.
+
+**6. OperationTag structural change + CSS**
+
+`operation-tag.jsx:72–118` does more than render a header — it renders a
+`.opblock-tag-section` wrapper div (gaining `is-open` when expanded) containing the `
`
+header **and** `{children}`.
+
+Flattening makes header and operations siblings — **but only on the virtualized path.** Because the
+legacy path (below threshold) still nests, `OperationTag` becomes **dual-mode** rather than
+header-only:
+
+- When `children` are supplied (legacy path) it must behave exactly as today.
+- When they are not (virtualized path) it renders the `
` header alone, with no
+ `.opblock-tag-section` wrapper and no ``.
+- Simplest shape: keep the current render for the children case, and add an early
+ header-only return when `children == null`. Do **not** remove the `children` propType (`:30`) or
+ the `getComponent("Collapse")` call (`:51`) — both are still needed.
+- On the virtualized path the `.opblock-tag-section` / `.is-open` wrapper and the inner
+ `.operation-tag-content` div (`operations.jsx:63`) are absent. **On the legacy path they must
+ remain**, which is what keeps every existing test and embedder stylesheet working.
+- **CSS impact is small — verified.** `.opblock-tag-section` has exactly one rule,
+ `src/style/_layout.scss:14`: `display: flex; flex-direction: column`. No descendant selectors,
+ and no `.is-open` variant in the stylesheets. `.operation-tag-content` has **zero** style rules.
+ So this is a "reproduce `flex-direction: column` on the virtual wrapper" job, not a CSS
+ refactor. Confirm with `grep -rn "opblock-tag-section\|operation-tag-content" src/style/`.
+- **The real fallout is in tests, not styles.** `test/` references these class names in ~14
+ places — `test/e2e-cypress/e2e/features/deep-linking.cy.js` (×2),
+ `test/e2e-cypress/e2e/features/oas32/oas32-component-only.cy.js`, and ~9
+ `test/e2e-selenium/scenarios/` files (`bugs/4445.js`, `bugs/4485.js`, `bugs/4756.js`,
+ `bugs/4374.js`, `bugs/4587.js`, `bugs/4409.js`, `bugs/4196.js`,
+ `features/parameter-example-rendering.js`, `features/parameter-enum-rendering.js`).
+ Audit and update these before touching JSX:
+ `grep -rn "opblock-tag-section\|operation-tag-content" test/`
+- The `` open/close **animation for tag content is lost** — collapse becomes an
+ instant `flatItems` change. Flag this to UX as an accepted, intentional behavior change; it
+ is not a regression to fix.
+- `operation-tag.jsx` declares `children: PropTypes.element` (`:30`) — remove it once children are
+ no longer passed, along with the now-unused `const Collapse = getComponent("Collapse")` at `:51`.
+
+**DOM contract on the tag header — do not drop these.** The `
` must keep, verbatim:
+
+- `id={isShownKey.map(v => escapeDeepLinkPath(v)).join("-")}` (`:77`) — the tag anchor.
+- `data-tag={tag}` (`:78`) and `data-is-open={showTag}` (`:79`).
+
+`deep-linking.cy.js` targets tags through exactly these, e.g.
+`.opblock-tag[data-tag="myTag"][data-is-open="true"]` (`:78`,`:89`,`:173`,`:184`). They survive the
+header-only restructure unchanged — but they are attributes on an element whose parent is being
+deleted, so they are easy to lose in the edit.
+
+**Under the count-threshold decision these tests are no longer at risk** — all of them use fixtures
+far below 150 items, so they exercise the legacy path where these classes still exist:
+
+- `deep-linking.cy.js:237`/`:289` assert `.opblock-tag-section.is-open` positively —
+ `deep-linking.swagger.yaml` has **5** operations → legacy path → passes untouched.
+- `oas32-component-only.cy.js:18` asserts `.opblock-tag-section` does **not** exist —
+ `component-only.yaml` has **0** paths → legacy path → still a meaningful assertion, not a
+ vacuous one.
+- The 9 Selenium scenarios likewise (and Selenium is not in CI anyway).
+
+Treat this list as the **regression contract for the legacy path**: if any of these start failing,
+the legacy branch was altered and that is a bug in this ticket, not a test to update. Only if the
+threshold is later removed do these become the breaking changes previously described.
+
+### Unit-test infrastructure
+
+Same two blockers as Phase 1 — see
+[Phase 1 → Unit-test infrastructure](./phase-1-models-virtualization.md#unit-test-infrastructure-blocking--must-be-set-up-before-the-tests-can-be-written).
+If Phase 1 shipped the `ResizeObserver` polyfill in `test/unit/jest-shim.js`, this phase inherits
+it; verify rather than assume. The new `Operations` test is greenfield, so choose the same strategy
+Phase 1 settled on (recommended: mock `@tanstack/react-virtual`, and cover real windowing in
+Cypress).
+
+### Measuring Success
+
+Benchmark with the Kubernetes OpenAPI spec (~800 operations).
+
+**First task of this ticket: record the actual baseline.** The "Before" column below is an
+unmeasured estimate — replace every value with a measured one before implementation starts, and
+recompute the targets from it.
+
+Record `docExpansion` for every run — `"list"` (default, operations collapsed) and `"full"`
+(everything mounted) give wildly different numbers.
+
+| Metric | Before (ESTIMATE — measure) | Stretch target |
+|--------|-----------------------------|----------------|
+| Time to interactive | ~8s | <2s |
+| Mounted components | ~800 OperationContainer + OperationSummary (collapsed bodies already unmounted) | ~15 |
+| Memory (heap) | ~400MB | ~80MB |
+| Tag expand time | ~500ms | <50ms |
+
+The pass bar in the AC is ≥50% off measured baseline; this table is the aspiration.
+
+### Accepted Behavior Changes (confirm with maintainers before building)
+
+Same tradeoffs as Phase 1, and more acute here because operations are what users search for.
+**All of these apply only above the 150-item threshold** — below it the legacy path runs and behavior
+is identical to today:
+
+- **Browser find-in-page (Ctrl/Cmd-F) no longer finds off-screen operations** — no path,
+ summary, parameter name, or description outside the rendered window is findable. For a large
+ API this is the single most visible regression in the epic.
+- **Printing / "Save as PDF" captures only the rendered window.**
+- **Loss of the tag-content `` animation** (constraint 6).
+- **DOM restructure** — on above-threshold specs only, `.opblock-tag-section` and
+ `.operation-tag-content` are absent, which affects embedders with custom CSS or scripts targeting
+ them *on large specs*.
+- **`OperationTag` stays backward-compatible — no public API break.** It is resolved via
+ `getComponent("OperationTag")`, so embedders can override it (`docs/customization/plugin-api.md`).
+ Because the legacy path still passes `children`, overrides that render `this.props.children`
+ continue to work. The dual-mode requirement (constraint 6) exists precisely to preserve this. An
+ override that renders children *unconditionally* will render nothing extra on the virtualized
+ path — worth a release note, but not a break.
+
+Note the in-app filter only matches **tag names** (constraint 5), so it is not a substitute for
+Ctrl-F over operation paths.
+
+The count threshold **is** the mitigation and it covers most specs. Above it, no further mitigation
+exists: the in-app filter matches tag names only, and `overscan` widens the window by a few rows.
+
+### Rollout: automatic count threshold (DECIDED — no config flag)
+
+**Decided 2026-08-04:** virtualize only when the flattened item count exceeds a threshold. No config
+key.
+
+```js
+import { VIRTUALIZE_OPERATIONS_THRESHOLD } from "core/utils" // suggested = 150
+
+if (flatItems.length < VIRTUALIZE_OPERATIONS_THRESHOLD) {
+ // legacy path — today's nested tag → operations markup, unchanged
+ return renderLegacyOperations()
+}
+// windowed path below
+```
+
+This decision **substantially changes this ticket's risk profile — read before starting:**
+
+- **`OperationTag` must keep supporting `children` and its ``.** The legacy path still
+ passes children, so constraint 6's restructure becomes *additive*: `OperationTag` needs to render
+ header-only **when used by the virtualized path** (e.g. children simply absent) while continuing
+ to wrap children when they are supplied. Do **not** delete the `.opblock-tag-section` wrapper,
+ `.operation-tag-content`, the ``, or the `children` propType. This also means it is
+ **no longer a public plugin-API break** (see Accepted Behavior Changes).
+- **All the E2E fallout in constraint 6 evaporates.** `deep-linking.swagger.yaml` has **5**
+ operations and `oas32/component-only.yaml` has **0** paths, so `deep-linking.cy.js:237`/`:289`,
+ `oas32-component-only.cy.js:18` and the 9 Selenium scenarios all take the legacy path and pass
+ untouched. Keep constraint 6's DOM-contract list as the definition of what the legacy path must
+ keep producing.
+- **The virtualized path has zero existing coverage.** `many-operations.yaml` (529 ops / 24 tags,
+ `flatItems` ≈ 554 expanded) is its only E2E exercise. Note `flatItems` counts tag headers *plus*
+ operations of **expanded** tags only — with `docExpansion: "none"` the count collapses to 24 and
+ would fall *under* the threshold. Assert in the test that the virtualized path is actually taken,
+ and be deliberate about which `docExpansion` the perf spec runs with.
+- Test **both sides of the boundary**, and remember `flatItems.length` changes as tags expand and
+ collapse — decide deliberately whether the path can flip mid-session (recommended: evaluate the
+ threshold once on the un-collapsed total, so it cannot).
+
+Hooks must be called unconditionally before the branch; the early return goes after them.
+
+## Dependencies
+
+- Phase 1 must be complete — **for the dependency and the general windowing pattern only**
+ (`@tanstack/react-virtual` in `package.json`, `getItemKey` / `measureElement` / measurement-cache
+ conventions). Phase 1 explicitly does **not** build a `scrollToIndex` → `readyToScroll` bridge,
+ because model deep-linking does not exist (see Phase 1 Technical Notes). **This phase's deep-link
+ bridge is greenfield** — nothing to inherit, and it is the largest single unknown in the estimate.
+- Rollout question is **resolved** (automatic count threshold, no config key) — no longer blocking
+- **Larger than Phase 1 when sizing:** adds the tag/operation flattening, the greenfield deep-link
+ bridge, and a dual-mode `OperationTag`. The E2E selector audit is no longer in scope (threshold
+ decision), but maintaining two render paths is.
+
+## Out of Scope
+
+- Schema property lists — out of scope for this epic; see the README's "Out of scope" section for why virtualization was rejected there
+- `src/core/components/overview.jsx` tag list — typically <100 tags, low ROI
+- `swagger-ui-react` flavor — inherits the change automatically (it re-exports core), so no code
+ work. But it is a separately published package not covered by the Cypress suite, so smoke-test
+ it once before release, same as Phase 1.
+
+## Risks
+
+| Risk | Mitigation |
+|------|-----------|
+| Deep link scroll silently breaks — unmounted ops never fire `readyToScroll` (constraint 3) | Highest-risk item. Build the `scrollToIndex` → mount → ref bridge *first*, before the perf work; dedicated E2E suite for `/#/tag/op` patterns incl. off-screen and collapsed-tag targets |
+| `useMemo` on `flatItems` never invalidates because `layoutSelectors` is referentially stable | Derive a changing primitive for expand state (constraint 2); unit-test that toggling a tag changes `flatItems.length` |
+| ~~E2E selectors break on DOM restructure~~ — **neutralized by the count threshold**; all affected fixtures are ≤5 operations and take the legacy path | Keep the legacy branch producing identical DOM; treat any failure in those specs as a regression |
+| **Virtualized path ships untested** — no existing fixture reaches 150 items | `many-operations.yaml` is its only coverage; assert in the test that the virtualized path is actually taken |
+| `OperationTag` dual-mode branch handled incorrectly, breaking embedder overrides that render `children` | Explicit unit tests for both modes; keep the `children` propType and the `Collapse` import |
+| Two render paths diverge over time | Extract the legacy branch from today's JSX rather than reimplementing it |
+| Find-in-page / print regression | See [Accepted Behavior Changes](#accepted-behavior-changes-confirm-with-maintainers-before-building); needs a maintainer decision |
+| Missing `scrollMargin` silently offsets every item by the height of the info/servers/auth block | Mandatory `scrollMargin` + `translateY(start - scrollMargin)`; re-read `offsetTop` on layout change |
+| Deep-link index lookup built on `path`+`method` never matches the `operationId`-based key | Carry `operationId` on flat items using `OperationContainer.jsx:62`'s exact 4-way fallback |
+| Expand state desync | Integration test: expand tag, scroll away, scroll back — ops still expanded |
+| CSS layout shift on measureElement correction | Set `min-height: estimateSize` on item wrapper |
+| Screen reader DOM order | Verify with axe-core; flat DOM order matches visual order |
+
+## References
+
+- TanStack Virtual `useWindowVirtualizer`: https://tanstack.com/virtual/latest/docs/framework/react/api/useWindowVirtualizer
+- Phase 1 ticket: `phase-1-models-virtualization.md`
+
+- `src/core/components/operations.jsx`
+- `src/core/components/operation-tag.jsx`
diff --git a/.claude/skills/README.md b/.claude/skills/README.md
new file mode 100644
index 00000000000..0d5548066df
--- /dev/null
+++ b/.claude/skills/README.md
@@ -0,0 +1,151 @@
+# Swagger UI Claude Skills
+
+This directory contains custom skills for working with the Swagger UI codebase in Claude Code.
+
+## Available Skills
+
+### `/add-oas-support` - Add OpenAPI Specification Version Support
+
+Comprehensive skill for adding support for a new OpenAPI Specification version to Swagger UI.
+
+**Usage:**
+```
+/add-oas-support --version 4.0 --type major
+/add-oas-support --version 3.2 --type minor
+```
+
+**Parameters:**
+- `--version` (required): The OpenAPI version to add support for (e.g., "3.2", "4.0")
+- `--type` (optional): Version type - "major" or "minor" (default: "minor")
+
+**Structure:**
+The skill includes both a **Quick Reference** section at the top for experienced developers, and a **comprehensive guide** below for detailed implementation instructions.
+
+**What it does:**
+1. **Analyzes the target OAS version specification using WebFetch**
+ - Fetches official spec from https://spec.openapis.org/
+ - Systematically identifies new/modified/removed fields
+ - Maps specification changes to Swagger UI components
+ - Creates specification change document
+2. Creates the plugin directory structure
+3. Implements version detection logic
+4. Creates selector factories and component wrappers
+5. Implements new feature components based on spec analysis
+6. Handles authentication changes
+7. Registers plugin in presets
+8. Adds unit and E2E tests
+9. Updates documentation
+10. Runs full test suite
+
+**Key Features:**
+- ✅ **Quick Reference** section for rapid development
+- ✅ Comprehensive specification analysis workflow using WebFetch
+- ✅ Detailed mapping table from spec changes → components (15+ change types)
+- ✅ Real examples from OAS 3.1 implementation (6 detailed examples)
+- ✅ Iterative spec-driven development approach
+- ✅ Component-by-component verification checklist
+- ✅ Best practices for continuous spec reference
+- ✅ WebFetch query templates for spec analysis
+- ✅ Common pitfalls and solutions
+- ✅ Pre-submit checklist
+
+**Based on:**
+This skill follows the patterns established by the OAS 3.1 implementation (commit history analyzed from `src/core/plugins/oas31/`).
+
+**Key patterns:**
+- Plugin-based architecture with Redux state management
+- Selector factories for version-specific logic
+- Component wrapping for conditional rendering
+- afterLoad lifecycle hooks for function overrides
+- Plugin loading order (new version loaded LAST)
+
+**Prerequisites:**
+- Understanding of Swagger UI plugin architecture
+- Access to the new OAS specification document
+- All existing tests passing
+
+**Example workflow:**
+
+Adding OAS 4.0 support:
+```
+# Start the skill
+/add-oas-support --version 4.0 --type major
+
+# Claude will:
+# 1. Ask about new features in OAS 4.0
+# 2. Create plugin directory structure
+# 3. Generate boilerplate code
+# 4. Guide through implementation
+# 5. Add tests
+# 6. Update documentation
+# 7. Verify build
+```
+
+## Creating New Skills
+
+To create a new skill for Swagger UI:
+
+1. Create a markdown file in `.claude/skills/`
+2. Add frontmatter with skill metadata:
+ ```yaml
+ ---
+ name: skill-name
+ description: Brief description
+ args:
+ param1:
+ description: "Parameter description"
+ required: true
+ type: string
+ ---
+ ```
+3. Write comprehensive instructions following the patterns in existing skills
+4. Document common pitfalls and best practices
+5. Include code templates with placeholders
+6. Add to this README
+
+## Skill Development Guidelines
+
+When creating skills for Swagger UI:
+
+1. **Follow project conventions:**
+ - No semicolons
+ - Double quotes
+ - @prettier pragma in all new files
+ - .jsx extension for React components
+
+2. **Use the plugin architecture:**
+ - Don't modify core unnecessarily
+ - Follow established patterns
+ - Load new plugins at the end of presets
+
+3. **Include comprehensive tests:**
+ - Unit tests for logic
+ - Component tests for UI
+ - E2E tests for integration
+
+4. **Document thoroughly:**
+ - Update CLAUDE.md
+ - Update relevant docs
+ - Add inline JSDoc comments
+
+5. **Security first:**
+ - Use DOMPurify for HTML
+ - Validate all input
+ - Follow OWASP guidelines
+
+## Contributing
+
+To contribute new skills:
+
+1. Fork the repository
+2. Create skill in `.claude/skills/`
+3. Test thoroughly
+4. Update this README
+5. Submit pull request
+
+## Resources
+
+- **CLAUDE.md** - Comprehensive codebase guide
+- **Plugin API** - `docs/customization/plugin-api.md`
+- **Development Setup** - `docs/development/setting-up.md`
+- **Contributing Guide** - https://github.com/swagger-api/.github/blob/HEAD/CONTRIBUTING.md
diff --git a/.claude/skills/add-oas-support.md b/.claude/skills/add-oas-support.md
new file mode 100644
index 00000000000..36062c30ab4
--- /dev/null
+++ b/.claude/skills/add-oas-support.md
@@ -0,0 +1,2333 @@
+---
+name: add-oas-support
+description: Add support for a new OpenAPI Specification version to Swagger UI
+args:
+ version:
+ description: "OpenAPI version to add support for (e.g., 3.2, 4.0)"
+ required: true
+ type: string
+ type:
+ description: "Version type: 'major' for new major version, 'minor' for patch/minor"
+ required: false
+ type: string
+ default: "minor"
+---
+
+# Add OpenAPI Specification Version Support
+
+This skill guides you through adding support for a new OpenAPI Specification (OAS) version to Swagger UI, following the established architectural patterns from OAS 3.1 implementation.
+
+---
+
+## 📌 Quick Reference
+
+> **New to this skill?** Skip to [Prerequisites](#prerequisites) for the full guide.
+> **Already familiar?** Use this quick reference for rapid development.
+
+### 🚀 Quick Start
+
+```bash
+# Major version (e.g., OAS 4.0)
+/add-oas-support --version 4.0 --type major
+
+# Minor version (e.g., OAS 3.2)
+/add-oas-support --version 3.2 --type minor
+```
+
+### 📋 Essential Checklist
+
+#### Phase 1: Specification Analysis ⭐ MOST IMPORTANT
+- [ ] Access official spec at **https://spec.openapis.org/oas/v{VERSION}/**
+- [ ] Use WebFetch to analyze spec systematically
+- [ ] Create specification change document
+- [ ] Map changes to components using [mapping table](#step-1d-map-specification-changes-to-swagger-ui-components)
+
+#### Phase 2: Implementation
+- [ ] Create plugin directory structure
+- [ ] Implement version detection (`isOAS{VERSION}`)
+- [ ] Create selector factories
+- [ ] Implement components for each spec change
+- [ ] Wrap existing components if modified
+- [ ] Register plugin in preset (LAST position)
+
+#### Phase 3: Testing & Documentation
+- [ ] Add unit tests
+- [ ] Add E2E tests with spec examples
+- [ ] Update documentation
+- [ ] Verify build & run full test suite
+
+### 🔍 WebFetch Queries for Spec Analysis
+
+```javascript
+// 1. Fetch main spec differences
+WebFetch(
+ "https://spec.openapis.org/oas/v{VERSION}/",
+ "List all new top-level fields, modified fields, and removed fields compared to version {PREV_VERSION}"
+)
+
+// 2. Analyze new feature
+WebFetch(
+ "https://spec.openapis.org/oas/v{VERSION}/",
+ "Describe the '{NEW_FIELD}' field: structure, type, purpose, required/optional, and provide examples"
+)
+
+// 3. Check JSON Schema version
+WebFetch(
+ "https://spec.openapis.org/oas/v{VERSION}/",
+ "What JSON Schema version does this use? List changes from {PREV_JSON_SCHEMA_VERSION}"
+)
+
+// 4. Security schemes
+WebFetch(
+ "https://spec.openapis.org/oas/v{VERSION}/",
+ "List all security scheme types, highlighting new or changed types"
+)
+```
+
+### 🗺️ Spec Change → Component Mapping (Quick)
+
+| Spec Change | Component Action | File Location |
+|-------------|------------------|---------------|
+| **New top-level field (Object)** | Create new component | `components/{field}.jsx` |
+| **New top-level field (String)** | Add to info or create display | `components/{field}.jsx` |
+| **Modified Info object** | Wrap Info component | `wrap-components/info.jsx` |
+| **New info subfield** | Create component + wrap Info | `components/info-{field}.jsx` |
+| **New auth type** | Create auth component | `components/auth/{type}.jsx` |
+| **Modified security** | Wrap auth selector | `auth-extensions/wrap-selectors.js` |
+| **New operation field** | Extend Operation/selector | `spec-extensions/selectors.js` |
+| **JSON Schema keyword** | Create keyword component | `json-schema-{V}-extensions/` |
+| **JSON Schema version** | Create entire new plugin | `plugins/json-schema-{V}/` |
+
+See [full mapping table with examples](#step-1d-map-specification-changes-to-swagger-ui-components) for detailed guidance.
+
+### 📝 Component Implementation Pattern
+
+**For each new feature:**
+
+1. **Check spec first:**
+ ```javascript
+ WebFetch(url, "Describe {field} structure, type, examples")
+ ```
+
+2. **Create selector:**
+ ```javascript
+ // spec-extensions/selectors.js
+ export const selectNewField = createSelector(
+ (state) => state,
+ (state) => state.getIn(["spec", "json", "newField"])
+ )
+ ```
+
+3. **Create component:**
+ ```javascript
+ // components/new-field.jsx
+ const NewField = ({ specSelectors, getComponent }) => {
+ const data = specSelectors.selectNewField()
+ if (!data) return null
+ return
{/* render based on spec structure */}
+ }
+ ```
+
+4. **Register in plugin:**
+ ```javascript
+ // index.js
+ components: { NewField },
+ statePlugins: {
+ spec: {
+ selectors: {
+ selectNewField: createOnlyOAS{V}Selector(selectNewField)
+ }
+ }
+ }
+ ```
+
+5. **Add tests** (unit + E2E with spec examples)
+
+### ⚠️ Common Pitfalls
+
+1. ❌ **Guessing field structure** → ✅ Always check spec with WebFetch
+2. ❌ **Forgetting @prettier pragma** → ✅ Add to all new files
+3. ❌ **Wrong plugin load order** → ✅ New plugin LAST in preset
+4. ❌ **Using semicolons** → ✅ No semicolons (project convention)
+5. ❌ **Single quotes** → ✅ Use double quotes
+6. ❌ **Skipping spec examples** → ✅ Use as test fixtures
+7. ❌ **Hardcoded assumptions** → ✅ Verify everything in spec
+8. ❌ **`props.Ori` in ComponentWrapper** → ✅ Use `props.originalComponent` (see Pattern 3)
+9. ❌ **Copying entire Info component for minor version** → ✅ Use OpenAPIVersion wrapper + `getComponent("OAS{PREV}Info")`
+10. ❌ **OAS{VERSION} logic in OAS{PREV} plugin** → ✅ Each version's logic lives in its own plugin
+11. ❌ **`isOAS{PREV}` wrapper on minor version** → ✅ Only add if regex overlaps; minor bumps don't need it
+12. ❌ **New HTTP method in core `validOperationMethods`** → ✅ Add to `OPERATION_METHODS` + wrap with `createOnlyOAS{VERSION}SelectorWrapper`
+13. ❌ **Assuming new OAS meta-schema = new JSON Schema dialect** → ✅ Verify — OAS 3.2 still uses JSON Schema 2020-12
+14. ❌ **Inline version guard in wrap-components** → ✅ Always use `createOnlyOAS{VERSION}ComponentWrapper`; never write `(Original, system) => (props) => { if (...isOAS{VERSION}...) }` by hand — even expanding an existing guard (`isOAS31 || isOAS32`) is wrong; add dedicated wrap-components in the new plugin instead
+
+### ✅ Pre-Submit Checklist
+
+- [ ] All spec changes mapped to components
+- [ ] All components reference spec in comments
+- [ ] Tests use examples from spec
+- [ ] Version detection regex correct
+- [ ] Plugin loaded last in preset
+- [ ] All tests passing (lint + unit + E2E)
+- [ ] Build completes successfully
+- [ ] Documentation updated
+- [ ] No semicolons, double quotes everywhere
+- [ ] @prettier pragma in all new files
+
+### 📚 Quick Links
+
+- **OAS Specs:** https://spec.openapis.org/
+- **OAS 3.1:** https://spec.openapis.org/oas/v3.1.0/
+- **OAS 3.0:** https://spec.openapis.org/oas/v3.0.3/
+- **JSON Schema:** https://json-schema.org/
+- **CLAUDE.md:** Complete codebase guide
+
+### 🔖 Full Guide Navigation
+
+Need detailed guidance? Jump to these sections:
+
+- **[Prerequisites](#prerequisites)** - Setup and requirements
+- **[Architecture Overview](#architecture-overview)** - Plugin system explained
+- **[Key Design Patterns](#key-design-patterns-from-oas-31)** - Code patterns from OAS 3.1
+- **[Step 1: Analyze Specification](#step-1-analyze-new-oas-version-specification)** - ⭐ Complete spec analysis workflow
+ - [1D: Mapping Table](#step-1d-map-specification-changes-to-swagger-ui-components) - 15+ spec change types
+ - [1E: Real Examples](#step-1e-examples-oas-31-specification-changes--components) - 6 detailed examples
+ - [1H: WebFetch Workflow](#step-1h-using-webfetch-to-analyze-specifications) - Spec analysis with WebFetch
+- **[Step 2-17: Implementation](#step-2-create-plugin-directory-structure)** - Complete implementation steps
+- **[Practical Workflow](#practical-workflow-from-spec-to-components)** - End-to-end example
+- **[Best Practices](#best-practices-iterative-spec-driven-development)** - Spec-driven development
+- **[Common Pitfalls](#common-pitfalls)** - What to avoid
+- **[Final Checklist](#final-checklist)** - Pre-submission verification
+
+---
+
+**Remember: The specification at https://spec.openapis.org/ is your source of truth!**
+
+---
+
+## Prerequisites
+
+Before starting, ensure:
+- You understand the Swagger UI plugin architecture
+- You've read the CLAUDE.md guide
+- You have the OAS specification document for the new version
+- All tests pass: `npm test`
+
+## Architecture Overview
+
+Adding OAS version support involves:
+
+1. **Plugin Creation** - New plugin following established patterns
+2. **Version Detection** - Regex-based version identifier
+3. **Selector Factories** - Conditional logic for version-specific features
+4. **Component Implementation** - New/wrapped components for rendering
+5. **State Management** - Redux selectors/actions for new features
+6. **Lifecycle Hooks** - afterLoad modifications
+7. **Preset Registration** - Plugin loading order
+8. **Testing** - Unit and E2E tests
+9. **Documentation** - Update guides and README
+
+## Key Design Patterns (from OAS 3.1)
+
+### Pattern 1: Version Detection
+```javascript
+export const isOAS{VERSION} = (jsSpec) => {
+ const oasVersion = jsSpec.get("openapi")
+ return (
+ typeof oasVersion === "string" && /^{MAJOR}\.{MINOR}\.(?:[1-9]\d*|0)$/.test(oasVersion)
+ )
+}
+```
+
+### Pattern 2: Selector Factories
+
+**createOnlyOAS{VERSION}Selector** - Features exclusive to this version:
+```javascript
+export const createOnlyOAS{VERSION}Selector =
+ (selector) =>
+ (state, ...args) =>
+ (system) => {
+ if (system.getSystem().specSelectors.isOAS{VERSION}()) {
+ const selectedValue = selector(state, ...args)
+ return typeof selectedValue === "function"
+ ? selectedValue(system)
+ : selectedValue
+ } else {
+ return null
+ }
+ }
+```
+
+**createOnlyOAS{VERSION}SelectorWrapper** - Override previous versions:
+```javascript
+export const createOnlyOAS{VERSION}SelectorWrapper =
+ (selector) =>
+ (oriSelector, system) =>
+ (state, ...args) => {
+ if (system.getSystem().specSelectors.isOAS{VERSION}()) {
+ const selectedValue = selector(state, ...args)
+ return typeof selectedValue === "function"
+ ? selectedValue(oriSelector, system)
+ : selectedValue
+ } else {
+ return oriSelector(...args)
+ }
+ }
+```
+
+### Pattern 3: Component Wrappers
+
+**⚠️ IMPORTANT: `originalComponent` prop name, not `Ori`**
+
+`createOnlyOAS{VERSION}ComponentWrapper` passes the original component as `originalComponent` in props (NOT `Ori` — that's the OAS3/OAS30ComponentWrapFactory convention). Use `const { originalComponent: Ori } = props` to access it.
+
+**Pattern A — Reuse previous version's component via getComponent:**
+```javascript
+const ComponentWrapper = createOnlyOAS{VERSION}ComponentWrapper(({ getSystem }) => {
+ const system = getSystem()
+ const OAS{PREV_VERSION}Component = system.getComponent("OAS{PREV_VERSION}Component", true)
+ return
+})
+```
+
+**Pattern B — Render original component with extra props (e.g. version badge):**
+```javascript
+// Use `originalComponent` (NOT `Ori`) — that's the prop name createOnlyOAS{VERSION}ComponentWrapper passes
+const OpenAPIVersionWrapper = createOnlyOAS{VERSION}ComponentWrapper((props) => {
+ const { originalComponent: Ori } = props
+ return
+})
+```
+
+### Pattern 4: Function Wrapping
+```javascript
+export const wrapOAS{VERSION}Fn = (fn, system) => {
+ const { fn: systemFn, specSelectors } = system
+ return Object.fromEntries(
+ Object.entries(fn).map(([name, newImpl]) => {
+ const oriImpl = systemFn[name]
+ const impl = (...args) =>
+ specSelectors.isOAS{VERSION}()
+ ? newImpl(...args)
+ : typeof oriImpl === "function"
+ ? oriImpl(...args)
+ : undefined
+ return [name, impl]
+ })
+ )
+}
+```
+
+## Implementation Steps
+
+### Step 1: Analyze New OAS Version Specification
+
+**CRITICAL: Always start by reviewing the official specification at https://spec.openapis.org/**
+
+#### 1A. Access the Official Specification
+
+**Primary Resource:** https://spec.openapis.org/
+
+**Available Versions:**
+- OAS 2.0: https://spec.openapis.org/oas/v2.0/
+- OAS 3.0.x: https://spec.openapis.org/oas/v3.0.3/
+- OAS 3.1.x: https://spec.openapis.org/oas/v3.1.0/
+- Future versions will follow the pattern: https://spec.openapis.org/oas/v{MAJOR}.{MINOR}.{PATCH}/
+
+**What to download:**
+1. The specification document (usually in HTML or Markdown format)
+2. JSON Schema definitions (if available)
+3. Example specification files
+4. Change log or migration guide (if available)
+
+#### 1B. Systematic Specification Analysis
+
+Use the WebFetch tool to retrieve and analyze the specification:
+
+```bash
+# Example for analyzing OAS 4.0 (hypothetical)
+WebFetch("https://spec.openapis.org/oas/v4.0.0/", "List all new top-level fields, objects, and keywords introduced in this version compared to OAS 3.1")
+```
+
+**Read these sections carefully:**
+
+1. **Version Number Section**
+ - Confirm the exact version format (e.g., "4.0.0", "3.2.0")
+ - Note any version detection changes
+
+2. **OpenAPI Object (Root Level)**
+ - New top-level fields (e.g., `webhooks` in OAS 3.1)
+ - Modified existing fields
+ - Removed/deprecated fields
+
+3. **Info Object**
+ - New fields (e.g., `summary` in OAS 3.1)
+ - Changes to `license`, `contact`, etc.
+
+4. **Paths Object & Operations**
+ - New operation-level fields
+ - Changes to parameters, request bodies, responses
+ - New callback syntax or features
+
+5. **Components Object**
+ - New component types
+ - Schema changes (especially JSON Schema version)
+ - New security schemes
+
+6. **Security Object**
+ - New authentication types (e.g., `mutualTLS` in OAS 3.1)
+ - Changes to OAuth2 flows
+ - New security-related fields
+
+7. **Schema Object**
+ - JSON Schema version change (critical!)
+ - New keywords (e.g., `examples` vs `example`)
+ - Type system changes
+
+#### 1C. Create Specification Change Document
+
+Create a structured document listing all changes:
+
+**Template:**
+```markdown
+# OpenAPI {VERSION} Specification Analysis
+
+## Version Information
+- Version: {MAJOR}.{MINOR}.{PATCH}
+- Release Date: YYYY-MM-DD
+- Previous Version: {PREVIOUS_VERSION}
+- JSON Schema Version: Draft {VERSION} or {YEAR}
+
+## New Top-Level Fields
+1. **`newField`** (Type: Object)
+ - Location: Root level
+ - Purpose: Description from spec
+ - Required: Yes/No
+ - Example from spec: {...}
+ - Swagger UI Impact: Needs new component in BaseLayout
+
+## Modified Existing Fields
+1. **`info`**
+ - New subfields: `summary`, `newField`
+ - Changed behavior: Description
+ - Swagger UI Impact: Need to wrap Info component
+
+## New Operation-Level Features
+1. **`newOperationField`**
+ - Purpose: Description
+ - Swagger UI Impact: Extend Operation component
+
+## New Authentication Types
+1. **`newAuthType`**
+ - Type name: "newAuthType"
+ - Fields: {...}
+ - Swagger UI Impact: New auth component needed
+
+## JSON Schema Changes
+- Version change: Draft 2020-12 → Draft 2024
+- New keywords: `keyword1`, `keyword2`
+- Removed keywords: `oldKeyword`
+- Swagger UI Impact: New json-schema plugin needed
+
+## Breaking Changes
+1. Removed `x-deprecated-field`
+2. Changed behavior of `existingField`
+3. Required fields now enforced
+
+## Deprecated Features
+1. `oldField` - Use `newField` instead
+2. `oldAuthType` - Use `newAuthType` instead
+
+## Examples from Specification
+- Link to example files
+- Notable edge cases to test
+```
+
+#### 1D. Map Specification Changes to Swagger UI Components
+
+**CRITICAL MAPPING GUIDE:** Use this table to determine what components to create/modify:
+
+| Spec Change Type | Swagger UI Component Type | Location | Example from OAS 3.1 |
+|-----------------|---------------------------|----------|---------------------|
+| **New Top-Level Field (Object)** | New standalone component | `components/{feature-name}.jsx` | `webhooks` → `Webhooks.jsx` |
+| **New Top-Level Field (String/Primitive)** | Add to existing layout component or create info component | `components/{field-name}.jsx` | `jsonSchemaDialect` → `JsonSchemaDialect.jsx` |
+| **Modified Info Object** | Wrap Info component OR create OAS{VERSION}Info | `wrap-components/info.jsx` | `info.summary` → Wrapped InfoContainer |
+| **New Info Subfield** | Create new component, render in Info | `components/info-{field}.jsx` | `info.license.identifier` → OAS31License |
+| **Modified License/Contact** | Wrap component | `wrap-components/license.jsx` | `license.identifier` → Wrapped License |
+| **New Authentication Type** | New auth component | `components/auth/{type}.jsx` | `mutualTLS` → `MutualTLSAuth.jsx` |
+| **Modified Security Scheme** | Wrap auth selector | `auth-extensions/wrap-selectors.js` | mutualTLS → `definitionsToAuthorize` wrapper |
+| **New Operation Field** | Extend Operation or create plugin selector | `spec-extensions/selectors.js` | N/A in OAS 3.1 |
+| **New Callback Feature** | Extend Callbacks component or wrap | `wrap-components/callbacks.jsx` | Enhanced in OAS 3.1 |
+| **New Parameter Type** | Extend Parameters component | `components/parameters/` | N/A in OAS 3.1 |
+| **New Response Feature** | Extend Responses component | `components/responses/` | N/A in OAS 3.1 |
+| **New Media Type Feature** | Modify media-type component or fn | `oas{PREV}-extensions/fn.js` | File upload detection changes |
+| **JSON Schema Keyword** | New keyword component | `json-schema-{VERSION}-extensions/components/keywords/` | `example` → `JSONSchema202012KeywordExample.jsx` |
+| **JSON Schema Version Change** | Entire new plugin + samples plugin | `plugins/json-schema-{VERSION}/` | JSON Schema 2020-12 plugin |
+| **Modified Schema Rendering** | Wrap schema component | `wrap-components/model.jsx` | Models → OAS31Models wrapper |
+| **Version Detection Field** | Update VersionPragmaFilter | `components/version-pragma-filter.jsx` | Updated for isOAS31 |
+| **Deprecated Field** | Remove or add deprecation warning | Component showing warning | N/A in OAS 3.1 |
+
+#### 1E. Examples: OAS 3.1 Specification Changes → Components
+
+**Example 1: `webhooks` Top-Level Field**
+
+**From Spec (OAS 3.1):**
+```yaml
+openapi: 3.1.0
+webhooks:
+ newPet:
+ post:
+ requestBody:
+ description: Information about a new pet
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Pet'
+ responses:
+ '200':
+ description: Return a 200 status
+```
+
+**Mapping Decision:**
+- Type: New top-level field (Object containing operations)
+- Location in spec: Root level, same structure as `paths`
+- Swagger UI component: New standalone component
+- File: `src/core/plugins/oas31/components/webhooks.jsx`
+- Rendering: Reuses Operations component to render webhook operations
+- Selector: `selectWebhooks` in `spec-extensions/selectors.js`
+- Layout integration: Added to BaseLayout or StandaloneLayout
+
+**Example 2: `info.summary` Field**
+
+**From Spec (OAS 3.1):**
+```yaml
+info:
+ title: My API
+ summary: A brief summary of the API # NEW in OAS 3.1
+ description: Detailed description...
+ version: 1.0.0
+```
+
+**Mapping Decision:**
+- Type: New subfield in existing Info object
+- Location: `info.summary`
+- Swagger UI component: Wrap existing Info component
+- File: `src/core/plugins/oas31/wrap-components/info.jsx` + `src/core/plugins/oas31/components/info.jsx`
+- Selector: `selectInfoSummary` (if needed)
+- Rendering: Display summary above or below title
+
+**Example 3: `mutualTLS` Authentication Type**
+
+**From Spec (OAS 3.1):**
+```yaml
+components:
+ securitySchemes:
+ myMutualTLS:
+ type: mutualTLS # NEW in OAS 3.1
+ description: Client certificate authentication
+```
+
+**Mapping Decision:**
+- Type: New authentication scheme type
+- Location: `components.securitySchemes[name].type`
+- Swagger UI component: New auth component
+- File: `src/core/plugins/oas31/components/auth/mutual-tls-auth.jsx`
+- Selector wrapper: `auth-extensions/wrap-selectors.js` → `definitionsToAuthorize`
+- Rendering: Display in authorize modal with certificate upload UI
+
+**Example 4: `license.identifier` Field**
+
+**From Spec (OAS 3.1):**
+```yaml
+info:
+ license:
+ name: Apache 2.0
+ identifier: Apache-2.0 # NEW in OAS 3.1 (SPDX ID)
+ url: https://www.apache.org/licenses/LICENSE-2.0.html
+```
+
+**Mapping Decision:**
+- Type: New optional field in license object (alternative to url)
+- Location: `info.license.identifier`
+- Swagger UI component: Wrap License component + create selector
+- File: `src/core/plugins/oas31/wrap-components/license.jsx` + selector in `spec-extensions/selectors.js`
+- Logic: If `identifier` exists, construct URL from SPDX registry
+- Rendering: Make license text a hyperlink to SPDX page
+
+**Example 5: JSON Schema 2020-12 Keywords**
+
+**From Spec (OAS 3.1):**
+```yaml
+# OAS 3.1 uses JSON Schema 2020-12 which includes:
+components:
+ schemas:
+ Pet:
+ type: object
+ properties:
+ name:
+ type: string
+ example: Fluffy # Changed from 'examples' array in OAS 3.0
+ xml: # Now a JSON Schema keyword, not OAS-specific
+ name: pet
+ externalDocs: # Now a JSON Schema keyword
+ url: https://example.com
+ discriminator: # Now a JSON Schema keyword
+ propertyName: petType
+```
+
+**Mapping Decision:**
+- Type: JSON Schema version upgrade (Draft 7 → 2020-12)
+- Location: Schema objects throughout spec
+- Swagger UI components: Entire new plugin
+- Files:
+ - `src/core/plugins/json-schema-2020-12/` (main plugin)
+ - `src/core/plugins/json-schema-2020-12-samples/` (sample generation)
+ - `src/core/plugins/json-schema-2020-12/components/keywords/example.jsx`
+ - `src/core/plugins/json-schema-2020-12/components/keywords/xml.jsx`
+ - `src/core/plugins/json-schema-2020-12/components/keywords/discriminator.jsx`
+ - `src/core/plugins/json-schema-2020-12/components/keywords/external-docs.jsx`
+- Integration: OAS31 plugin extends JSON Schema 2020-12 keywords in `json-schema-2020-12-extensions/`
+
+**Example 6: `jsonSchemaDialect` Top-Level Field**
+
+**From Spec (OAS 3.1):**
+```yaml
+openapi: 3.1.0
+jsonSchemaDialect: https://spec.openapis.org/oas/3.1/dialect/base # NEW in OAS 3.1
+```
+
+**Mapping Decision:**
+- Type: New top-level field (String/URI)
+- Location: Root level
+- Swagger UI component: Simple display component
+- File: `src/core/plugins/oas31/components/json-schema-dialect.jsx`
+- Selector: `selectJsonSchemaDialectField` in `spec-extensions/selectors.js`
+- Rendering: Show in info section with warning if non-default
+
+#### 1F. Specification Analysis Checklist
+
+Use WebFetch and Read tools to systematically check:
+
+- [ ] **Accessed official spec at https://spec.openapis.org/**
+- [ ] **Downloaded specification document**
+- [ ] **Read version number section**
+- [ ] **Analyzed OpenAPI Object (root level) changes**
+- [ ] **Reviewed Info Object modifications**
+- [ ] **Checked Paths/Operations changes**
+- [ ] **Examined Components Object updates**
+- [ ] **Identified Security Scheme changes**
+- [ ] **Analyzed Schema Object / JSON Schema version**
+- [ ] **Listed all new top-level fields**
+- [ ] **Listed all modified existing fields**
+- [ ] **Documented breaking changes**
+- [ ] **Identified deprecated features**
+- [ ] **Found example specifications**
+- [ ] **Created specification change document**
+- [ ] **Mapped changes to Swagger UI components using table above**
+- [ ] **Prioritized implementation order**
+
+#### 1G. Questions to Answer During Analysis
+
+**Version Type:**
+- Is this a major version (4.0) or minor version (3.2)?
+- Are there breaking changes?
+- Is backward compatibility maintained?
+
+**JSON Schema:**
+- Does it use a new JSON Schema version?
+- What new keywords are available?
+- Are there removed/deprecated keywords?
+
+**Top-Level Changes:**
+- What new top-level fields are added?
+- Which existing fields have new subfields?
+- Any removed top-level fields?
+
+**Operation Changes:**
+- Are there new operation-level features?
+- Changes to parameters, request bodies, responses?
+- New callback or link syntax?
+
+**Security Changes:**
+- What authentication types are new/changed?
+- OAuth2 flow modifications?
+- New security requirements?
+
+**Media Type Changes:**
+- Are there new media type features?
+- File upload handling changes?
+- Encoding changes?
+
+**Component Mapping:**
+- Which components need to be created from scratch?
+- Which existing components need to be wrapped?
+- What selectors are required?
+- Are new plugins needed (e.g., for JSON Schema)?
+
+**Implementation Priority:**
+- What's the critical path for basic rendering?
+- Which features can be added incrementally?
+- What has the highest user impact?
+
+### Step 1H: Using WebFetch to Analyze Specifications
+
+**Always use WebFetch tool to retrieve and analyze the specification systematically.**
+
+**Step-by-step WebFetch workflow:**
+
+1. **Fetch the specification landing page:**
+```javascript
+WebFetch(
+ "https://spec.openapis.org/oas/v{VERSION}/",
+ "Provide a summary of this OpenAPI Specification version, including links to the full specification document and any change logs or migration guides."
+)
+```
+
+2. **Analyze the specification structure:**
+```javascript
+WebFetch(
+ "https://spec.openapis.org/oas/v{VERSION}/",
+ "List all top-level fields in the OpenAPI Object for this version. For each field, indicate if it is new, modified, or unchanged from version {PREVIOUS_VERSION}."
+)
+```
+
+3. **Deep dive into new features:**
+```javascript
+WebFetch(
+ "https://spec.openapis.org/oas/v{VERSION}/",
+ "Describe the '{NEW_FIELD}' field in detail: its type, structure, purpose, whether it's required, and provide examples from the specification."
+)
+```
+
+4. **Analyze security changes:**
+```javascript
+WebFetch(
+ "https://spec.openapis.org/oas/v{VERSION}/",
+ "List all security scheme types supported in this version. Highlight any new or changed security scheme types compared to {PREVIOUS_VERSION}."
+)
+```
+
+5. **Check JSON Schema compatibility:**
+```javascript
+WebFetch(
+ "https://spec.openapis.org/oas/v{VERSION}/",
+ "What version of JSON Schema does this OpenAPI Specification version use? List any specific dialect or modifications to JSON Schema."
+)
+```
+
+6. **Find example specifications:**
+```javascript
+WebFetch(
+ "https://spec.openapis.org/oas/v{VERSION}/",
+ "Provide links to example OpenAPI specification files for this version, particularly examples that demonstrate new features."
+)
+```
+
+**Real example from analyzing OAS 3.1:**
+
+```javascript
+// 1. Initial analysis
+WebFetch(
+ "https://spec.openapis.org/oas/v3.1.0/",
+ "List all new fields and features introduced in OpenAPI 3.1.0 compared to OpenAPI 3.0.3"
+)
+
+// Response would include:
+// - webhooks field (new top-level)
+// - info.summary (new)
+// - info.license.identifier (new)
+// - jsonSchemaDialect (new top-level)
+// - mutualTLS security scheme type (new)
+// - JSON Schema 2020-12 (changed from Draft 7)
+
+// 2. Deep dive into webhooks
+WebFetch(
+ "https://spec.openapis.org/oas/v3.1.0/",
+ "Explain the 'webhooks' field in detail: structure, purpose, and provide a complete example"
+)
+
+// 3. Understand JSON Schema changes
+WebFetch(
+ "https://spec.openapis.org/oas/v3.1.0/",
+ "What are the differences between JSON Schema Draft 7 (used in OAS 3.0) and JSON Schema 2020-12 (used in OAS 3.1) that affect schema definitions?"
+)
+```
+
+**Pro tip:** Use multiple targeted queries rather than one broad query. This gives more precise information for each component you need to implement.
+
+### Step 2: Create Plugin Directory Structure
+
+**Directory:** `src/core/plugins/oas{VERSION_NUMBER}/`
+
+**Files to Create:**
+```
+src/core/plugins/oas{VERSION_NUMBER}/
+├── index.js # Main plugin export
+├── fn.js # Selector/function factories
+├── selectors.js # Version-specific selectors (if needed)
+├── after-load.js # Lifecycle hook
+├── auth-extensions/ # Authentication features
+│ └── wrap-selectors.js
+├── oas{PREVIOUS_VERSION}-extensions/ # Extensions to previous version
+│ └── fn.js
+├── spec-extensions/ # Spec-level features
+│ ├── selectors.js
+│ └── wrap-selectors.js
+├── components/ # New components
+│ ├── version-pragma-filter.jsx
+│ ├── {new-feature-1}.jsx
+│ └── auth/
+│ └── {new-auth-type}.jsx
+├── wrap-components/ # Component wrappers
+│ ├── info.jsx
+│ ├── license.jsx
+│ └── {existing-component}.jsx
+└── json-schema-{VERSION}-extensions/ # If new JSON Schema version
+ ├── fn.js
+ ├── components/keywords/
+ └── wrap-components/keywords/
+```
+
+**Create the directories:**
+```bash
+mkdir -p src/core/plugins/oas{VERSION_NUMBER}/{auth-extensions,oas{PREVIOUS_VERSION}-extensions,spec-extensions,components/auth,wrap-components}
+```
+
+### Step 3: Implement Version Detection (fn.js)
+
+**File:** `src/core/plugins/oas{VERSION_NUMBER}/fn.js`
+
+**Template:**
+```javascript
+/**
+ * @prettier
+ */
+
+/**
+ * Detects if a spec is OpenAPI {MAJOR}.{MINOR}.x
+ */
+export const isOAS{VERSION} = (jsSpec) => {
+ const oasVersion = jsSpec.get("openapi")
+ return (
+ typeof oasVersion === "string" &&
+ /^{MAJOR}\.{MINOR}\.(?:[1-9]\d*|0)$/.test(oasVersion)
+ )
+}
+
+/**
+ * Creates a selector that only returns a value for OAS {VERSION} specs
+ */
+export const createOnlyOAS{VERSION}Selector =
+ (selector) =>
+ (state, ...args) =>
+ (system) => {
+ if (system.getSystem().specSelectors.isOAS{VERSION}()) {
+ const selectedValue = selector(state, ...args)
+ return typeof selectedValue === "function"
+ ? selectedValue(system)
+ : selectedValue
+ } else {
+ return null
+ }
+ }
+
+/**
+ * Creates a selector wrapper that uses new impl for OAS {VERSION}, falls back to original
+ */
+export const createOnlyOAS{VERSION}SelectorWrapper =
+ (selector) =>
+ (oriSelector, system) =>
+ (state, ...args) => {
+ if (system.getSystem().specSelectors.isOAS{VERSION}()) {
+ const selectedValue = selector(state, ...args)
+ return typeof selectedValue === "function"
+ ? selectedValue(oriSelector, system)
+ : selectedValue
+ } else {
+ return oriSelector(...args)
+ }
+ }
+
+/**
+ * Creates a system-aware selector
+ */
+export const createSystemSelector =
+ (selector) =>
+ (state, ...args) =>
+ (system) => {
+ const selectedValue = selector(state, system, ...args)
+ return typeof selectedValue === "function"
+ ? selectedValue(system)
+ : selectedValue
+ }
+
+/**
+ * Creates a component wrapper that only renders for OAS {VERSION}.
+ * When active, passes `originalComponent` (the unwrapped original) and
+ * `getSystem` as extra props. Access the original via:
+ * const { originalComponent: Ori } = props
+ * NOT via `props.Ori` — that's the OAS3/OAS30ComponentWrapFactory convention.
+ */
+export const createOnlyOAS{VERSION}ComponentWrapper =
+ (Component) => (Original, system) => (props) => {
+ if (system.specSelectors.isOAS{VERSION}()) {
+ return (
+
+ )
+ }
+
+ return
+ }
+
+/**
+ * Wraps functions to conditionally use OAS {VERSION} implementations
+ */
+export const wrapOAS{VERSION}Fn = (fn, system) => {
+ const { fn: systemFn, specSelectors } = system
+ return Object.fromEntries(
+ Object.entries(fn).map(([name, newImpl]) => {
+ const oriImpl = systemFn[name]
+ const impl = (...args) =>
+ specSelectors.isOAS{VERSION}()
+ ? newImpl(...args)
+ : typeof oriImpl === "function"
+ ? oriImpl(...args)
+ : undefined
+ return [name, impl]
+ })
+ )
+}
+```
+
+### Step 4: Create Main Plugin Export (index.js)
+
+**File:** `src/core/plugins/oas{VERSION_NUMBER}/index.js`
+
+**Template:**
+```javascript
+/**
+ * @prettier
+ */
+
+import afterLoad from "./after-load.js"
+import {
+ isOAS{VERSION},
+ createSystemSelector,
+ createOnlyOAS{VERSION}Selector,
+ createOnlyOAS{VERSION}SelectorWrapper,
+ createOnlyOAS{VERSION}ComponentWrapper,
+} from "./fn.js"
+
+// Import spec extensions
+import * as specExtensionsSelectors from "./spec-extensions/selectors.js"
+import * as specExtensionsWrapSelectors from "./spec-extensions/wrap-selectors.js"
+
+// Import auth extensions
+import * as authExtensionsWrapSelectors from "./auth-extensions/wrap-selectors.js"
+
+// Import OAS{PREVIOUS_VERSION} extensions
+import * as oas{PREVIOUS_VERSION}ExtensionsFn from "./oas{PREVIOUS_VERSION}-extensions/fn.js"
+
+// Import components
+import VersionPragmaFilter from "./components/version-pragma-filter.jsx"
+// Import new feature components here
+
+// Import wrap components
+import InfoWrapper from "./wrap-components/info.jsx"
+// Import other wrappers here
+
+/**
+ * OpenAPI {MAJOR}.{MINOR} Plugin
+ *
+ * Adds support for OpenAPI Specification {MAJOR}.{MINOR}.x
+ *
+ * This plugin should be loaded AFTER:
+ * - oas{PREVIOUS_VERSION} plugin
+ * - json-schema-{JSON_SCHEMA_VERSION} plugin (if applicable)
+ *
+ * It wraps and overrides components/selectors from previous versions.
+ */
+const OpenAPI{VERSION}Plugin = ({ fn }) => {
+ return {
+ fn: {
+ oas{VERSION_NUMBER}: {
+ isOAS{VERSION},
+ createSystemSelector,
+ createOnlyOAS{VERSION}Selector,
+ createOnlyOAS{VERSION}SelectorWrapper,
+ createOnlyOAS{VERSION}ComponentWrapper,
+ },
+ },
+ components: {
+ // New components
+ OAS{VERSION}VersionPragmaFilter: VersionPragmaFilter,
+ // Add new feature components here
+ },
+ wrapComponents: {
+ // Wrapped components
+ VersionPragmaFilter: (Ori, system) =>
+ system.specSelectors.isOAS{VERSION}()
+ ? system.getComponent("OAS{VERSION}VersionPragmaFilter", true)
+ : Ori,
+ InfoContainer: InfoWrapper,
+ // Add other wrappers here
+ },
+ statePlugins: {
+ spec: {
+ selectors: {
+ // Add version detection selector
+ isOAS{VERSION}: createSystemSelector(specExtensionsSelectors.selectIsOAS{VERSION}),
+ // Add new feature selectors here
+ },
+ wrapSelectors: {
+ // Wrap previous version selectors if needed
+ ...specExtensionsWrapSelectors,
+ },
+ },
+ auth: {
+ wrapSelectors: {
+ // Add auth extensions if needed
+ ...authExtensionsWrapSelectors,
+ },
+ },
+ oas{VERSION_NUMBER}: {
+ selectors: {
+ // Plugin-specific selectors
+ },
+ },
+ },
+ afterLoad,
+ }
+}
+
+export default OpenAPI{VERSION}Plugin
+```
+
+### Step 5: Implement Spec Extensions
+
+**File:** `src/core/plugins/oas{VERSION_NUMBER}/spec-extensions/selectors.js`
+
+**Template:**
+```javascript
+/**
+ * @prettier
+ */
+
+import { createSelector } from "reselect"
+import { isOAS{VERSION} } from "../fn.js"
+
+/**
+ * Detects if the current spec is OAS {VERSION}
+ */
+export const selectIsOAS{VERSION} = (state, system) => () => {
+ const spec = system.specSelectors.specJson()
+ return isOAS{VERSION}(spec)
+}
+
+// Add selectors for new OAS {VERSION} fields here
+// Example:
+// export const selectNewField = createSelector(
+// (state) => state,
+// (state) => {
+// const spec = state.getIn(["spec", "json"])
+// return spec.get("newField")
+// }
+// )
+```
+
+**File:** `src/core/plugins/oas{VERSION_NUMBER}/spec-extensions/wrap-selectors.js`
+
+**⚠️ Minor version: DON'T wrap `isOAS{PREV}` unless the regex actually matches both versions.** For example, OAS 3.1's `isOAS31` regex (`/^3\.1\./`) will never match `3.2.x`, so wrapping it to return `false` is dead code. Only add the `isOAS{PREV}` override if the previous version's detection regex would incorrectly match the new version.
+
+**Template:**
+```javascript
+/**
+ * @prettier
+ */
+
+import { createOnlyOAS{VERSION}SelectorWrapper } from "../fn.js"
+
+// Ensure OAS {VERSION} specs are recognized as OAS 3.x (needed when major version number didn't change)
+export const isOAS3 =
+ (oriSelector, system) =>
+ (state, ...args) => {
+ const isOAS{VERSION} = system.specSelectors.isOAS{VERSION}()
+ return isOAS{VERSION} || oriSelector(...args)
+ }
+
+// ONLY add isOAS{PREV} wrapper if the previous version's regex could match this new version.
+// For a minor version bump (e.g. 3.1 → 3.2), the previous regex won't match, so DON'T add this.
+// export const isOAS{PREV} = createOnlyOAS{VERSION}SelectorWrapper((state) => () => false)
+```
+
+### Step 6: Create Version Pragma Filter Component
+
+**File:** `src/core/plugins/oas{VERSION_NUMBER}/components/version-pragma-filter.jsx`
+
+**Template:**
+```javascript
+/**
+ * @prettier
+ */
+
+import React from "react"
+import PropTypes from "prop-types"
+
+const OAS{VERSION}VersionPragmaFilter = ({ bypass, isSwagger2, isOAS3, isOAS{PREVIOUS_VERSION}, isOAS{VERSION} }) => {
+ // Handle version detection logic
+ const isAmbiguous =
+ (isSwagger2 && isOAS3) ||
+ (isSwagger2 && isOAS{PREVIOUS_VERSION}) ||
+ (isSwagger2 && isOAS{VERSION}) ||
+ (isOAS3 && isOAS{PREVIOUS_VERSION}) ||
+ (isOAS3 && isOAS{VERSION}) ||
+ (isOAS{PREVIOUS_VERSION} && isOAS{VERSION})
+
+ const isMissing = !isSwagger2 && !isOAS3 && !isOAS{PREVIOUS_VERSION} && !isOAS{VERSION}
+
+ if (bypass) {
+ return null
+ }
+
+ if (isAmbiguous) {
+ return (
+
+
+
+
Unable to render this document, as it contains multiple OpenAPI version fields.
+
+
+
+ )
+ }
+
+ if (isMissing) {
+ return (
+
+
+
+
Unable to render this document, as it doesn't contain an OpenAPI version field.