From e134956f16d167d3fb7b2063357cef73232626f3 Mon Sep 17 00:00:00 2001 From: Paul Wellner Bou Date: Mon, 8 Jun 2026 10:46:08 +0200 Subject: [PATCH 1/2] feat(config): add resolveSubtreeOnExpand option to skip subtree resolution on expand Adds an opt-in resolveSubtreeOnExpand config option (default true, preserving current behavior). When set to false, operation and model subtrees are no longer resolved when expanded and render directly from the unresolved spec, avoiding the browser tab freeze/crash on documents with many deeply nested oneOf compositions. To keep the unresolved view readable, models derive their display name from $ref/composition when no explicit name is present. Addresses #10917 --- src/core/config/defaults.js | 1 + src/core/config/type-cast/mappings.js | 4 ++ src/core/containers/OperationContainer.jsx | 44 ++++++++++-- .../json-schema-5/components/model.jsx | 3 + .../json-schema-5/components/models.jsx | 14 ++-- .../json-schema-5/components/object-model.jsx | 68 +++++++++++++++++++ .../resolve-subtree-on-expand-disabled.cy.js | 20 ++++++ test/unit/core/config/type-cast/index.js | 4 ++ .../json-schema-5/components/model.jsx | 38 +++++++++++ .../json-schema-5/components/models.jsx | 58 +++++++++++++++- .../json-schema-5/components/object-model.jsx | 30 ++++++++ 11 files changed, 266 insertions(+), 18 deletions(-) create mode 100644 test/e2e-cypress/e2e/features/resolve-subtree-on-expand-disabled.cy.js create mode 100644 test/unit/core/plugins/json-schema-5/components/model.jsx diff --git a/src/core/config/defaults.js b/src/core/config/defaults.js index 96102dda55e..a8bf20fc9d3 100644 --- a/src/core/config/defaults.js +++ b/src/core/config/defaults.js @@ -31,6 +31,7 @@ const defaultOptions = Object.freeze({ defaultModelRendering: "example", defaultModelExpandDepth: 1, defaultModelsExpandDepth: 1, + resolveSubtreeOnExpand: true, showExtensions: false, showCommonExtensions: false, withCredentials: false, diff --git a/src/core/config/type-cast/mappings.js b/src/core/config/type-cast/mappings.js index 2063f101edb..0cda315c864 100644 --- a/src/core/config/type-cast/mappings.js +++ b/src/core/config/type-cast/mappings.js @@ -88,6 +88,10 @@ const mappings = { typeCaster: booleanTypeCaster, defaultValue: defaultOptions.requestSnippetsEnabled, }, + resolveSubtreeOnExpand: { + typeCaster: booleanTypeCaster, + defaultValue: defaultOptions.resolveSubtreeOnExpand, + }, responseInterceptor: { typeCaster: functionTypeCaster, defaultValue: defaultOptions.responseInterceptor, diff --git a/src/core/containers/OperationContainer.jsx b/src/core/containers/OperationContainer.jsx index 65ae7832b85..4179c52f111 100644 --- a/src/core/containers/OperationContainer.jsx +++ b/src/core/containers/OperationContainer.jsx @@ -85,7 +85,7 @@ export default class OperationContainer extends PureComponent { const { isShown } = this.props const resolvedSubtree = this.getResolvedSubtree() - if(isShown && resolvedSubtree === undefined) { + if (this.shouldResolveOperationSubtree() && isShown && resolvedSubtree === undefined) { this.requestResolvedSubtree() } } @@ -98,7 +98,11 @@ export default class OperationContainer extends PureComponent { this.setState({ executeInProgress: false }) } - if (isShown && resolvedSubtree === undefined) { + if ( + this.shouldResolveOperationSubtree() && + isShown && + resolvedSubtree === undefined + ) { this.requestResolvedSubtree() } } @@ -106,13 +110,22 @@ export default class OperationContainer extends PureComponent { toggleShown =() => { let { layoutActions, tag, operationId, isShown } = this.props const resolvedSubtree = this.getResolvedSubtree() - if(!isShown && resolvedSubtree === undefined) { + if ( + this.shouldResolveOperationSubtree() && + !isShown && + resolvedSubtree === undefined + ) { // transitioning from collapsed to expanded this.requestResolvedSubtree() } layoutActions.show(["operations", tag, operationId], !isShown) } + shouldResolveOperationSubtree = () => { + const { resolveSubtreeOnExpand = true } = this.props.getConfigs() + return resolveSubtreeOnExpand + } + onCancelClick=() => { this.setState({tryItOutEnabled: !this.state.tryItOutEnabled}) } @@ -214,19 +227,36 @@ export default class OperationContainer extends PureComponent { const Operation = getComponent( "operation" ) - const resolvedSubtree = this.getResolvedSubtree() || Map() + const resolvedSubtree = this.getResolvedSubtree() + const unresolvedOperation = unresolvedOp.get("operation") || Map() + const hasResolvedSubtree = Map.isMap(resolvedSubtree) && resolvedSubtree.size + // When subtree resolution is enabled we wait for the resolved subtree + // before rendering, otherwise initializing param values from the + // unresolved ($ref) schema would prevent them from refreshing once the + // resolved schema arrives. When resolution is disabled, fall back to the + // unresolved operation so the operation still renders. + const operation = hasResolvedSubtree + ? resolvedSubtree + : this.shouldResolveOperationSubtree() + ? Map() + : unresolvedOperation const operationProps = fromJS({ - op: resolvedSubtree, + op: operation, tag, path, summary: unresolvedOp.getIn(["operation", "summary"]) || "", - deprecated: resolvedSubtree.get("deprecated") || unresolvedOp.getIn(["operation", "deprecated"]) || false, + deprecated: + operation.get("deprecated") || + unresolvedOp.getIn(["operation", "deprecated"]) || + false, method, security, isAuthorized, operationId, - originalOperationId: resolvedSubtree.getIn(["operation", "__originalOperationId"]), + originalOperationId: + operation.get("__originalOperationId") || + unresolvedOp.getIn(["operation", "__originalOperationId"]), showSummary, isShown, jumpToKey, diff --git a/src/core/plugins/json-schema-5/components/model.jsx b/src/core/plugins/json-schema-5/components/model.jsx index 1fb70d386bf..fe3f43bec84 100644 --- a/src/core/plugins/json-schema-5/components/model.jsx +++ b/src/core/plugins/json-schema-5/components/model.jsx @@ -62,6 +62,9 @@ export default class Model extends ImmutablePureComponent { if (!name && $$ref) { name = this.getModelName($$ref) } + if (!name && $ref) { + name = this.getModelName($ref) + } /* * If we have an unresolved ref, get the schema and name from the ref. diff --git a/src/core/plugins/json-schema-5/components/models.jsx b/src/core/plugins/json-schema-5/components/models.jsx index e9023f36f3e..b0b305f446c 100644 --- a/src/core/plugins/json-schema-5/components/models.jsx +++ b/src/core/plugins/json-schema-5/components/models.jsx @@ -24,9 +24,10 @@ export default class Models extends Component { } handleToggle = (name, isExpanded) => { - const { layoutActions } = this.props + const { layoutActions, getConfigs } = this.props + const { resolveSubtreeOnExpand = true } = getConfigs() layoutActions.show([...this.getSchemaBasePath(), name], isExpanded) - if(isExpanded) { + if (isExpanded && resolveSubtreeOnExpand) { this.props.specActions.requestResolvedSubtree([...this.getSchemaBasePath(), name]) } } @@ -82,18 +83,13 @@ export default class Models extends Component { 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 schema = + Map.isMap(schemaValue) && schemaValue.size ? schemaValue : rawSchema 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 = { + if (typeof uri !== "string") { + return null + } + + const unescaped = uri.replace(/~1/g, "/").replace(/~0/g, "~") + try { + return decodeURIComponent(unescaped) + } catch { + return unescaped + } +} + +const getRefDisplayName = (ref) => { + if (typeof ref !== "string") { + return null + } + if (ref.indexOf("#/definitions/") !== -1) { + return decodeRefName(ref.replace(/^.*#\/definitions\//, "")) + } + if (ref.indexOf("#/components/schemas/") !== -1) { + return decodeRefName(ref.replace(/^.*#\/components\/schemas\//, "")) + } + return null +} + +const inferSchemaDisplayName = (schema, level = 0) => { + if (!schema || typeof schema.get !== "function" || level > 2) { + return null + } + + const title = schema.get("title") + if (typeof title === "string" && title) { + return title + } + + const refDisplayName = getRefDisplayName( + schema.get("$$ref") || schema.get("$ref") + ) + if (refDisplayName) { + return refDisplayName + } + + const compositions = ["allOf", "oneOf", "anyOf"] + for (let i = 0; i < compositions.length; i++) { + const composed = schema.get(compositions[i]) + if (List.isList(composed) && composed.size) { + for (let j = 0; j < composed.size; j++) { + const inferred = inferSchemaDisplayName(composed.get(j), level + 1) + if (inferred) { + return inferred + } + } + } + } + + return null +} + export default class ObjectModel extends Component { static propTypes = { schema: PropTypes.object.isRequired, @@ -237,6 +296,8 @@ export default class ObjectModel extends Component { {"allOf ->"} {allOf.map((schema, k) => { + const inferredDisplayName = + inferSchemaDisplayName(schema) return (
@@ -259,6 +321,8 @@ export default class ObjectModel extends Component { {"anyOf ->"} {anyOf.map((schema, k) => { + const inferredDisplayName = + inferSchemaDisplayName(schema) return (
@@ -281,6 +346,8 @@ export default class ObjectModel extends Component { {"oneOf ->"} {oneOf.map((schema, k) => { + const inferredDisplayName = + inferSchemaDisplayName(schema) return (
diff --git a/test/e2e-cypress/e2e/features/resolve-subtree-on-expand-disabled.cy.js b/test/e2e-cypress/e2e/features/resolve-subtree-on-expand-disabled.cy.js new file mode 100644 index 00000000000..ea0c75933da --- /dev/null +++ b/test/e2e-cypress/e2e/features/resolve-subtree-on-expand-disabled.cy.js @@ -0,0 +1,20 @@ +/** + * @prettier + */ +describe("resolveSubtreeOnExpand disabled", () => { + it("renders a schema model and its properties when expanded", () => { + cy.visit( + "/?url=/documents/features/models.swagger.yaml&resolveSubtreeOnExpand=false" + ) + .get("#model-Pet .model-box .model-box-control") + .click() + .get("#model-Pet .model-box .model .inner-object") + .should("exist") + + cy.get("#model-Pet").contains("tr.property-row td", "name").should("exist") + cy.get("#model-Pet") + .contains("tr.property-row td", "category") + .should("exist") + cy.get("#model-Pet").contains("Category").should("exist") + }) +}) diff --git a/test/unit/core/config/type-cast/index.js b/test/unit/core/config/type-cast/index.js index c233b13fbb7..27e27c4d384 100644 --- a/test/unit/core/config/type-cast/index.js +++ b/test/unit/core/config/type-cast/index.js @@ -12,6 +12,7 @@ describe("typeCast", () => { tryItOutEnabled: "false", withCredentials: "true", filter: "false", + resolveSubtreeOnExpand: "true", } const expectedConfig = { @@ -19,6 +20,7 @@ describe("typeCast", () => { tryItOutEnabled: false, withCredentials: true, filter: false, + resolveSubtreeOnExpand: true, } expect(typeCast(config)).toStrictEqual(expectedConfig) @@ -87,6 +89,7 @@ describe("typeCast", () => { maxDisplayedTags: "null", defaultModelExpandDepth: {}, defaultModelsExpandDepth: false, + resolveSubtreeOnExpand: "invalid", } const expectedConfig = { @@ -97,6 +100,7 @@ describe("typeCast", () => { maxDisplayedTags: -1, defaultModelExpandDepth: 1, defaultModelsExpandDepth: 1, + resolveSubtreeOnExpand: true, } expect(typeCast(config)).toStrictEqual(expectedConfig) diff --git a/test/unit/core/plugins/json-schema-5/components/model.jsx b/test/unit/core/plugins/json-schema-5/components/model.jsx new file mode 100644 index 00000000000..b5985c330a5 --- /dev/null +++ b/test/unit/core/plugins/json-schema-5/components/model.jsx @@ -0,0 +1,38 @@ +import React from "react" +import { shallow } from "enzyme" +import { fromJS } from "immutable" +import Model from "core/plugins/json-schema-5/components/model" + +describe("", function() { + const ObjectModel = () => null + const ArrayModel = () => null + const PrimitiveModel = () => null + + const getComponent = (componentName) => { + return { + ObjectModel, + ArrayModel, + PrimitiveModel, + }[componentName] + } + + const props = { + getComponent, + getConfigs: () => ({}), + specSelectors: { + isOAS3: () => true, + findDefinition: () => fromJS({ type: "object" }), + }, + specPath: fromJS([]), + } + + it("derives model name from unresolved $ref", function() { + const wrapper = shallow( + + ) + + const renderedObjectModel = wrapper.find(ObjectModel) + expect(renderedObjectModel.length).toEqual(1) + expect(renderedObjectModel.first().prop("name")).toEqual("Links") + }) +}) 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..6ff0fe4ed85 100644 --- a/test/unit/core/plugins/json-schema-5/components/models.jsx +++ b/test/unit/core/plugins/json-schema-5/components/models.jsx @@ -17,6 +17,9 @@ describe("", function(){ getComponent: (c) => { return components[c] }, + specActions: { + requestResolvedSubtree: jest.fn() + }, specSelectors: { isOAS3: () => false, specJson: () => Map(), @@ -31,10 +34,14 @@ describe("", function(){ layoutSelectors: { isShown: jest.fn() }, - layoutActions: {}, + layoutActions: { + show: jest.fn(), + readyToScroll: jest.fn() + }, getConfigs: () => ({ docExpansion: "list", - defaultModelsExpandDepth: 0 + defaultModelsExpandDepth: 0, + resolveSubtreeOnExpand: false }) } @@ -51,4 +58,51 @@ describe("", function(){ }) }) + it("does not resolve model subtree on expand when disabled", function() { + const localProps = { + ...props, + specActions: { + requestResolvedSubtree: jest.fn() + }, + layoutActions: { + show: jest.fn(), + readyToScroll: jest.fn() + }, + getConfigs: () => ({ + docExpansion: "list", + defaultModelsExpandDepth: 0, + resolveSubtreeOnExpand: false + }) + } + + const wrapper = shallow() + wrapper.instance().handleToggle("def1", true) + + expect(localProps.layoutActions.show).toHaveBeenCalledWith(["definitions", "def1"], true) + expect(localProps.specActions.requestResolvedSubtree).not.toHaveBeenCalled() + }) + + it("resolves model subtree on expand when enabled", function() { + const localProps = { + ...props, + specActions: { + requestResolvedSubtree: jest.fn() + }, + layoutActions: { + show: jest.fn(), + readyToScroll: jest.fn() + }, + getConfigs: () => ({ + docExpansion: "list", + defaultModelsExpandDepth: 0, + resolveSubtreeOnExpand: true + }) + } + + const wrapper = shallow() + wrapper.instance().handleToggle("def1", true) + + expect(localProps.specActions.requestResolvedSubtree).toHaveBeenCalledWith(["definitions", "def1"]) + }) + }) diff --git a/test/unit/core/plugins/json-schema-5/components/object-model.jsx b/test/unit/core/plugins/json-schema-5/components/object-model.jsx index 9f4c5f72382..941a79bf69c 100644 --- a/test/unit/core/plugins/json-schema-5/components/object-model.jsx +++ b/test/unit/core/plugins/json-schema-5/components/object-model.jsx @@ -61,6 +61,25 @@ describe("", function() { ...props, schema: props.schema.set("minProperties", 1).set("maxProperties", 5) } + const propsOneOfRefs = { + ...props, + schema: Immutable.fromJS({ + oneOf: [ + { + allOf: [ + { + $ref: "#/components/schemas/Application" + } + ] + } + ] + }), + specSelectors: { + isOAS3(){ + return true + } + } + } it("renders a collapsible header", function(){ const wrapper = shallow() @@ -106,4 +125,15 @@ describe("", function() { expect(renderProperties.get(1).props.propKey).toEqual("maxProperties") expect(renderProperties.get(1).props.propVal).toEqual(5) }) + + it("infers display names for composed refs in oneOf entries", function() { + const wrapper = shallow() + const oneOfModel = wrapper.find(Model).findWhere((node) => { + const specPath = node.prop("specPath") + return specPath && specPath.equals(List(["oneOf", 0])) + }) + + expect(oneOfModel.length).toEqual(1) + expect(oneOfModel.first().prop("displayName")).toEqual("Application") + }) }) From 1884db123f6809239ba4f3bb5d614d0ef7b6660e Mon Sep 17 00:00:00 2001 From: Paul Wellner Bou Date: Mon, 8 Jun 2026 10:48:57 +0200 Subject: [PATCH 2/2] docs(config): document resolveSubtreeOnExpand option --- docs/usage/configuration.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md index b4b28e1cb92..436e9f06393 100644 --- a/docs/usage/configuration.md +++ b/docs/usage/configuration.md @@ -114,6 +114,20 @@ Parameter name | Docker variable | Description 'none' (expands nothing). + + resolveSubtreeOnExpand + + Unavailable + Boolean=true. Controls whether an operation's or model's + subtree is resolved (dereferencing $refs and generating + samples) when it is expanded. The default is true. Set to + false to skip this work and render directly from the + unresolved specification, which can keep expansion responsive on very + large or deeply nested schemas (for example documents with many nested + oneOf compositions). When disabled, referenced schemas are + shown by name rather than fully inlined. + + filter FILTER