From 95906f60258380988e0c2dee5a35c26ee6cf1b06 Mon Sep 17 00:00:00 2001 From: Oliwia Rogala Date: Fri, 21 Aug 2026 11:14:51 +0200 Subject: [PATCH 1/2] feat: add virtualization for schema section --- .../phase-1-models-virtualization.md | 30 +- package-lock.json | 28 ++ package.json | 1 + src/core/plugins/deep-linking/layout.js | 26 +- .../json-schema-5/components/models.jsx | 341 ++++++++++++------ src/core/utils/index.js | 2 + src/style/_models.scss | 12 + .../e2e/features/models-virtualization.cy.js | 76 ++++ .../features/plugins/oas3/complex-spec.cy.js | 4 + .../json-schema-5/components/models.jsx | 183 ++++++++-- test/unit/jest-shim.js | 6 + 11 files changed, 546 insertions(+), 163 deletions(-) create mode 100644 test/e2e-cypress/e2e/features/models-virtualization.cy.js diff --git a/.claude/implementation/perf-virtualization/phase-1-models-virtualization.md b/.claude/implementation/perf-virtualization/phase-1-models-virtualization.md index b50d2e22cc5..152486afa36 100644 --- a/.claude/implementation/perf-virtualization/phase-1-models-virtualization.md +++ b/.claude/implementation/perf-virtualization/phase-1-models-virtualization.md @@ -76,39 +76,39 @@ component renders instead of this one. ## Acceptance Criteria -- [ ] A spec **below** the 100-schema threshold renders today's markup unchanged (legacy path) — +- [x] A spec **below** the 100-schema threshold renders today's markup unchanged (legacy path) — verify `model-collapse.cy.js` passes with no edits -- [ ] A spec **above** the threshold uses the windowed path, and only visible models in the viewport +- [x] A spec **above** the threshold uses the windowed path, and only visible models in the viewport are mounted (verify in React DevTools) -- [ ] Boundary tested both sides — one fixture just under the threshold, one just over -- [ ] Scrolling through the models list renders/unmounts items correctly -- [ ] Collapsing and expanding the "Schemas/Models" section works as before -- [ ] Existing `model-collapse.cy.js` scenarios still pass **with no edits** — its fixtures have 3 +- [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 -- [ ] `defaultModelsExpandDepth < 0` still short-circuits the whole section to `null` +- [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 -- [ ] No accessibility regression — keyboard navigation and screen reader order preserved -- [ ] Performance: initial render time for the 200+ model fixture reduced vs. the recorded baseline +- [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) -- [ ] Unit tests updated in `test/unit/core/plugins/json-schema-5/components/models.jsx` -- [ ] `ResizeObserver` polyfill added to `test/unit/jest-shim.js` and `npm run test:unit` green +- [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 -- [ ] `swagger-ui-react` still renders models correctly — the flavor re-exports core, so it +- [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 -- [ ] E2E test: models section scrolls and renders correctly with the new fixture -- [ ] `#model-` browser-anchor navigation still works below the threshold, and its +- [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) -- [ ] Expanding model A, scrolling it out of view, and scrolling back shows A still expanded and +- [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 diff --git a/package-lock.json b/package-lock.json index 7fc53c07ea9..aff9687596d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@babel/runtime-corejs3": "^7.27.1", "@scarf/scarf": "=1.4.0", + "@tanstack/react-virtual": "=3.14.9", "base64-js": "^1.5.1", "buffer": "^6.0.3", "classnames": "^2.5.1", @@ -7669,6 +7670,33 @@ "node": ">=12.20.0" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.9", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.9.tgz", + "integrity": "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.7", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.7.tgz", + "integrity": "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tokenizer/inflate": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", diff --git a/package.json b/package.json index 3ff966dcf27..90640330a1f 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "dependencies": { "@babel/runtime-corejs3": "^7.27.1", "@scarf/scarf": "=1.4.0", + "@tanstack/react-virtual": "=3.14.9", "base64-js": "^1.5.1", "buffer": "^6.0.3", "classnames": "^2.5.1", diff --git a/src/core/plugins/deep-linking/layout.js b/src/core/plugins/deep-linking/layout.js index bbd526cb9ff..2434b7cb44c 100644 --- a/src/core/plugins/deep-linking/layout.js +++ b/src/core/plugins/deep-linking/layout.js @@ -5,6 +5,8 @@ import Im, { fromJS } from "immutable" const SCROLL_TO = "layout_scroll_to" const CLEAR_SCROLL_TO = "layout_clear_scroll" +const SCROLL_TO_VIRTUALIZED_SCHEMA = "layout_scroll_to_virtualized_schema" +const CLEAR_SCROLL_TO_VIRTUALIZED_SCHEMA = "layout_clear_scroll_to_virtualized_schema" export const show = (ori, { getConfigs, layoutSelectors }) => (...args) => { ori(...args) @@ -133,6 +135,15 @@ export const clearScrollTo = () => { } } +export const scrollToVirtualizedSchema = (name) => ({ + type: SCROLL_TO_VIRTUALIZED_SCHEMA, + payload: name, +}) + +export const clearScrollToVirtualizedSchema = () => ({ + type: CLEAR_SCROLL_TO_VIRTUALIZED_SCHEMA, +}) + // From: https://stackoverflow.com/a/42543908/3933724 // Modified to return html instead of body element as last resort function getScrollParent(element, includeHidden) { @@ -166,12 +177,17 @@ export default { scrollTo, clearScrollTo, readyToScroll, - parseDeepLinkHash + parseDeepLinkHash, + scrollToVirtualizedSchema, + clearScrollToVirtualizedSchema, }, selectors: { getScrollToKey(state) { return state.get("scrollToKey") }, + getScrollToVirtualizedSchema(state) { + return state.get("scrollToVirtualizedSchema") + }, isShownKeyFromUrlHashArray(state, urlHashArray) { const [tag, operationId] = urlHashArray // We only put operations in the URL @@ -199,7 +215,13 @@ export default { }, [CLEAR_SCROLL_TO](state) { return state.delete("scrollToKey") - } + }, + [SCROLL_TO_VIRTUALIZED_SCHEMA](state, action) { + return state.set("scrollToVirtualizedSchema", action.payload) + }, + [CLEAR_SCROLL_TO_VIRTUALIZED_SCHEMA](state) { + return state.delete("scrollToVirtualizedSchema") + }, }, wrapActions: { show diff --git a/src/core/plugins/json-schema-5/components/models.jsx b/src/core/plugins/json-schema-5/components/models.jsx index 5f185ca090f..373bf186c00 100644 --- a/src/core/plugins/json-schema-5/components/models.jsx +++ b/src/core/plugins/json-schema-5/components/models.jsx @@ -1,67 +1,199 @@ -import React, { Component } from "react" +/** + * @prettier + */ +import React, { useRef, useMemo, useCallback, useEffect } from "react" import Im, { Map } from "immutable" import PropTypes from "prop-types" +import { useVirtualizer } from "@tanstack/react-virtual" +import { VIRTUALIZE_MODELS_THRESHOLD } from "core/utils" /* eslint-disable react/jsx-no-bind */ -export default class Models extends Component { - static propTypes = { - getComponent: PropTypes.func, - specSelectors: PropTypes.object, - specActions: PropTypes.object.isRequired, - layoutSelectors: PropTypes.object, - layoutActions: PropTypes.object, - getConfigs: PropTypes.func.isRequired - } - - getSchemaBasePath = () => { - const isOAS3 = this.props.specSelectors.isOAS3() - return isOAS3 ? ["components", "schemas"] : ["definitions"] - } - - getCollapsedContent = () => { - return " " - } - - handleToggle = (name, isExpanded) => { - const { layoutActions } = this.props - layoutActions.show([...this.getSchemaBasePath(), name], isExpanded) - if(isExpanded) { - this.props.specActions.requestResolvedSubtree([...this.getSchemaBasePath(), name]) +const Models = ({ + getComponent, + specSelectors, + specActions, + layoutSelectors, + layoutActions, + getConfigs, +}) => { + const definitions = specSelectors.definitions() + const { docExpansion, defaultModelsExpandDepth } = getConfigs() + + const isOAS3 = specSelectors.isOAS3() + const specPathBase = useMemo( + () => (isOAS3 ? ["components", "schemas"] : ["definitions"]), + [isOAS3] + ) + + const getCollapsedContent = useCallback(() => " ", []) + + const handleToggle = useCallback( + (name, isExpanded) => { + layoutActions.show([...specPathBase, name], isExpanded) + if (isExpanded) { + specActions.requestResolvedSubtree([...specPathBase, name]) + } + }, + [layoutActions, specActions, specPathBase] + ) + + const onLoadModels = useCallback( + (ref) => { + if (ref) { + layoutActions.readyToScroll(specPathBase, ref) + } + }, + [layoutActions, specPathBase] + ) + + const onLoadModel = useCallback( + (ref) => { + if (ref) { + const name = ref.getAttribute("data-name") + layoutActions.readyToScroll([...specPathBase, name], ref) + } + }, + [layoutActions, specPathBase] + ) + + const definitionEntries = useMemo( + () => definitions.entrySeq().toArray(), + [definitions] + ) + + const parentRef = useRef(null) + const measurementsCache = useRef([]) + + const virtualizer = useVirtualizer({ + count: definitionEntries.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 71, + overscan: 5, + getItemKey: (index) => `models-section-${definitionEntries[index][0]}`, + initialMeasurementsCache: measurementsCache.current, + onChange: (instance) => { + measurementsCache.current = instance.takeSnapshot() + }, + }) + + const isVirtualized = definitionEntries.length >= VIRTUALIZE_MODELS_THRESHOLD + const pendingVirtualizedSchemaScroll = + layoutSelectors.getScrollToVirtualizedSchema() + + useEffect(() => { + if (!pendingVirtualizedSchemaScroll || !isVirtualized) return + + const idx = definitionEntries.findIndex( + ([name]) => name === pendingVirtualizedSchemaScroll + ) + + if (idx !== -1) { + virtualizer.scrollToIndex(idx, { align: "start" }) + layoutActions.scrollToElement(parentRef.current) } - } - onLoadModels = (ref) => { - if (ref) { - this.props.layoutActions.readyToScroll(this.getSchemaBasePath(), ref) + layoutActions.clearScrollToVirtualizedSchema() + }, [ + pendingVirtualizedSchemaScroll, + definitionEntries, + virtualizer, + isVirtualized, + layoutActions, + ]) + + if (!definitions.size || defaultModelsExpandDepth < 0) return null + + const showModels = layoutSelectors.isShown( + specPathBase, + defaultModelsExpandDepth > 0 && docExpansion !== "none" + ) + + const ModelWrapper = getComponent("ModelWrapper") + const Collapse = getComponent("Collapse") + const ModelCollapse = getComponent("ModelCollapse") + const JumpToPath = getComponent("JumpToPath", true) + const ArrowUpIcon = getComponent("ArrowUpIcon") + const ArrowDownIcon = getComponent("ArrowDownIcon") + + const renderModelItem = (name, key) => { + const fullPath = [...specPathBase, name] + const specPath = Im.List(fullPath) + + const schemaValue = specSelectors.specResolvedSubtree(fullPath) + const rawSchemaValue = specSelectors.specJson().getIn(fullPath) + + const schema = Map.isMap(schemaValue) ? schemaValue : Im.Map() + const rawSchema = Map.isMap(rawSchemaValue) ? rawSchemaValue : Im.Map() + + const displayName = schema.get("title") || rawSchema.get("title") || name + const isShown = layoutSelectors.isShown(fullPath, false) + + if (isShown && schema.size === 0 && rawSchema.size > 0) { + // Firing an action in a container render is not great, + // but it works for now. + specActions.requestResolvedSubtree(fullPath) } - } - onLoadModel = (ref) => { - if (ref) { - const name = ref.getAttribute("data-name") - this.props.layoutActions.readyToScroll([...this.getSchemaBasePath(), name], ref) - } + const content = ( + + ) + + const title = ( + + {displayName} + + ) + + return ( +
+ + + + 0 && isShown} + > + {content} + +
+ ) } - render(){ - let { specSelectors, getComponent, layoutSelectors, layoutActions, getConfigs } = this.props - let definitions = specSelectors.definitions() - let { docExpansion, defaultModelsExpandDepth } = getConfigs() - if (!definitions.size || defaultModelsExpandDepth < 0) return null - - const specPathBase = this.getSchemaBasePath() - let showModels = layoutSelectors.isShown(specPathBase, defaultModelsExpandDepth > 0 && docExpansion !== "none") - const isOAS3 = specSelectors.isOAS3() - - const ModelWrapper = getComponent("ModelWrapper") - const Collapse = getComponent("Collapse") - const ModelCollapse = getComponent("ModelCollapse") - const JumpToPath = getComponent("JumpToPath", true) - const ArrowUpIcon = getComponent("ArrowUpIcon") - const ArrowDownIcon = getComponent("ArrowDownIcon") - - return
+ return ( +

- { - definitions.entrySeq().map(([name])=>{ - - const fullPath = [...specPathBase, name] - const specPath = Im.List(fullPath) - - const schemaValue = specSelectors.specResolvedSubtree(fullPath) - const rawSchemaValue = specSelectors.specJson().getIn(fullPath) - - const schema = Map.isMap(schemaValue) ? schemaValue : Im.Map() - const rawSchema = Map.isMap(rawSchemaValue) ? rawSchemaValue : Im.Map() - - const displayName = schema.get("title") || rawSchema.get("title") || name - const isShown = layoutSelectors.isShown(fullPath, false) - - if( isShown && (schema.size === 0 && rawSchema.size > 0) ) { - // Firing an action in a container render is not great, - // but it works for now. - this.props.specActions.requestResolvedSubtree(fullPath) - } - - const content = - - const title = - - {displayName} - - - - return
- - 0 && isShown } - >{content} -
- }).toArray() - } + {isVirtualized ? ( +
+
+ {virtualizer.getVirtualItems().map((vItem) => { + const [name] = definitionEntries[vItem.index] + return ( +
+ {renderModelItem(name)} +
+ ) + })} +
+
+ ) : ( + definitionEntries.map(([name]) => + renderModelItem(name, `models-section-${name}`) + ) + )}
- } + ) +} + +Models.propTypes = { + getComponent: PropTypes.func, + specSelectors: PropTypes.object, + specActions: PropTypes.object.isRequired, + layoutSelectors: PropTypes.object, + layoutActions: PropTypes.object, + getConfigs: PropTypes.func.isRequired, } + +export default Models diff --git a/src/core/utils/index.js b/src/core/utils/index.js index 21e8c34b03c..3e02d3b7605 100644 --- a/src/core/utils/index.js +++ b/src/core/utils/index.js @@ -26,6 +26,8 @@ import getParameterSchema from "core/utils/get-parameter-schema" const DEFAULT_RESPONSE_KEY = "default" +export const VIRTUALIZE_MODELS_THRESHOLD = 100 + export const isImmutable = (maybe) => Im.Iterable.isIterable(maybe) export const immutableToJS = (value) => isImmutable(value) ? value.toJS() : value diff --git a/src/style/_models.scss b/src/style/_models.scss index d8a9b98fb22..2d11daf1bc7 100644 --- a/src/style/_models.scss +++ b/src/style/_models.scss @@ -191,6 +191,11 @@ section.models { &.is-open { padding: 0 0 20px; + + &.models--virtualized { + padding-bottom: 0; + } + h4 { margin: 0 0 5px 0; @@ -267,6 +272,13 @@ section.models { } } + .models-scroll { + max-height: min(60vh, 800px); + overflow-y: auto; + padding-top: 20px; + padding-bottom: 5px; + } + .model-box { background: none; diff --git a/test/e2e-cypress/e2e/features/models-virtualization.cy.js b/test/e2e-cypress/e2e/features/models-virtualization.cy.js new file mode 100644 index 00000000000..c6532d48dba --- /dev/null +++ b/test/e2e-cypress/e2e/features/models-virtualization.cy.js @@ -0,0 +1,76 @@ +/** + * @prettier + */ + +describe("Models list virtualization", () => { + describe("legacy path — below threshold (OpenAPI 2.0)", () => { + it("renders all model-container elements without a scroll wrapper", () => { + cy.visit("/?url=/documents/features/models.swagger.yaml") + cy.get(".models-scroll").should("not.exist") + cy.get(".model-container").should("have.length", 3) + }) + }) + + describe("virtualized path — above threshold (OpenAPI 2.0)", () => { + const baseUrl = "/?url=/documents/perf/many-schemas.swagger.yaml" + + it("renders a scroll wrapper and mounts only a windowed subset", () => { + cy.visit(baseUrl) + cy.get(".models-scroll").should("exist") + cy.get(".model-container").should("have.length.lessThan", 240) + }) + + it("section header collapse and expand still works", () => { + cy.visit(baseUrl) + cy.get(".models").should("have.class", "is-open") + cy.get(".models h4 .models-control").click() + cy.get(".models").should("not.have.class", "is-open") + cy.get(".models-scroll").should("not.exist") + cy.get(".models h4 .models-control").click() + cy.get(".models").should("have.class", "is-open") + cy.get(".models-scroll").should("exist") + }) + + it("scrolling renders new items and unmounts old ones", () => { + cy.visit(baseUrl) + cy.get(".models-scroll").should("exist") + cy.get("#model-PerfModel001").should("exist") + + cy.get(".models-scroll").then(($scroll) => { + $scroll[0].scrollTop = $scroll[0].scrollHeight + cy.wait(200) + cy.get("#model-PerfModel001").should("not.exist") + cy.get("#model-PerfModel240").should("exist") + cy.get(".model-container").should("have.length.lessThan", 240) + }) + }) + + it("expanded model state is preserved when scrolled out of view and back", () => { + cy.visit(baseUrl) + // expand first visible model + cy.get(".models-scroll").should("exist") + cy.get("#model-PerfModel001 span.inner-object").should("not.exist") + cy.get("#model-PerfModel001 .model-box-control").click() + cy.get("#model-PerfModel001 span.inner-object").should("exist") + + cy.get(".models-scroll").then(($scroll) => { + $scroll[0].scrollTop = $scroll[0].scrollHeight + }) + cy.wait(200) + cy.get(".models-scroll").then(($scroll) => { + $scroll[0].scrollTop = 0 + }) + cy.wait(200) + + cy.get("#model-PerfModel001 span.inner-object").should("exist") + }) + }) + + describe("virtualized path — above threshold (OpenAPI 3.0)", () => { + it("renders a scroll wrapper and mounts only a windowed subset", () => { + cy.visit("/?url=/documents/perf/many-schemas.openapi.yaml") + cy.get(".models-scroll").should("exist") + cy.get(".model-container").should("have.length.lessThan", 240) + }) + }) +}) diff --git a/test/e2e-cypress/e2e/features/plugins/oas3/complex-spec.cy.js b/test/e2e-cypress/e2e/features/plugins/oas3/complex-spec.cy.js index b70c9905f73..a2c3bc5e9ba 100644 --- a/test/e2e-cypress/e2e/features/plugins/oas3/complex-spec.cy.js +++ b/test/e2e-cypress/e2e/features/plugins/oas3/complex-spec.cy.js @@ -5,6 +5,10 @@ describe("OpenAPI 3.0 complex spec with allOf and nested references", () => { it("should render nested references", () => { cy.visit("/?url=/documents/features/oas3-complex-spec.json").then(() => { + // Virtualized path - scroll the list to bring the target model into view. + // Scroll position = index * estimateSize = 196 * 71 + // Update estimateSize value if it changes in Models component. + cy.get(".models-scroll").scrollTo(0, 196 * 71) cy.get( "[id='model-com.sap.ctsm.backend.core.api.study.v1.StudyAPIv1.StudyTreatments-create']" ) diff --git a/test/unit/core/plugins/json-schema-5/components/models.jsx b/test/unit/core/plugins/json-schema-5/components/models.jsx index 3203f486376..8b8daba82f4 100644 --- a/test/unit/core/plugins/json-schema-5/components/models.jsx +++ b/test/unit/core/plugins/json-schema-5/components/models.jsx @@ -1,54 +1,173 @@ +/** + * @prettier + */ import React from "react" -import { shallow } from "enzyme" +import { mount } from "enzyme" import { fromJS, Map } from "immutable" import Models from "core/plugins/json-schema-5/components/models" -import ModelCollapse from "core/plugins/json-schema-5/components/model-collapse" -import ModelComponent from "core/plugins/json-schema-5/components/model-wrapper" -describe("", function(){ +jest.mock("@tanstack/react-virtual", () => ({ + useVirtualizer: () => ({ + getVirtualItems: () => [], + getTotalSize: () => 0, + measureElement: () => {}, + }), +})) + +describe("", function () { const dummyComponent = () => null - // Given - let components = { - Collapse: ModelCollapse, - ModelWrapper: ModelComponent, + /* eslint-disable react/prop-types */ + const DummyComponentWithChildren = ({ children }) =>
{children}
+ + const makeComponents = (overrides = {}) => ({ + Collapse: DummyComponentWithChildren, + ModelWrapper: dummyComponent, JumpToPath: dummyComponent, - } - let props = { - getComponent: (c) => { - return components[c] - }, + ModelCollapse: DummyComponentWithChildren, + ArrowUpIcon: dummyComponent, + ArrowDownIcon: dummyComponent, + ...overrides, + }) + + const makeProps = (overrides = {}) => ({ + getComponent: (c) => makeComponents()[c] || dummyComponent, specSelectors: { isOAS3: () => false, specJson: () => Map(), - definitions: function() { - return fromJS({ - def1: {}, - def2: {} - }) - }, - specResolvedSubtree: () => {} + definitions: () => fromJS({ def1: {}, def2: {} }), + specResolvedSubtree: () => {}, }, layoutSelectors: { - isShown: jest.fn() + isShown: jest.fn(), + getScrollToVirtualizedSchema: jest.fn(() => null), + }, + layoutActions: { + show: jest.fn(), + readyToScroll: jest.fn(), + }, + specActions: { + requestResolvedSubtree: jest.fn(), }, - layoutActions: {}, getConfigs: () => ({ docExpansion: "list", - defaultModelsExpandDepth: 0 + defaultModelsExpandDepth: 1, + }), + ...overrides, + }) + + it("passes defaultModelsExpandDepth to ModelWrapper", function () { + const ModelWrapperSpy = jest.fn(() => null) + const props = makeProps({ + getComponent: (c) => { + if (c === "ModelWrapper") return ModelWrapperSpy + return makeComponents()[c] || dummyComponent + }, + getConfigs: () => ({ docExpansion: "list", defaultModelsExpandDepth: 2 }), + }) + + mount() + + expect(ModelWrapperSpy).toHaveBeenCalled() + + const receivedProps = ModelWrapperSpy.mock.calls[0][0] + + expect(receivedProps.expandDepth).toBe(2) + }) + + it("returns null when defaultModelsExpandDepth < 0", function () { + const props = makeProps({ + getConfigs: () => ({ + docExpansion: "list", + defaultModelsExpandDepth: -1, + }), }) - } + const wrapper = mount() - it("passes defaultModelsExpandDepth to ModelWrapper", function(){ - // When - let wrapper = shallow() + expect(wrapper.html()).toBeNull() + }) - // Then should render tabs - expect(wrapper.find("ModelCollapse").length).toEqual(1) - expect(wrapper.find("ModelWrapper").length).toBeGreaterThan(0) - wrapper.find("ModelComponent").forEach((modelWrapper) => { - expect(modelWrapper.props().expandDepth).toBe(0) + it("returns null when there are no definitions", function () { + const props = makeProps({ + specSelectors: { + isOAS3: () => false, + specJson: () => Map(), + definitions: () => fromJS({}), + specResolvedSubtree: () => undefined, + }, }) + + const wrapper = mount() + + expect(wrapper.html()).toBeNull() }) + it("uses docExpansion config to determine default open state", function () { + const isShownMock = jest.fn(() => false) + const props = makeProps({ + layoutSelectors: { + isShown: isShownMock, + getScrollToVirtualizedSchema: jest.fn(() => null), + }, + getConfigs: () => ({ docExpansion: "none", defaultModelsExpandDepth: 1 }), + }) + + mount() + + expect(isShownMock).toHaveBeenCalledWith(["definitions"], false) + }) + + it("uses non-virtualized path when definition count is below threshold", function () { + const props = makeProps() + + const wrapper = mount() + + expect(wrapper.find(".models-scroll").length).toEqual(0) + expect(wrapper.find(".model-container").length).toEqual(2) + }) + + it("uses non-virtualized path when definition count is just below threshold (99)", function () { + const defs = {} + + for (let i = 0; i < 99; i++) { + defs[`Schema${i}`] = {} + } + + const props = makeProps({ + specSelectors: { + isOAS3: () => false, + specJson: () => Map(), + definitions: () => fromJS(defs), + specResolvedSubtree: () => undefined, + }, + }) + + const wrapper = mount() + + expect(wrapper.find(".models-scroll").length).toEqual(0) + expect(wrapper.find(".model-container").length).toEqual(99) + }) + + it("uses virtualized path when definition count is at threshold (100)", function () { + const defs = {} + + for (let i = 0; i < 100; i++) { + defs[`Schema${i}`] = {} + } + + const props = makeProps({ + specSelectors: { + isOAS3: () => false, + specJson: () => Map(), + definitions: () => fromJS(defs), + specResolvedSubtree: () => undefined, + }, + }) + + const wrapper = mount() + + expect(wrapper.find(".models-scroll").length).toEqual(1) + // virtualizer mock returns no items, so no .model-container should be in DOM + expect(wrapper.find(".model-container").length).toEqual(0) + }) }) diff --git a/test/unit/jest-shim.js b/test/unit/jest-shim.js index 90496ee42bd..b3bed5ca225 100644 --- a/test/unit/jest-shim.js +++ b/test/unit/jest-shim.js @@ -2,3 +2,9 @@ import { TextDecoder, TextEncoder } from "node:util" global.TextDecoder = TextDecoder global.TextEncoder = TextEncoder + +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} From 2d67de58e2e2819c2e50e842d30af818c8c6748a Mon Sep 17 00:00:00 2001 From: Oliwia Rogala Date: Fri, 21 Aug 2026 12:24:57 +0200 Subject: [PATCH 2/2] fix: preserve expanded state for nested properties --- .../json-schema-5/components/array-model.jsx | 23 ++++++++++++-- .../json-schema-5/components/object-model.jsx | 30 +++++++++++++++---- .../components/primitive-model.jsx | 22 +++++++++++++- .../e2e/features/models-virtualization.cy.js | 24 +++++++++++++++ 4 files changed, 90 insertions(+), 9 deletions(-) diff --git a/src/core/plugins/json-schema-5/components/array-model.jsx b/src/core/plugins/json-schema-5/components/array-model.jsx index dd04208950a..283304f495b 100644 --- a/src/core/plugins/json-schema-5/components/array-model.jsx +++ b/src/core/plugins/json-schema-5/components/array-model.jsx @@ -19,10 +19,24 @@ export default class ArrayModel extends Component { depth: PropTypes.number, includeReadOnly: PropTypes.bool, includeWriteOnly: PropTypes.bool, + layoutActions: PropTypes.shape({ + show: PropTypes.func.isRequired, + }), + layoutSelectors: PropTypes.shape({ + isShown: PropTypes.func.isRequired, + }), + } + + handleToggle = (modelName, shown) => { + const { layoutActions, specPath } = this.props + layoutActions?.show(specPath.toJS(), shown) } render(){ - let { getComponent, getConfigs, schema, depth, expandDepth, name, displayName, specPath } = this.props + let { getComponent, getConfigs, schema, depth, expandDepth, name, displayName, specPath, layoutSelectors } = this.props + const defaultExpanded = depth <= expandDepth + const isExpanded = layoutSelectors?.isShown(specPath.toJS(), defaultExpanded) ?? defaultExpanded + let description = schema.get("description") let items = schema.get("items") let title = schema.get("title") || displayName || name @@ -48,7 +62,12 @@ export default class ArrayModel extends Component { */ return - + [ { properties.size ? properties.entrySeq().map( ( [ key, v ] ) => ) : null diff --git a/src/core/plugins/json-schema-5/components/object-model.jsx b/src/core/plugins/json-schema-5/components/object-model.jsx index b6b9f6ebf20..fd3e1675abb 100644 --- a/src/core/plugins/json-schema-5/components/object-model.jsx +++ b/src/core/plugins/json-schema-5/components/object-model.jsx @@ -29,6 +29,17 @@ export default class ObjectModel extends Component { specPath: ImPropTypes.list.isRequired, includeReadOnly: PropTypes.bool, includeWriteOnly: PropTypes.bool, + layoutActions: PropTypes.shape({ + show: PropTypes.func.isRequired, + }), + layoutSelectors: PropTypes.shape({ + isShown: PropTypes.func.isRequired, + }), + } + + handleToggle = (modelName, shown) => { + const { layoutActions, specPath } = this.props + layoutActions?.show(specPath.toJS(), shown) } render() { @@ -40,15 +51,22 @@ export default class ObjectModel extends Component { getComponent, getConfigs, depth, - onToggle, - expanded, specPath, ...otherProps } = this.props - let { specSelectors, expandDepth, includeReadOnly, includeWriteOnly } = - otherProps + let { + specSelectors, + expandDepth, + includeReadOnly, + includeWriteOnly, + layoutSelectors, + } = otherProps const { isOAS3 } = specSelectors const isEmbedded = depth > 2 || (depth === 2 && specPath.last() !== "items") + const defaultExpanded = depth <= expandDepth + const isExpanded = + layoutSelectors?.isShown(specPath.toJS(), defaultExpanded) ?? + defaultExpanded if (!schema) { return null @@ -119,8 +137,8 @@ export default class ObjectModel extends Component { {braceOpen} diff --git a/src/core/plugins/json-schema-5/components/primitive-model.jsx b/src/core/plugins/json-schema-5/components/primitive-model.jsx index 592b97716fb..ccd606cf545 100644 --- a/src/core/plugins/json-schema-5/components/primitive-model.jsx +++ b/src/core/plugins/json-schema-5/components/primitive-model.jsx @@ -3,6 +3,7 @@ */ import React, { Component } from "react" import PropTypes from "prop-types" +import ImPropTypes from "react-immutable-proptypes" import { getExtensions } from "core/utils" import { sanitizeUrl } from "core/utils/url" @@ -17,6 +18,18 @@ export default class Primitive extends Component { displayName: PropTypes.string, depth: PropTypes.number, expandDepth: PropTypes.number, + specPath: ImPropTypes.list, + layoutActions: PropTypes.shape({ + show: PropTypes.func.isRequired, + }), + layoutSelectors: PropTypes.shape({ + isShown: PropTypes.func.isRequired, + }), + } + + handleToggle = (modelName, shown) => { + const { layoutActions, specPath } = this.props + layoutActions?.show(specPath?.toJS(), shown) } render() { @@ -28,9 +41,15 @@ export default class Primitive extends Component { displayName, depth, expandDepth, + specPath, + layoutSelectors, } = this.props const { showExtensions } = getConfigs() + const defaultExpanded = depth <= expandDepth + const isExpanded = + layoutSelectors?.isShown(specPath?.toJS(), defaultExpanded) ?? + defaultExpanded if (!schema || !schema.get) { // don't render if schema isn't correctly formed @@ -78,7 +97,8 @@ export default class Primitive extends Component { diff --git a/test/e2e-cypress/e2e/features/models-virtualization.cy.js b/test/e2e-cypress/e2e/features/models-virtualization.cy.js index c6532d48dba..8d2f75784eb 100644 --- a/test/e2e-cypress/e2e/features/models-virtualization.cy.js +++ b/test/e2e-cypress/e2e/features/models-virtualization.cy.js @@ -64,6 +64,30 @@ describe("Models list virtualization", () => { cy.get("#model-PerfModel001 span.inner-object").should("exist") }) + + it("expanded nested property state is preserved when scrolled out of view and back", () => { + cy.visit(baseUrl) + cy.get("#model-PerfModel001 .model-box-control").first().click() + cy.get("#model-PerfModel001 span.inner-object").should("exist") + cy.get("#model-PerfModel001 span.inner-object .prop-type").should( + "not.exist" + ) + cy.get("#model-PerfModel001 span.inner-object .model-box-control") + .first() + .click() + cy.get("#model-PerfModel001 span.inner-object .prop-type").should("exist") + + cy.get(".models-scroll").then(($scroll) => { + $scroll[0].scrollTop = $scroll[0].scrollHeight + }) + cy.wait(200) + cy.get(".models-scroll").then(($scroll) => { + $scroll[0].scrollTop = 0 + }) + cy.wait(200) + + cy.get("#model-PerfModel001 span.inner-object .prop-type").should("exist") + }) }) describe("virtualized path — above threshold (OpenAPI 3.0)", () => {