Compare commits

...

3 Commits

Author SHA1 Message Date
Peter White 858f586d29 Merge main into peter/rnd-13222-openapi-nullable-recursion 2026-10-06 18:49:37 +02:00
Peter White d75bea72b6 changeset 2026-10-06 16:54:12 +02:00
Peter White 33dd0d3d7c Stop self-referencing nullable OpenAPI schemas from recursing when expanded
Part of RND-13222
2026-10-06 16:54:12 +02:00
4 changed files with 97 additions and 0 deletions
+6
View File
@@ -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.
@@ -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(
<OpenAPIRootSchema schema={schema} context={context} />
);
const html = await new Response(stream).text();
expect(html).toContain('AND');
});
});
+8
View File
@@ -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' }],
+14
View File
@@ -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<OpenAPIV3.SchemaObject, OpenAPIV3.SchemaObject>();
/**
* 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) {