From e4638f1833223899d92212b8a74696926c88c9fc Mon Sep 17 00:00:00 2001 From: Tomek Date: Fri, 25 Sep 2026 12:13:44 +0200 Subject: [PATCH] Fix webframes inside modals not receiving visitor claims (#4635) --- .changeset/quiet-frames-travel.md | 5 ++ .../AdaptiveVisitorContextProvider.tsx | 38 +++++++++-- .../ContentKitWithClientContext.tsx | 19 +++--- .../Integration/IntegrationBlock.tsx | 31 +++------ .../DocumentView/Integration/adaptive.test.ts | 40 +----------- .../DocumentView/Integration/adaptive.ts | 64 ------------------- 6 files changed, 58 insertions(+), 139 deletions(-) create mode 100644 .changeset/quiet-frames-travel.md diff --git a/.changeset/quiet-frames-travel.md b/.changeset/quiet-frames-travel.md new file mode 100644 index 000000000..c2a0e8680 --- /dev/null +++ b/.changeset/quiet-frames-travel.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Pass page context to webframes opened from integration modals. diff --git a/packages/gitbook/src/components/Adaptive/AdaptiveVisitorContextProvider.tsx b/packages/gitbook/src/components/Adaptive/AdaptiveVisitorContextProvider.tsx index 91b163246..340977cad 100644 --- a/packages/gitbook/src/components/Adaptive/AdaptiveVisitorContextProvider.tsx +++ b/packages/gitbook/src/components/Adaptive/AdaptiveVisitorContextProvider.tsx @@ -19,21 +19,25 @@ const adaptiveVisitorReaderCache = new Map< function createResourceReader(promise: Promise) { 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(() => null); +export type AdaptiveVisitorAsyncContextValue = () => Promise; + +const AdaptiveVisitorAsyncContext = createContext( + 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 ( - - {children} - + + + {children} + + ); } @@ -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); +} diff --git a/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithClientContext.tsx b/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithClientContext.tsx index 04a103be5..660851c7c 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithClientContext.tsx +++ b/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithClientContext.tsx @@ -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( 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( }, [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( () => ({ - 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( navigateTo(linker.toPathInSite(path) + search + hash); }, }), - [canAccessVisitorClaims, visitorClaims, page, linker, navigateTo] + [canAccessVisitorClaims, getVisitorContext, page, linker, navigateTo] ); return ; diff --git a/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx b/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx index 02c7f72c9..9d8ca9266 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx +++ b/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx @@ -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 - {useClientContext ? ( - - - - ) : ( - - - - )} + + + ); } diff --git a/packages/gitbook/src/components/DocumentView/Integration/adaptive.test.ts b/packages/gitbook/src/components/DocumentView/Integration/adaptive.test.ts index 46486b01e..57552b778 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/adaptive.test.ts +++ b/packages/gitbook/src/components/DocumentView/Integration/adaptive.test.ts @@ -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; diff --git a/packages/gitbook/src/components/DocumentView/Integration/adaptive.ts b/packages/gitbook/src/components/DocumentView/Integration/adaptive.ts index d24846988..9b07e747a 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/adaptive.ts +++ b/packages/gitbook/src/components/DocumentView/Integration/adaptive.ts @@ -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); -}