Load the OpenAPI renderer only on pages that use it (#4472)

This commit is contained in:
Nolann B.
2026-08-12 14:53:09 +02:00
committed by GitHub
parent 65f99eafe5
commit 1424c566a0
16 changed files with 286 additions and 40 deletions
@@ -0,0 +1,6 @@
---
"gitbook": patch
"@gitbook/react-openapi": minor
---
Keep the OpenAPI renderer out of the initial bundle of pages that have no OpenAPI block, by building its context on the client behind a dynamic boundary.
+4
View File
@@ -59,6 +59,10 @@ const nextConfig = {
optimisticClientCache: false,
// Disable splitting the RSC in like 5 chunks
prefetchInlining: true,
// Rewrites barrel imports into deep ones: without it, importing a single helper from
// react-openapi drags its whole client renderer into every page's entry.
optimizePackageImports: ['@gitbook/react-openapi'],
},
env: {
@@ -0,0 +1,32 @@
'use client';
import type { CustomizationThemedCodeTheme } from '@gitbook/api';
import { useId } from 'react';
import type { DocumentContext } from '../DocumentView';
import { ClientCodeBlock } from './ClientCodeBlock';
import { convertCodeStringToBlock } from './utils';
// Client counterpart of `PlainCodeBlock`, for callers that build code from client state and so
// cannot go through the async server `CodeBlock`.
export function ClientPlainCodeBlock(props: {
code: string;
syntax: string;
mode?: DocumentContext['mode'];
themes?: CustomizationThemedCodeTheme;
}) {
const { code, syntax, mode = 'default', themes } = props;
const id = useId();
const block = convertCodeStringToBlock({ key: id, code, syntax });
return (
<ClientCodeBlock
block={block}
inlines={[]}
inlineExprVariables={{}}
mode={mode}
themes={themes}
/>
);
}
@@ -0,0 +1,86 @@
'use client';
import type { CustomizationThemedCodeTheme } from '@gitbook/api';
import {
OpenAPIOperation as BaseOpenAPIOperation,
OpenAPISchemas as BaseOpenAPISchemas,
OpenAPIWebhook as BaseOpenAPIWebhook,
type OpenAPIContextInput,
} from '@gitbook/react-openapi';
import type React from 'react';
import { ClientPlainCodeBlock } from '../CodeBlock/ClientPlainCodeBlock';
import type { DocumentContext } from '../DocumentView';
// Only what survives the RSC boundary. Rebuilding the renderers on the client is what lets this
// whole subtree sit behind a `next/dynamic` chunk instead of the route's eager entry.
export type OpenAPIBlockClientContextProps = {
className?: string;
mode: DocumentContext['mode'];
codeTheme?: CustomizationThemedCodeTheme;
icons: OpenAPIContextInput['icons'];
specUrl: string | null;
/** Pre-signed server-side: signing needs a secret the client must never see. */
proxyUrl?: string;
locale?: OpenAPIContextInput['locale'];
expandAllResponses?: boolean;
expandAllModelSections?: boolean;
headless?: boolean;
id?: string;
blockKey?: string;
/** Rendered on the server: both go through the async document pipeline. */
headingNode: React.ReactNode;
descriptionNode: React.ReactNode;
scalarRuntimeURL: string;
};
type OpenAPIBlockVariant =
| { variant: 'operation'; data: React.ComponentProps<typeof BaseOpenAPIOperation>['data'] }
| { variant: 'webhook'; data: React.ComponentProps<typeof BaseOpenAPIWebhook>['data'] }
| {
variant: 'schemas';
data: React.ComponentProps<typeof BaseOpenAPISchemas>['data'];
grouped?: boolean;
};
export type OpenAPIBlockClientProps = OpenAPIBlockClientContextProps & OpenAPIBlockVariant;
export function OpenAPIBlockClient(props: OpenAPIBlockClientProps) {
const { className, mode, codeTheme, headingNode, descriptionNode, proxyUrl } = props;
const context: OpenAPIContextInput = {
icons: props.icons,
specUrl: props.specUrl,
locale: props.locale,
expandAllResponses: props.expandAllResponses,
expandAllModelSections: props.expandAllModelSections,
headless: props.headless,
id: props.id,
blockKey: props.blockKey,
renderCodeBlock: ({ code, syntax }) => (
<ClientPlainCodeBlock code={code} syntax={syntax} mode={mode} themes={codeTheme} />
),
renderDocument: () => descriptionNode,
renderHeading: () => headingNode,
resolveProxyUrl: proxyUrl ? () => proxyUrl : undefined,
scalarRuntimeURL: props.scalarRuntimeURL,
};
switch (props.variant) {
case 'operation':
return (
<BaseOpenAPIOperation data={props.data} context={context} className={className} />
);
case 'webhook':
return <BaseOpenAPIWebhook data={props.data} context={context} className={className} />;
case 'schemas':
return (
<BaseOpenAPISchemas
data={props.data}
grouped={props.grouped}
context={context}
className={className}
/>
);
}
}
@@ -0,0 +1,15 @@
'use client';
import dynamic from 'next/dynamic';
import type { OpenAPIBlockClientProps } from './OpenAPIBlockClient';
// `ssr: true` keeps the operation in the server-rendered HTML — API reference content is the whole
// SEO point of these pages.
const OpenAPIBlockClient = dynamic(
() => import('./OpenAPIBlockClient').then((mod) => mod.OpenAPIBlockClient),
{ ssr: true }
);
export function OpenAPIBlockLazy(props: OpenAPIBlockClientProps) {
return <OpenAPIBlockClient {...props} />;
}
@@ -1,11 +1,10 @@
import { OpenAPIOperation as BaseOpenAPIOperation } from '@gitbook/react-openapi';
import { resolveOpenAPIOperationBlock } from '@/lib/openapi/resolveOpenAPIOperationBlock';
import { tcls } from '@/lib/tailwind';
import type { AnyOpenAPIOperationsBlock } from '@/lib/openapi/types';
import type { BlockProps } from '../Block';
import { getOpenAPIContext } from './context';
import { OpenAPIBlockLazy } from './OpenAPIBlockLazy';
import { getOpenAPIBlockClientProps } from './context';
/**
* Render an openapi block or an openapi-operation block.
@@ -44,10 +43,12 @@ async function OpenAPIOperationBody(props: BlockProps<AnyOpenAPIOperationsBlock>
}
return (
<BaseOpenAPIOperation
<OpenAPIBlockLazy
variant="operation"
data={data}
context={getOpenAPIContext({
{...getOpenAPIBlockClientProps({
props,
data,
specUrl: publicURL,
context: context.contentContext,
expandAllResponses:
@@ -1,10 +1,10 @@
import { resolveOpenAPISchemasBlock } from '@/lib/openapi/resolveOpenAPISchemasBlock';
import { tcls } from '@/lib/tailwind';
import { OpenAPISchemas as BaseOpenAPISchemas } from '@gitbook/react-openapi';
import type { OpenAPISchemasBlock } from '@/lib/openapi/types';
import type { BlockProps } from '../Block';
import { getOpenAPIContext } from './context';
import { OpenAPIBlockLazy } from './OpenAPIBlockLazy';
import { getOpenAPIBlockClientProps } from './context';
/**
* Render an openapi-schemas block.
@@ -45,11 +45,13 @@ async function OpenAPISchemasBody(props: BlockProps<OpenAPISchemasBlock>) {
}
return (
<BaseOpenAPISchemas
<OpenAPIBlockLazy
variant="schemas"
data={data}
grouped={block.data.grouped}
context={getOpenAPIContext({
{...getOpenAPIBlockClientProps({
props,
schemas: { data, grouped: block.data.grouped },
specUrl: publicURL,
context: context.contentContext,
expandAllModelSections: data['x-expandAllModelSections'],
@@ -1,11 +1,10 @@
import { OpenAPIWebhook as BaseOpenAPIWebhook } from '@gitbook/react-openapi';
import { resolveOpenAPIWebhookBlock } from '@/lib/openapi/resolveOpenAPIWebhookBlock';
import { tcls } from '@/lib/tailwind';
import type { OpenAPIWebhookBlock } from '@/lib/openapi/types';
import type { BlockProps } from '../Block';
import { getOpenAPIContext } from './context';
import { OpenAPIBlockLazy } from './OpenAPIBlockLazy';
import { getOpenAPIBlockClientProps } from './context';
/**
* Render an openapi block or an openapi-webhook block.
@@ -46,10 +45,12 @@ async function OpenAPIWebhookBody(props: BlockProps<OpenAPIWebhookBlock>) {
}
return (
<BaseOpenAPIWebhook
<OpenAPIBlockLazy
variant="webhook"
data={data}
context={getOpenAPIContext({
{...getOpenAPIBlockClientProps({
props,
data,
specUrl: publicURL,
context: context.contentContext,
expandAllResponses:
@@ -1,11 +1,22 @@
import type { JSONDocument } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { type OpenAPIContextInput, checkIsValidLocale } from '@gitbook/react-openapi';
import {
type OpenAPIContextInput,
type OpenAPIOperationData,
type OpenAPISchemasData,
type OpenAPIWebhookData,
checkIsValidLocale,
extractOrigin,
getAllServerOrigins,
getOperationTitle,
getSchemasHeading,
} from '@gitbook/react-openapi';
import type { BlockProps } from '../Block';
import { PlainCodeBlock } from '../CodeBlock';
import { DocumentView } from '../DocumentView';
import { Heading } from '../Heading';
import type { OpenAPIBlockClientContextProps } from './OpenAPIBlockClient';
import './style.css';
import { DEFAULT_LOCALE, getSpaceLocale } from '@/intl/server';
@@ -121,3 +132,67 @@ export function getOpenAPIContext(args: {
locale,
};
}
// Same inputs as `getOpenAPIContext`, but anything that can't cross the RSC boundary is pre-rendered
// here or rebuilt on the client, so the block can sit behind a `next/dynamic` chunk.
export function getOpenAPIBlockClientProps(args: {
props: BlockProps<AnyOpenAPIOperationsBlock | OpenAPISchemasBlock | OpenAPIWebhookBlock>;
/** Omitted for schemas blocks, which have no operation nor servers. */
data?: OpenAPIOperationData | OpenAPIWebhookData;
/** Schemas blocks render their own heading, from a different shape. */
schemas?: { data: OpenAPISchemasData; grouped: boolean | undefined };
specUrl: string | null;
context: GitBookAnyContext | undefined;
expandAllResponses?: boolean;
expandAllModelSections?: boolean;
headless?: boolean;
}): OpenAPIBlockClientContextProps {
const { props, data, schemas, specUrl, context } = args;
const serverContext = getOpenAPIContext(args);
const { renderCodeBlock, renderDocument, renderHeading, resolveProxyUrl, ...serializable } =
serverContext;
const heading = data
? getOperationHeading(data, serializable.headless)
: schemas
? getSchemasHeading(schemas.data, schemas.grouped)
: null;
const descriptionDocument = data?.operation['x-gitbook-description-document'];
const origins = data ? getAllServerOrigins(data.servers) : [];
const specOrigin = specUrl ? extractOrigin(specUrl) : null;
if (specOrigin) {
origins.push(specOrigin);
}
return {
...serializable,
mode: props.context.mode,
codeTheme:
context && 'customization' in context
? context.customization.styling.codeTheme.openapi
: undefined,
proxyUrl: resolveProxyUrl?.(origins) ?? undefined,
headingNode: heading ? renderHeading(heading) : null,
descriptionNode: descriptionDocument
? renderDocument({ document: descriptionDocument })
: null,
};
}
// Mirrors the condition in `OpenAPISummary`, which is the only place asking for this heading.
function getOperationHeading(
data: OpenAPIOperationData | OpenAPIWebhookData,
headless: boolean | undefined
) {
const title = getOperationTitle(data);
if (headless || !title) {
return null;
}
return {
title,
deprecated: data.operation.deprecated ?? false,
stability: data.operation['x-stability'],
};
}
@@ -1,6 +1,7 @@
import { OpenAPIPath } from '../OpenAPIPath';
import type { OpenAPIContext } from '../context';
import type { OpenAPIOperationData, OpenAPIWebhookData } from '../types';
import { getOperationTitle } from '../utils';
import { OpenAPIMcpBadge } from './OpenAPIMcpBadge';
import { OpenAPIStability } from './OpenAPIStability';
@@ -11,17 +12,7 @@ export function OpenAPISummary(props: {
const { data, context } = props;
const { operation } = data;
const title = (() => {
if (operation.summary) {
return operation.summary;
}
if ('name' in data) {
return data.name;
}
return undefined;
})();
const title = getOperationTitle(data);
return (
<div
@@ -64,9 +64,13 @@ export function generateMediaTypeExamples(
/** Hard limit for rendering circular references */
const MAX_LEVELS_DEEP = 5;
// Fixed, not `new Date()`: the block is rendered on the server and hydrated in the browser, so any
// value read from the clock differs between the two and fails hydration.
const EXAMPLE_DATE = '2026-01-01T00:00:00.000Z';
const genericExampleValues: Record<string, string> = {
'date-time': new Date().toISOString(),
date: new Date().toISOString().split('T')[0] ?? '1970-01-01',
'date-time': EXAMPLE_DATE,
date: EXAMPLE_DATE.split('T')[0] ?? '1970-01-01',
email: 'name@gmail.com',
hostname: 'example.com',
ipv4: '0.0.0.0',
@@ -86,7 +90,7 @@ const genericExampleValues: Record<string, string> = {
// https://tools.ietf.org/html/draft-handrews-relative-json-pointer-01
'relative-json-pointer': '1/nested/objects',
// full-time in https://tools.ietf.org/html/rfc3339#section-5.6
time: new Date().toISOString().split('T')[1]?.split('.')[0] ?? '00:00:00Z',
time: EXAMPLE_DATE.split('T')[1]?.split('.')[0] ?? '00:00:00Z',
// either a URI or relative-reference https://tools.ietf.org/html/rfc3986#section-4.1
'uri-reference': '../folder',
'uri-template': 'https://example.com/{id}',
+2 -1
View File
@@ -1,5 +1,6 @@
export * from './schemas';
export * from './formatOpenAPIMethod';
export { getOperationTitle } from './utils';
export * from './OpenAPIMethodBadge';
export * from './OpenAPIOperation';
export * from './OpenAPIWebhook';
@@ -10,4 +11,4 @@ export * from './resolveOpenAPIWebhook';
export type { OpenAPIOperationData, OpenAPIWebhookData } from './types';
export type { OpenAPIContextInput } from './context';
export { checkIsValidLocale } from './translations';
export { extractOrigin } from './util/server';
export { extractOrigin, getAllServerOrigins } from './util/server';
@@ -11,7 +11,7 @@ import {
import { t } from '../translate';
import { getExampleFromSchema } from '../util/example';
import { OpenAPISchemaItem } from './OpenAPISchemaItem';
import type { OpenAPISchemasData } from './resolveOpenAPISchemas';
import { type OpenAPISchemasData, getSchemasHeading } from './resolveOpenAPISchemas';
/**
* OpenAPI Schemas component.
@@ -38,19 +38,14 @@ export function OpenAPISchemas(props: {
const clientContext = getOpenAPIClientContext(context);
// If there is only one model and we are not grouping, we show it directly.
if (schemas.length === 1 && !grouped) {
const title = `The ${firstSchema.name} object`;
const heading = getSchemasHeading(data, grouped);
if (heading) {
const { title } = heading;
return (
<div className={clsx('openapi-schemas openapi-schemas-single', className)}>
{/* The heading rendered below already carries the anchor id; a second id here would
win the fragment target and scroll to the wrong margin. */}
<div className="openapi-summary">
{context.renderHeading({
title,
deprecated: Boolean(firstSchema.schema.deprecated),
stability: firstSchema.schema['x-stability'],
})}
</div>
<div className="openapi-summary">{context.renderHeading(heading)}</div>
<div className="openapi-columns">
<div className="openapi-column-spec">
<StaticSection
@@ -11,6 +11,24 @@ export type OpenAPISchemasData = Pick<OpenAPICustomSpecProperties, 'x-expandAllM
schemas: OpenAPISchema[];
};
// Hosts that pre-render the heading must reach the same answer as `OpenAPISchemas`, so both read it
// from here rather than duplicating the condition.
export function getSchemasHeading(
data: OpenAPISchemasData,
grouped: boolean | undefined
): { title: string; deprecated: boolean; stability?: string } | null {
const firstSchema = data.schemas[0];
if (!firstSchema || data.schemas.length !== 1 || grouped) {
return null;
}
return {
title: `The ${firstSchema.name} object`,
deprecated: Boolean(firstSchema.schema.deprecated),
stability: firstSchema.schema['x-stability'],
};
}
/**
* Resolve an OpenAPI schemas from a file and compile it to a more usable format.
* Schemas are extracted from the OpenAPI components.schemas
+14
View File
@@ -6,6 +6,7 @@ import type {
OpenAPICustomSecurityScheme,
OpenAPIOperationData,
OpenAPISecurityScope,
OpenAPIWebhookData,
} from './types';
export function checkIsReference(input: unknown): input is OpenAPIV3.ReferenceObject {
@@ -483,3 +484,16 @@ function resolveRequiredScopesForScheme(
return operationScopes.map((scope) => [scope, undefined]);
}
/**
* Title shown for an operation or webhook. Exported so hosts that pre-render the heading
* server-side derive it the same way this package does.
*/
export function getOperationTitle(
data: OpenAPIOperationData | OpenAPIWebhookData
): string | undefined {
if (data.operation.summary) {
return data.operation.summary;
}
return 'name' in data ? data.name : undefined;
}
+1
View File
@@ -3,6 +3,7 @@ import { defineConfig } from 'tsdown';
export default defineConfig([
{
entry: 'src/index.ts',
// One file per module, so consumers can deep-import past the barrel.
unbundle: true,
},
]);