OpenAPI: Support sync loading components on client mode

This commit is contained in:
Fuma Nama
2026-03-31 17:17:49 +08:00
parent e3855eb1cf
commit 9b350d76cb
20 changed files with 89 additions and 92 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
Support sync loading components on client mode
@@ -104,12 +104,12 @@ function Page() {
</DocsPage>
);
} else {
content = clientLoader.useContent(page.path, page);
content = <Suspense>{clientLoader.useContent(page.path, page)}</Suspense>;
}
return (
<DocsLayout {...baseOptions()} tree={page.pageTree}>
<Suspense>{content}</Suspense>
{content}
</DocsLayout>
);
}
+1 -12
View File
@@ -2,7 +2,6 @@
import {
type FC,
Fragment,
lazy,
type ReactNode,
useEffect,
useMemo,
@@ -53,6 +52,7 @@ import type { ParameterObject } from '@/types';
import { ClientCodeBlock } from '@/ui/components/codeblock';
import { useTranslations } from '@/ui/client/i18n';
import { useOperationContext } from '@/ui/operation/client';
import { OauthDialog, OauthDialogTrigger } from './components/oauth-dialog';
export interface FormValues extends Record<string, unknown> {
path: Record<string, unknown>;
@@ -131,17 +131,6 @@ export interface PlaygroundClientOptions {
) => ReactNode;
}
const OauthDialog = lazy(() =>
import('./components/oauth-dialog').then((mod) => ({
default: mod.OauthDialog,
})),
);
const OauthDialogTrigger = lazy(() =>
import('./components/oauth-dialog').then((mod) => ({
default: mod.OauthDialogTrigger,
})),
);
export default function PlaygroundClient({
route,
method = 'GET',
+1 -2
View File
@@ -7,7 +7,6 @@ import type {
} from '@/types';
import { getPreferredType, NoReference, type ParsedSchema } from '@/utils/schema';
import { type PlaygroundClientProps } from './client';
import { ClientLazy } from './lazy';
interface Context {
references: Record<string, ParsedSchema>;
@@ -97,7 +96,7 @@ export function APIPlayground({ path, method, ctx }: APIPlaygroundProps) {
readOnly: false,
};
return <ClientLazy {...props} />;
return <ctx.clientBoundary.PlaygroundClient {...props} />;
}
function writeReferences(
-4
View File
@@ -1,4 +0,0 @@
'use client';
import { wrapLazy } from '../utils/lazy';
export const ClientLazy = wrapLazy(() => import('./client'));
+1
View File
@@ -44,6 +44,7 @@ export interface RenderContext
* dereferenced schema
*/
schema: ProcessedDocument;
clientBoundary: typeof import('@/ui/client/boundary');
mediaAdapters: Record<string, MediaAdapter>;
+4 -5
View File
@@ -1,7 +1,6 @@
import { Operation } from '@/ui/operation';
import type { HttpMethods, RenderContext, ServerObject } from '@/types';
import { createMethod } from '@/utils/schema';
import { ApiProviderLazy, ServerProviderLazy } from './contexts/api.lazy';
export interface ApiPageProps {
document: string;
@@ -113,10 +112,10 @@ export function APIPage({
);
return (
<ApiProviderLazy shikiOptions={ctx.shikiOptions} client={ctx.client ?? {}}>
<ServerProviderLazy servers={dereferenced.servers as ServerObject[]}>
<ctx.clientBoundary.ApiProvider shikiOptions={ctx.shikiOptions} client={ctx.client ?? {}}>
<ctx.clientBoundary.ServerProvider servers={dereferenced.servers as ServerObject[]}>
{content}
</ServerProviderLazy>
</ApiProviderLazy>
</ctx.clientBoundary.ServerProvider>
</ctx.clientBoundary.ApiProvider>
);
}
+2
View File
@@ -25,6 +25,7 @@ import type { BundledTheme, CodeOptionsThemes, CodeToHastOptionsCommon } from 's
import { highlightHast, type ShikiFactory } from 'fumadocs-core/highlight/shiki';
import type { ExampleRequestItem } from './operation/get-example-requests';
import { compile } from '@fumari/json-schema-ts';
import * as ClientBoundary from '@/ui/client/boundary.lazy';
export interface GenerateTypeScriptDefinitionsContext extends RenderContext {
operation: NoReference<MethodInformation>;
@@ -256,6 +257,7 @@ export function createAPIPage(
const ctx: RenderContext = {
schema: processed,
proxyUrl: server.options.proxyUrl,
clientBoundary: ClientBoundary,
...options,
mediaAdapters: {
...defaultAdapters,
@@ -0,0 +1,39 @@
'use client';
import { type ComponentType, lazy } from 'react';
// the boundary between server & lazy client components
export const ApiProvider = wrapLazy(() =>
import('@/ui/contexts/api').then((mod) => ({ default: mod.ApiProvider })),
);
export const ServerProvider = wrapLazy(() =>
import('@/ui/contexts/api').then((mod) => ({ default: mod.ServerProvider })),
);
export const UsageTabsSelector = wrapLazy(() =>
import('@/ui/operation/usage-tabs/client').then((mod) => ({ default: mod.UsageTabsSelector })),
);
export const UsageTab = wrapLazy(() =>
import('@/ui/operation/usage-tabs/client').then((mod) => ({ default: mod.UsageTab })),
);
export const SchemaUI = wrapLazy(() =>
import('@/ui/schema/client').then((mod) => ({ default: mod.SchemaUI })),
);
export const PlaygroundClient = wrapLazy(() => import('@/playground/client'));
// Waku wraps all export functions of a "use client" file in `React.lazy`, but `lazy(lazy(() => ...))` causes error.
// we wrap another layer of component such that it is valid
// TODO: perhaps we can remove it once Waku migrated to vite-rsc
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- type infer
function wrapLazy<T extends ComponentType<any>>(load: () => Promise<{ default: T }>): T {
const V = lazy(load);
return function wrapper(props) {
return <V {...props} />;
} as T;
}
@@ -0,0 +1,6 @@
'use client';
export { ApiProvider, ServerProvider } from '@/ui/contexts/api';
export { UsageTab, UsageTabsSelector } from '@/ui/operation/usage-tabs/client';
export { SchemaUI } from '@/ui/schema/client';
export { default as PlaygroundClient } from '@/playground/client';
@@ -1,10 +0,0 @@
'use client';
import { wrapLazy } from '../../utils/lazy';
export const ApiProviderLazy = wrapLazy(() =>
import('./api').then((mod) => ({ default: mod.ApiProvider })),
);
export const ServerProviderLazy = wrapLazy(() =>
import('./api').then((mod) => ({ default: mod.ServerProvider })),
);
@@ -26,6 +26,7 @@ import { ClientCodeBlock, ClientCodeBlockProvider } from './components/codeblock
import { dereferenceSync } from '@/utils/schema/dereference';
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
import { slug } from 'github-slugger';
import * as ClientBoundary from '@/ui/client/boundary';
export interface ClientApiPageProps extends Omit<ApiPageProps, 'document'> {
payload: ClientApiPagePayload;
@@ -107,6 +108,7 @@ export function createClientAPIPage({
shiki,
shikiOptions,
generateTypeScriptDefinitions,
clientBoundary: ClientBoundary,
...options,
mediaAdapters: {
...defaultAdapters,
+2 -3
View File
@@ -24,7 +24,6 @@ import {
import { isMediaTypeSupported } from '@/requests/media/adapter';
import { APIPlayground } from '@/playground';
import { RequestTabs } from './request-tabs';
import { ServerProviderLazy } from '../contexts/api.lazy';
import { cn } from '@/utils/cn';
import { getExampleRequests } from './get-example-requests';
import { SelectTabs, SelectTabTrigger, SelectTab } from '../components/server-tab';
@@ -304,9 +303,9 @@ export function Operation({
);
if (method.servers) {
content = (
<ServerProviderLazy servers={method.servers as ServerObject[]}>
<ctx.clientBoundary.ServerProvider servers={method.servers as ServerObject[]}>
{content}
</ServerProviderLazy>
</ctx.clientBoundary.ServerProvider>
);
}
@@ -9,7 +9,6 @@ import {
CodeBlockTabsList,
CodeBlockTabsTrigger,
} from 'fumadocs-ui/components/codeblock';
import { UsageTabsSelectorLazy, UsageTabLazy } from './lazy';
import { ResponseTabs } from '../response-tabs';
import { registerDefault } from '@/requests/generators/all';
import { useMemo } from 'react';
@@ -42,7 +41,7 @@ export function UsageTabs({ method, ctx }: { method: MethodInformation; ctx: Ren
</CodeBlockTabsList>
{map.map(([id, item]) => (
<CodeBlockTab key={id} value={id}>
<UsageTabLazy id={id} lang={item.lang} _client={item._client} />
<ctx.clientBoundary.UsageTab id={id} lang={item.lang} _client={item._client} />
</CodeBlockTab>
))}
</CodeBlockTabs>
@@ -74,7 +73,7 @@ export function UsageTabs({ method, ctx }: { method: MethodInformation; ctx: Ren
return renderAPIExampleLayout(
{
selector: method['x-exclusiveCodeSample'] ? null : <UsageTabsSelectorLazy />,
selector: method['x-exclusiveCodeSample'] ? null : <ctx.clientBoundary.UsageTabsSelector />,
usageTabs: renderAPIExampleUsageTabs(registry, ctx),
responseTabs: <ResponseTabs operation={method} ctx={ctx} />,
},
@@ -1,10 +0,0 @@
'use client';
import { wrapLazy } from '../../../utils/lazy';
export const UsageTabsSelectorLazy = wrapLazy(() =>
import('./client').then((mod) => ({ default: mod.UsageTabsSelector })),
);
export const UsageTabLazy = wrapLazy(() =>
import('./client').then((mod) => ({ default: mod.UsageTab })),
);
+16 -9
View File
@@ -4,7 +4,6 @@ import type { RenderContext } from '@/types';
import { FormatFlags, schemaToString } from '@/utils/schema/to-string';
import { mergeAllOf } from '@/utils/merge-schema';
import type { SchemaUIProps } from '@/ui/schema/client';
import { SchemaUILazy } from '@/ui/schema/lazy';
import { I18nLabel } from '../client/i18n';
export interface FieldBase {
@@ -80,21 +79,29 @@ export interface SchemaUIGeneratedData {
export function Schema({
ctx,
...options
client,
root,
readOnly,
writeOnly,
}: SchemaUIOptions & {
ctx: RenderContext;
}) {
const generated = useMemo(
() => generateSchemaUI(root, readOnly, writeOnly, ctx),
[root, readOnly, writeOnly, ctx],
);
if (ctx.schemaUI?.render) {
return ctx.schemaUI.render(options, ctx);
return ctx.schemaUI.render({ client, root, readOnly, writeOnly }, ctx);
}
// eslint-disable-next-line react-hooks/rules-of-hooks -- assume options unchanged
const generated = useMemo(() => generateSchemaUI(options, ctx), [ctx, options]);
return <SchemaUILazy {...options.client} generated={generated} />;
return <ctx.clientBoundary.SchemaUI {...client} generated={generated} />;
}
export function generateSchemaUI(
{ root, readOnly, writeOnly }: SchemaUIOptions,
root: ResolvedSchema,
readOnly = false,
writeOnly = false,
ctx: RenderContext,
): SchemaUIGeneratedData {
const refs: Record<string, SchemaData> = {};
@@ -185,8 +192,8 @@ export function generateSchemaUI(
function isVisible(schema: ResolvedSchema): boolean {
if (typeof schema === 'boolean') return true;
if (schema.writeOnly) return writeOnly ?? false;
if (schema.readOnly) return readOnly ?? false;
if (schema.writeOnly) return writeOnly;
if (schema.readOnly) return readOnly;
return true;
}
-6
View File
@@ -1,6 +0,0 @@
'use client';
import { wrapLazy } from '../../utils/lazy';
export const SchemaUILazy = wrapLazy(() =>
import('./client').then((mod) => ({ default: mod.SchemaUI })),
);
-13
View File
@@ -1,13 +0,0 @@
import { type ComponentType, lazy } from 'react';
// Waku wraps all export functions of a "use client" file in `React.lazy`, but `lazy(lazy(() => ...))` causes error.
// we wrap another layer of component such that it is valid
// TODO: perhaps we can remove it once Waku migrated to vite-rsc
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- type infer
export function wrapLazy<T extends ComponentType<any>>(load: () => Promise<{ default: T }>): T {
const V = lazy(load);
return function wrapper(props) {
return <V {...props} />;
} as T;
}
+4 -13
View File
@@ -11,13 +11,7 @@ test('double-oneOf in allOf does not crash', async () => {
const ctx = await renderContextFrom(path.join(cwd, './fixtures/double-oneof.yaml'));
const createItem = ctx.schema.dereferenced.components!.schemas!.CreateItem as ResolvedSchema;
const out = generateSchemaUI(
{
client: { name: 'test' },
root: createItem,
},
ctx,
);
const out = generateSchemaUI(createItem, undefined, undefined, ctx);
// Should produce a cross-product of 4 oneOf variants, not crash
expect(out).toBeDefined();
@@ -28,12 +22,9 @@ test('test', async () => {
const ctx = await renderContextFrom(path.join(cwd, './fixtures/unkey.json'));
const out = generateSchemaUI(
{
client: {
name: 'test',
},
root: ctx.schema.dereferenced.components!.schemas!.V1KeysVerifyKeyResponse as ResolvedSchema,
},
ctx.schema.dereferenced.components!.schemas!.V1KeysVerifyKeyResponse as ResolvedSchema,
undefined,
undefined,
ctx,
);
+2
View File
@@ -1,6 +1,7 @@
import { RenderContext } from '@/types';
import { processDocument } from '@/utils/process-document';
import { defaultShikiFactory } from 'fumadocs-core/highlight/shiki/full';
import * as ClientBoundary from '@/ui/client/boundary.lazy';
export async function renderContextFrom(input: string): Promise<RenderContext> {
const schema = await processDocument(input);
@@ -18,6 +19,7 @@ export async function renderContextFrom(input: string): Promise<RenderContext> {
renderHeading(_depth, text) {
return text;
},
clientBoundary: ClientBoundary,
renderMarkdown(text) {
return text;
},