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) {