mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-25 11:52:10 +00:00
Fix webframes inside modals not receiving visitor claims (#4635)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
+9
-10
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user