Fix webframes inside modals not receiving visitor claims (#4635)

This commit is contained in:
Tomek
2026-09-25 12:13:44 +02:00
committed by GitHub
parent d7f2addb03
commit e4638f1833
6 changed files with 58 additions and 139 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Pass page context to webframes opened from integration modals.
@@ -19,21 +19,25 @@ const adaptiveVisitorReaderCache = new Map<
function createResourceReader<T>(promise: Promise<T>) {
let result: T | null | undefined;
const suspender = (async () => {
const settled = (async () => {
try {
result = await promise;
} catch {
result = null;
}
return result;
})();
return {
read() {
if (result === undefined) {
throw suspender;
throw settled;
}
return result;
},
load() {
return settled;
},
};
}
@@ -65,6 +69,12 @@ export type AdaptiveVisitorContextValue = () => AdaptiveVisitorClaims | null;
const AdaptiveVisitorContext = createContext<AdaptiveVisitorContextValue>(() => null);
export type AdaptiveVisitorAsyncContextValue = () => Promise<AdaptiveVisitorClaims | null>;
const AdaptiveVisitorAsyncContext = createContext<AdaptiveVisitorAsyncContextValue>(
async () => null
);
/**
* Provide context to adapt site based on visitor claims.
*/
@@ -83,11 +93,22 @@ export function AdaptiveVisitorContextProvider(
return getAdaptiveVisitorClaimsReader(visitorClaimsURL, contextId).read();
}, [visitorClaimsURL, contextId]);
const loadAdaptiveVisitorClaims = React.useCallback(async () => {
if (!contextId) {
return null;
}
return getAdaptiveVisitorClaimsReader(visitorClaimsURL, contextId).load();
}, [visitorClaimsURL, contextId]);
return (
<AdaptiveVisitorContext.Provider value={getAdaptiveVisitorClaims}>
<OpenAPIPrefillContextProvider getPrefillInputContextData={getAdaptiveVisitorClaims}>
{children}
</OpenAPIPrefillContextProvider>
<AdaptiveVisitorAsyncContext.Provider value={loadAdaptiveVisitorClaims}>
<OpenAPIPrefillContextProvider
getPrefillInputContextData={getAdaptiveVisitorClaims}
>
{children}
</OpenAPIPrefillContextProvider>
</AdaptiveVisitorAsyncContext.Provider>
</AdaptiveVisitorContext.Provider>
);
}
@@ -98,3 +119,10 @@ export function AdaptiveVisitorContextProvider(
export function useAdaptiveVisitor(): AdaptiveVisitorContextValue {
return useContext(AdaptiveVisitorContext);
}
/**
* Hook that returns an async getter for adaptive visitor claims data, for use outside of render.
*/
export function useAdaptiveVisitorAsync(): AdaptiveVisitorAsyncContextValue {
return useContext(AdaptiveVisitorAsyncContext);
}
@@ -6,7 +6,7 @@ import React from 'react';
import { ContentKit, type ContentKitClientContextData } from '@gitbook/react-contentkit/client';
import type { WebframePageContext } from './adaptive';
import { useAdaptiveVisitor } from '@/components/Adaptive';
import { useAdaptiveVisitorAsync } from '@/components/Adaptive';
import { NavigationStatusContext } from '@/components/hooks';
import { type GitBookLinker, createLinker } from '@/lib/links';
@@ -40,7 +40,12 @@ export function ContentKitWithClientContext<RenderContext>(
const router = useRouter();
const { onNavigationClick } = React.useContext(NavigationStatusContext);
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
const loadAdaptiveVisitorClaims = useAdaptiveVisitorAsync();
const getVisitorContext = React.useCallback(async () => {
const visitorClaims = await loadAdaptiveVisitorClaims();
return { visitor: visitorClaims?.visitor ?? null };
}, [loadAdaptiveVisitorClaims]);
// 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.
@@ -55,15 +60,9 @@ export function ContentKitWithClientContext<RenderContext>(
},
[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,
getVisitorContext: canAccessVisitorClaims ? getVisitorContext : undefined,
getPageContext: page ? () => ({ page }) : undefined,
navigate: ({ path, anchor, query }) => {
// Resolve the requested path relative to the site root so a webframe can navigate
@@ -78,7 +77,7 @@ export function ContentKitWithClientContext<RenderContext>(
navigateTo(linker.toPathInSite(path) + search + hash);
},
}),
[canAccessVisitorClaims, visitorClaims, page, linker, navigateTo]
[canAccessVisitorClaims, getVisitorContext, page, linker, navigateTo]
);
return <ContentKit {...contentKitProps} clientContext={clientContext} />;
@@ -1,8 +1,8 @@
import type { DocumentBlockIntegration, RenderIntegrationUI } from '@gitbook/api';
import { ContentKit, ContentKitOutput } from '@gitbook/react-contentkit';
import { ContentKitOutput } from '@gitbook/react-contentkit';
import type { BlockProps } from '../Block';
import { getWebframePageContext, integrationBlockContainsWebframe } from './adaptive';
import { getWebframePageContext } from './adaptive';
import { contentKitServerContext } from './contentkit';
import './contentkit.css';
import {
@@ -74,16 +74,11 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
return null;
}
const containsWebframe = integrationBlockContainsWebframe(initialOutput);
const canAccessVisitorClaims = initialOutput.canAccessVisitorClaims === true;
// The current page (path/id/title) is non-sensitive, so it is always exposed to webframes.
const page = getWebframePageContext(context.contentContext);
// Any webframe uses the client-context wrapper: it enables navigation to other pages and
// exposes the current page, plus visitor claims when the integration is allowed them.
const useClientContext = containsWebframe;
const contentKitProps = {
renderContext: {
integrationName: block.data.integration,
@@ -105,20 +100,14 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
return (
<div className={tcls(style)}>
{useClientContext ? (
<ContentKitWithClientContext
{...contentKitProps}
canAccessVisitorClaims={canAccessVisitorClaims}
page={page}
linkerData={getWebframeLinkerData(context.contentContext.linker)}
>
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
</ContentKitWithClientContext>
) : (
<ContentKit {...contentKitProps}>
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
</ContentKit>
)}
<ContentKitWithClientContext
{...contentKitProps}
canAccessVisitorClaims={canAccessVisitorClaims}
page={page}
linkerData={getWebframeLinkerData(context.contentContext.linker)}
>
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
</ContentKitWithClientContext>
</div>
);
}
@@ -1,47 +1,9 @@
import { describe, expect, it } from 'bun:test';
import type { ContentKitRenderOutput, ContentKitWebFrame } from '@gitbook/api';
import { getWebframePageContext, integrationBlockContainsWebframe } from './adaptive';
import { getWebframePageContext } from './adaptive';
import type { GitBookAnyContext } from '@/lib/context';
import { createLinker } from '@/lib/links';
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);
});
});
describe('getWebframePageContext', () => {
it('returns null when the context has no page', () => {
const context = { space: { id: 'space-1' } } as unknown as GitBookAnyContext;
@@ -1,14 +1,5 @@
import type {
ContentKitDescendantElement,
ContentKitRenderOutput,
ContentKitRootElement,
ContentKitStepper,
} from '@gitbook/api';
import type { GitBookAnyContext } from '@/lib/context';
type ContentKitElement = ContentKitRootElement | ContentKitDescendantElement | ContentKitStepper;
/**
* Current page exposed to a webframe through the client-only webframe state.
*/
@@ -19,18 +10,6 @@ export type WebframePageContext = {
title: string;
};
/**
* Whether an integration block's output contains a webframe that can consume client-only context
* (navigation, visitor claims and/or the current page).
*/
export function integrationBlockContainsWebframe(output: ContentKitRenderOutput): boolean {
if (output.type === 'complete') {
return false;
}
return doesContentKitElementContainWebframe(output.element);
}
/**
* Extract the current page to expose to a webframe, or `null` when it is unknown
* (e.g. a non-page context, or reusable content resolved from another source).
@@ -55,46 +34,3 @@ export function getWebframePageContext(
title,
};
}
/**
* Check whether a ContentKit element tree contains a webframe element.
*/
function doesContentKitElementContainWebframe(element: ContentKitElement): boolean {
switch (element.type) {
case 'webframe':
return true;
case 'block':
case 'box':
case 'hstack':
case 'vstack':
case 'step':
case 'modal':
case 'configuration':
case 'stepper':
case 'card':
return doesContentKitElementArrayContainWebframe(element.children);
case 'codeblock':
return (
doesContentKitElementArrayContainWebframe(element.header) ||
doesContentKitElementArrayContainWebframe(element.footer)
);
default:
return false;
}
}
function doesContentKitElementArrayContainWebframe(elements: unknown): boolean {
if (!Array.isArray(elements)) {
return doesContentKitElementContainWebframeValue(elements);
}
return elements.some(doesContentKitElementContainWebframeValue);
}
function doesContentKitElementContainWebframeValue(value: unknown): boolean {
if (typeof value !== 'object' || value === null || !('type' in value)) {
return false;
}
return doesContentKitElementContainWebframe(value as ContentKitElement);
}