From 5c4a808ba808417b20c527d3031fdc94e1121024 Mon Sep 17 00:00:00 2001 From: Peter White <1788320+peterwhite@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:38:46 +0200 Subject: [PATCH] Stop self-referencing nullable OpenAPI schemas from recursing when expanded (#4665) --- .changeset/openapi-nullable-recursion.md | 6 ++ .../src/OpenAPISchemaServer.test.tsx | 69 +++++++++++++++++++ packages/react-openapi/src/utils.test.ts | 8 +++ packages/react-openapi/src/utils.ts | 14 ++++ 4 files changed, 97 insertions(+) create mode 100644 .changeset/openapi-nullable-recursion.md create mode 100644 packages/react-openapi/src/OpenAPISchemaServer.test.tsx diff --git a/.changeset/openapi-nullable-recursion.md b/.changeset/openapi-nullable-recursion.md new file mode 100644 index 000000000..367166228 --- /dev/null +++ b/.changeset/openapi-nullable-recursion.md @@ -0,0 +1,6 @@ +--- +"@gitbook/react-openapi": patch +"gitbook": patch +--- + +Fix OpenAPI schemas that reference themselves through a nullable union rendering forever when expanded, which crashed PDF exports. diff --git a/packages/react-openapi/src/OpenAPISchemaServer.test.tsx b/packages/react-openapi/src/OpenAPISchemaServer.test.tsx new file mode 100644 index 000000000..fbe85c8e9 --- /dev/null +++ b/packages/react-openapi/src/OpenAPISchemaServer.test.tsx @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'bun:test'; +import { renderToReadableStream } from 'react-dom/server'; + +import { parseOpenAPI } from '@gitbook/openapi-parser'; + +import type { OpenAPIClientContext } from './context'; +import { OpenAPIRootSchema } from './OpenAPISchemaServer'; +import { resolveOpenAPISchemas } from './schemas/resolveOpenAPISchemas'; +import { translations } from './translations'; + +const context: OpenAPIClientContext = { + translation: translations.en, + icons: { + chevronDown: null, + chevronRight: null, + plus: null, + copy: null, + check: null, + lock: null, + mcp: null, + hashtag: null, + }, + expandAllModelSections: true, + scalarRuntimeURL: '', + $$isClientContext$$: true, +}; + +describe('OpenAPIRootSchema', () => { + it('should stop at a self-reference behind a nullable union when expanded', async () => { + const { filesystem } = await parseOpenAPI({ + value: JSON.stringify({ + openapi: '3.1.0', + info: { title: 'Test', version: '1.0.0' }, + paths: {}, + components: { + schemas: { + FilterClause: { + type: 'object', + properties: { + AND: { + anyOf: [ + { + type: 'array', + items: { $ref: '#/components/schemas/FilterClause' }, + }, + { type: 'null' }, + ], + }, + }, + }, + }, + }, + }), + rootURL: 'memory://spec.json', + }); + const resolved = await resolveOpenAPISchemas(filesystem, { schemas: ['FilterClause'] }); + const schema = resolved?.schemas[0]?.schema; + if (!schema) { + throw new Error('FilterClause not resolved'); + } + + const stream = await renderToReadableStream( + + ); + const html = await new Response(stream).text(); + + expect(html).toContain('AND'); + }); +}); diff --git a/packages/react-openapi/src/utils.test.ts b/packages/react-openapi/src/utils.test.ts index 6c9722d48..8345f9f72 100644 --- a/packages/react-openapi/src/utils.test.ts +++ b/packages/react-openapi/src/utils.test.ts @@ -190,6 +190,14 @@ describe('extractNonNullTypes', () => { }); describe('normalizeNullableUnion', () => { + it('should return the same object for the same input', () => { + const schema: OpenAPIV3_1.SchemaObject = { + anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'null' }], + }; + + expect(normalizeNullableUnion(schema)).toBe(normalizeNullableUnion(schema)); + }); + it('should collapse anyOf with a single non-null member into a nullable schema', () => { const schema: OpenAPIV3_1.SchemaObject = { anyOf: [{ type: 'string' }, { type: 'null' }], diff --git a/packages/react-openapi/src/utils.ts b/packages/react-openapi/src/utils.ts index b8d67ad4b..2f130c9b8 100644 --- a/packages/react-openapi/src/utils.ts +++ b/packages/react-openapi/src/utils.ts @@ -287,6 +287,10 @@ function isNullSchema(schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject return type === 'null'; } +// Circular-ref tracking compares schemas by identity: a fresh copy on every render lets a +// self-referencing nullable property (`anyOf: [{ items: Self }, null]`) recurse forever when expanded. +const normalizedNullableUnions = new WeakMap(); + /** * Normalize the OpenAPI 3.1+ idiom of expressing nullability through `anyOf`/`oneOf` * (e.g. `anyOf: [{ type: 'string' }, { type: 'null' }]`) into a regular nullable schema, @@ -301,7 +305,17 @@ export function normalizeNullableUnion( schema: OpenAPIV3.SchemaObject | OpenAPIV3_1.SchemaObject ): OpenAPIV3.SchemaObject { const typed = schema as OpenAPIV3.SchemaObject; + const cached = normalizedNullableUnions.get(typed); + if (cached) { + return cached; + } + const normalized = normalizeNullableUnionUncached(typed); + normalizedNullableUnions.set(typed, normalized); + return normalized; +} + +function normalizeNullableUnionUncached(typed: OpenAPIV3.SchemaObject): OpenAPIV3.SchemaObject { const isAnyOf = Array.isArray(typed.anyOf); const isOneOf = !isAnyOf && Array.isArray(typed.oneOf); if (!isAnyOf && !isOneOf) {