Add a navigate action to integration block webframes (#4362)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Greg Bergé
2026-07-10 11:24:33 +02:00
committed by GitHub
parent 49d35aa026
commit cb92754575
8 changed files with 221 additions and 71 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/react-contentkit": patch
"gitbook": patch
---
Let integration block webframes navigate the reader to another page in the site by posting a `@webframe.navigate` action with a `path` (and optional `anchor`). Resolved client-side against the site base path, so navigation stays in-site and drives the standard navigation progress bar.
@@ -1,28 +0,0 @@
'use client';
import { useAdaptiveVisitor } from '@/components/Adaptive';
import { ContentKit, type ContentKitClientContextData } from '@gitbook/react-contentkit/client';
import React from 'react';
type ContentKitProps<RenderContext> = React.ComponentProps<typeof ContentKit<RenderContext>>;
/**
* ContentKit wrapper for integration blocks that need client-only adaptive context.
*/
export function ContentKitWithAdaptiveVisitorContext<RenderContext>(
props: ContentKitProps<RenderContext>
) {
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
const visitorClaims = getAdaptiveVisitorClaims();
const clientContext = React.useMemo<ContentKitClientContextData>(
() => ({
getVisitorContext: () => ({
visitor: visitorClaims?.visitor ?? null,
}),
}),
[visitorClaims]
);
return <ContentKit {...props} clientContext={clientContext} />;
}
@@ -0,0 +1,70 @@
'use client';
import { useAdaptiveVisitor } from '@/components/Adaptive';
import { NavigationStatusContext } from '@/components/hooks';
import { type GitBookLinker, createLinker } from '@/lib/links';
import { ContentKit, type ContentKitClientContextData } from '@gitbook/react-contentkit/client';
import { useRouter } from 'next/navigation';
import React from 'react';
type ContentKitProps<RenderContext> = React.ComponentProps<typeof ContentKit<RenderContext>>;
/** Serializable inputs to rebuild the tested linker on the client (functions can't cross the RSC boundary). */
export type WebframeLinkerData = Pick<
Parameters<typeof createLinker>[0],
'host' | 'protocol' | 'siteBasePath' | 'spaceBasePath'
>;
/**
* ContentKit wrapper for integration blocks that expose client-only capabilities to webframes:
* navigation to other pages, and adaptive visitor claims (only when the integration is allowed to
* access them).
*/
export function ContentKitWithClientContext<RenderContext>(
props: ContentKitProps<RenderContext> & {
/** Whether visitor claims may be exposed to the webframe (integration scope gated). */
canAccessVisitorClaims: boolean;
/** Data to rebuild the site linker, used to resolve webframe navigation requests. */
linkerData: WebframeLinkerData;
}
) {
const { canAccessVisitorClaims, linkerData, ...contentKitProps } = props;
const router = useRouter();
const { onNavigationClick } = React.useContext(NavigationStatusContext);
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
// Rebuild the (tested) linker on the client so navigation resolves paths exactly like the rest
// of the app, instead of duplicating the join logic here.
const linker = React.useMemo<GitBookLinker>(() => createLinker(linkerData), [linkerData]);
// Navigate to an in-site href, driving the same navigation progress bar as a regular link so
// the reader gets feedback while the destination page loads.
const navigateTo = React.useCallback(
(href: string) => {
onNavigationClick(href);
router.push(href);
},
[onNavigationClick, router]
);
// Read during render (Suspense) only when the integration is allowed visitor claims, so that
// webframes that don't use visitor claims don't suspend on the visitor-claims fetch.
const visitorClaims = canAccessVisitorClaims ? getAdaptiveVisitorClaims() : null;
const clientContext = React.useMemo<ContentKitClientContextData>(
() => ({
getVisitorContext: canAccessVisitorClaims
? () => ({ visitor: visitorClaims?.visitor ?? null })
: undefined,
navigate: ({ path, anchor }) => {
// Resolve the requested path relative to the site root so a webframe can navigate
// to any section or space within the site (and nowhere outside it).
const suffix = anchor ? `#${anchor}` : '';
navigateTo(linker.toPathInSite(path) + suffix);
},
}),
[canAccessVisitorClaims, visitorClaims, linker, navigateTo]
);
return <ContentKit {...contentKitProps} clientContext={clientContext} />;
}
@@ -5,8 +5,12 @@ import { ContentKit, ContentKitOutput } from '@gitbook/react-contentkit';
import type { BlockProps } from '../Block';
import './contentkit.css';
import { ContentKitWithAdaptiveVisitorContext } from './ContentKitWithAdaptiveVisitorContext';
import { shouldRenderIntegrationBlockWithAdaptiveVisitorContext } from './adaptive';
import type { GitBookLinker } from '@/lib/links';
import {
ContentKitWithClientContext,
type WebframeLinkerData,
} from './ContentKitWithClientContext';
import { integrationBlockContainsWebframe } from './adaptive';
import { contentKitServerContext } from './contentkit';
import { fetchSafeIntegrationUI } from './render';
import { renderIntegrationUi } from './server-actions';
@@ -70,34 +74,70 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
return null;
}
const ContentKitComponent = shouldRenderIntegrationBlockWithAdaptiveVisitorContext(
initialOutput
)
? ContentKitWithAdaptiveVisitorContext
: ContentKit;
const containsWebframe = integrationBlockContainsWebframe(initialOutput);
const canAccessVisitorClaims = initialOutput.canAccessVisitorClaims === true;
// Any webframe uses the client-context wrapper: it enables navigation to other pages, plus
// visitor claims when the integration is allowed them.
const useClientContext = containsWebframe;
const contentKitProps = {
renderContext: {
integrationName: block.data.integration,
},
security: {
// Trust both the integrations host and the (cookieless) content host that
// serves rendered WebFrames. `ElementWebframe` gates inbound and outbound
// postMessage on this list, so a WebFrame served from the content host would
// break (no resize/ready/actions) if the content host weren't trusted.
// The hosts are identical until a distinct content origin is configured.
firstPartyDomains: [
...new Set([GITBOOK_INTEGRATIONS_HOST, GITBOOK_INTEGRATIONS_CONTENT_HOST]),
],
},
initialInput,
initialOutput,
render: renderIntegrationUi,
};
return (
<div className={tcls(style)}>
<ContentKitComponent
renderContext={{
integrationName: block.data.integration,
}}
security={{
// Trust both the integrations host and the (cookieless) content host that
// serves rendered WebFrames. `ElementWebframe` gates inbound and outbound
// postMessage on this list, so a WebFrame served from the content host would
// break (no resize/ready/actions) if the content host weren't trusted.
// The hosts are identical until a distinct content origin is configured.
firstPartyDomains: [
...new Set([GITBOOK_INTEGRATIONS_HOST, GITBOOK_INTEGRATIONS_CONTENT_HOST]),
],
}}
initialInput={initialInput}
initialOutput={initialOutput}
render={renderIntegrationUi}
>
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
</ContentKitComponent>
{useClientContext ? (
<ContentKitWithClientContext
{...contentKitProps}
canAccessVisitorClaims={canAccessVisitorClaims}
linkerData={getWebframeLinkerData(context.contentContext.linker)}
>
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
</ContentKitWithClientContext>
) : (
<ContentKit {...contentKitProps}>
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
</ContentKit>
)}
</div>
);
}
/**
* Extract the serializable data needed to rebuild the site linker on the client, so webframe
* navigation resolves paths through the same (tested) linker as the rest of the app.
*/
function getWebframeLinkerData(linker: GitBookLinker): WebframeLinkerData {
const data: WebframeLinkerData = {
siteBasePath: linker.siteBasePath,
spaceBasePath: linker.spaceBasePath,
};
// `host`/`protocol` are only used to build absolute URLs, which webframe navigation never does.
// Carry them along when available so the rebuilt linker is complete (and avoids a dev warning).
try {
const url = new URL(linker.toAbsoluteURL('/'));
data.host = url.host;
data.protocol = url.protocol;
} catch {
// No usable host (e.g. tests): the linker still resolves in-site paths without it.
}
return data;
}
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'bun:test';
import type { ContentKitRenderOutput, ContentKitWebFrame } from '@gitbook/api';
import { integrationBlockContainsWebframe } from './adaptive';
const webframe: ContentKitWebFrame = {
type: 'webframe',
source: { url: 'https://integrations.gitbook.com/frame' },
};
function elementOutput(element: unknown): ContentKitRenderOutput {
return {
type: 'element',
element,
state: {},
props: {},
} as ContentKitRenderOutput;
}
describe('integrationBlockContainsWebframe', () => {
it('returns false for a completed output', () => {
expect(integrationBlockContainsWebframe({ type: 'complete' })).toBe(false);
});
it('returns false when there is no webframe in the tree', () => {
const output = elementOutput({
type: 'block',
children: [{ type: 'text', text: 'hello' }],
} as never);
expect(integrationBlockContainsWebframe(output)).toBe(false);
});
it('returns true when a webframe is nested in the tree', () => {
const output = elementOutput({
type: 'block',
children: [{ type: 'vstack', children: [webframe] }],
} as never);
expect(integrationBlockContainsWebframe(output)).toBe(true);
});
});
@@ -8,19 +8,15 @@ import type {
type ContentKitElement = ContentKitRootElement | ContentKitDescendantElement | ContentKitStepper;
/**
* Decide whether an integration block should expose Adaptive visitor context to webframes.
* Whether an integration block's output contains a webframe that can consume client-only context
* (navigation and/or visitor claims).
*/
export function shouldRenderIntegrationBlockWithAdaptiveVisitorContext(
output: ContentKitRenderOutput
) {
export function integrationBlockContainsWebframe(output: ContentKitRenderOutput): boolean {
if (output.type === 'complete') {
return false;
}
return (
output.canAccessVisitorClaims === true &&
doesContentKitElementContainWebframe(output.element)
);
return doesContentKitElementContainWebframe(output.element);
}
/**
@@ -127,6 +127,19 @@ export function ElementWebframe(props: ContentKitClientElementProps<ContentKitWe
})(),
}));
break;
case '@webframe.navigate':
// Let the host navigate to another page. The destination is addressed by
// `path`; the host resolves it within the current site and gates it.
if (typeof message.action.path === 'string') {
renderer.clientContext?.navigate?.({
path: message.action.path,
anchor:
typeof message.action.anchor === 'string'
? message.action.anchor
: undefined,
});
}
break;
default:
renderer.update({
action: message.action,
@@ -146,7 +159,7 @@ export function ElementWebframe(props: ContentKitClientElementProps<ContentKitWe
};
}, [renderer, sendMessage]);
// Send data and client-only visitor context as state to the webframe.
// Send data and client-only context (visitor claims) as state to the webframe.
React.useEffect(() => {
const abort = { cancelled: false };
sendWebframeState({
@@ -218,14 +231,14 @@ function resolveWebframeState(
}
/**
* Read optional client-only visitor context.
* Resolve the optional client-only contexts (visitor claims) to merge into the webframe state.
*/
async function resolveVisitorContext(clientContext: ContentKitClientContextData | undefined) {
return await clientContext?.getVisitorContext?.();
async function resolveClientContexts(clientContext: ContentKitClientContextData | undefined) {
return await Promise.all([clientContext?.getVisitorContext?.()]);
}
/**
* Send the combined webframe state once visitor context has been resolved.
* Send the combined webframe state once client-only contexts have been resolved.
*/
async function sendWebframeState(args: {
elementData: ContentKitWebFrame['data'];
@@ -236,14 +249,16 @@ async function sendWebframeState(args: {
}) {
const { elementData, rendererState, clientContext, sendMessage, abort } = args;
const state = resolveWebframeState(elementData, rendererState);
const visitorContext = await resolveVisitorContext(clientContext);
const clientContexts = await resolveClientContexts(clientContext);
if (abort.cancelled) {
return;
}
if (typeof visitorContext !== 'undefined') {
Object.assign(state, visitorContext);
for (const context of clientContexts) {
if (context) {
Object.assign(state, context);
}
}
if (Object.keys(state).length > 0) {
+11
View File
@@ -18,11 +18,22 @@ export type ContentKitRenderUpdate = Partial<
>;
export type ContentKitClientContextData = {
/**
* Client-only visitor claims, merged into the webframe state.
* Gated by the integration's visitor-claims scope.
*/
getVisitorContext?: () =>
| Record<string, unknown>
| null
| undefined
| Promise<Record<string, unknown> | null | undefined>;
/**
* Navigate the host page to another page, in response to a webframe `@webframe.navigate`
* action. The destination is addressed by `path` (resolved against the site base path); the
* host restricts navigation to destinations within the current site.
*/
navigate?: (target: { path: string; anchor?: string }) => void;
};
export interface ContentKitClientContextType {