Fix crash when integration script fails to render block (#3332)

This commit is contained in:
Samy Pessé
2025-06-16 20:06:33 +02:00
committed by GitHub
parent af98402655
commit 11a6511b7a
7 changed files with 129 additions and 42 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix crash when integration script fails to render block.
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/react-contentkit": patch
---
Add basic error handling when transitioning between states.
+29 -5
View File
@@ -47,11 +47,7 @@ export function getDataOrNull<T>(
return response.then((result) => getDataOrNull(result, ignoreErrors)); return response.then((result) => getDataOrNull(result, ignoreErrors));
} }
if (response.error) { return ignoreDataFetcherErrors(response, ignoreErrors).data ?? null;
if (ignoreErrors.includes(response.error.code)) return null;
throw new DataFetcherError(response.error.message, response.error.code);
}
return response.data;
} }
/** /**
@@ -93,6 +89,34 @@ export async function wrapDataFetcherError<T>(
} }
} }
/**
* Ignore some data fetcher errors.
*/
export function ignoreDataFetcherErrors<T>(
response: DataFetcherResponse<T>,
ignoreErrors?: number[]
): DataFetcherResponse<T>;
export function ignoreDataFetcherErrors<T>(
response: Promise<DataFetcherResponse<T>>,
ignoreErrors?: number[]
): Promise<DataFetcherResponse<T>>;
export function ignoreDataFetcherErrors<T>(
response: DataFetcherResponse<T> | Promise<DataFetcherResponse<T>>,
ignoreErrors: number[] = [404]
): DataFetcherResponse<T> | Promise<DataFetcherResponse<T>> {
if (response instanceof Promise) {
return response.then((result) => ignoreDataFetcherErrors(result, ignoreErrors));
}
if (response.error) {
if (ignoreErrors.includes(response.error.code)) {
return response;
}
throw new DataFetcherError(response.error.message, response.error.code);
}
return response;
}
/** /**
* Get a data fetcher exposable error from a JS error. * Get a data fetcher exposable error from a JS error.
*/ */
@@ -5,8 +5,8 @@ import { GITBOOK_INTEGRATIONS_HOST } from '@v2/lib/env';
import type { BlockProps } from '../Block'; import type { BlockProps } from '../Block';
import './contentkit.css'; import './contentkit.css';
import { getDataOrNull } from '@v2/lib/data';
import { contentKitServerContext } from './contentkit'; import { contentKitServerContext } from './contentkit';
import { fetchSafeIntegrationUI } from './render';
import { renderIntegrationUi } from './server-actions'; import { renderIntegrationUi } from './server-actions';
export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegration>) { export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegration>) {
@@ -16,8 +16,6 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
throw new Error('integration block requires a content.spaceId'); throw new Error('integration block requires a content.spaceId');
} }
const { dataFetcher } = context.contentContext;
const initialInput: RenderIntegrationUI = { const initialInput: RenderIntegrationUI = {
componentId: block.data.block, componentId: block.data.block,
props: block.data.props, props: block.data.props,
@@ -30,17 +28,27 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
}, },
}; };
const initialOutput = await getDataOrNull( const initialResponse = await fetchSafeIntegrationUI(context.contentContext, {
dataFetcher.renderIntegrationUi({ integrationName: block.data.integration,
integrationName: block.data.integration, request: initialInput,
request: initialInput, });
}),
// The API can respond with a 400 error if the integration is not installed if (initialResponse.error) {
// and 404 if the integration is not found. if (initialResponse.error.code === 404) {
[404, 400] return null;
); }
if (!initialOutput || initialOutput.type === 'complete') {
return (
<div className={tcls(style)}>
<pre>
Unexpected error with integration {block.data.integration}:{' '}
{initialResponse.error.message}
</pre>
</div>
);
}
const initialOutput = initialResponse.data;
if (initialOutput.type === 'complete') {
return null; return null;
} }
@@ -0,0 +1,31 @@
import type { RenderIntegrationUI } from '@gitbook/api';
import type { GitBookBaseContext } from '@v2/lib/context';
import { ignoreDataFetcherErrors } from '@v2/lib/data';
/**
* Render an integration UI while ignoring some errors.
*/
export async function fetchSafeIntegrationUI(
context: GitBookBaseContext,
{
integrationName,
request,
}: {
integrationName: string;
request: RenderIntegrationUI;
}
) {
const output = await ignoreDataFetcherErrors(
context.dataFetcher.renderIntegrationUi({
integrationName,
request,
}),
// The API can respond with a 400 error if the integration is not installed
// and 404 if the integration is not found.
// The API can also respond with a 502 error if the integration is not generating a proper response.
[404, 400, 502]
);
return output;
}
@@ -4,9 +4,9 @@ import { getV1BaseContext } from '@/lib/v1';
import { isV2 } from '@/lib/v2'; import { isV2 } from '@/lib/v2';
import type { RenderIntegrationUI } from '@gitbook/api'; import type { RenderIntegrationUI } from '@gitbook/api';
import { ContentKitOutput } from '@gitbook/react-contentkit'; import { ContentKitOutput } from '@gitbook/react-contentkit';
import { throwIfDataError } from '@v2/lib/data';
import { getServerActionBaseContext } from '@v2/lib/server-actions'; import { getServerActionBaseContext } from '@v2/lib/server-actions';
import { contentKitServerContext } from './contentkit'; import { contentKitServerContext } from './contentkit';
import { fetchSafeIntegrationUI } from './render';
/** /**
* Server action to render an integration UI request from <ContentKit />. * Server action to render an integration UI request from <ContentKit />.
@@ -22,16 +22,19 @@ export async function renderIntegrationUi({
request: RenderIntegrationUI; request: RenderIntegrationUI;
}) { }) {
const serverAction = isV2() ? await getServerActionBaseContext() : await getV1BaseContext(); const serverAction = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
const output = await fetchSafeIntegrationUI(serverAction, {
integrationName: renderContext.integrationName,
request,
});
const output = await throwIfDataError( if (output.error) {
serverAction.dataFetcher.renderIntegrationUi({ return {
integrationName: renderContext.integrationName, error: output.error.message,
request, };
}) }
);
return { return {
children: <ContentKitOutput output={output} context={contentKitServerContext} />, children: <ContentKitOutput output={output.data} context={contentKitServerContext} />,
output: output, output: output.data,
}; };
} }
+26 -15
View File
@@ -38,10 +38,18 @@ export function ContentKit<RenderContext>(props: {
render: (input: { render: (input: {
renderContext: RenderContext; renderContext: RenderContext;
request: RequestRenderIntegrationUI; request: RequestRenderIntegrationUI;
}) => Promise<{ }) => Promise<
children: React.ReactNode; | {
output: ContentKitRenderOutput; error?: undefined;
}>; children: React.ReactNode;
output: ContentKitRenderOutput;
}
| {
error: string;
children?: undefined;
output?: undefined;
}
>;
/** Callback when an action is triggered */ /** Callback when an action is triggered */
onAction?: (action: ContentKitAction) => void; onAction?: (action: ContentKitAction) => void;
/** Callback when the flow is completed */ /** Callback when the flow is completed */
@@ -98,17 +106,18 @@ export function ContentKit<RenderContext>(props: {
request: newInput, request: newInput,
}); });
const output = result.output; const output = result.output;
if (output) {
if (output.type === 'complete') {
return onComplete?.(output.returnValue);
}
if (output.type === 'complete') { setCurrent((prev) => ({
return onComplete?.(output.returnValue); input: newInput,
children: result.children,
output: output,
state: prev.state,
}));
} }
setCurrent((prev) => ({
input: newInput,
children: result.children,
output: output,
state: prev.state,
}));
}, },
[setCurrent, current, render, onComplete] [setCurrent, current, render, onComplete]
); );
@@ -147,8 +156,10 @@ export function ContentKit<RenderContext>(props: {
renderContext, renderContext,
request: modalInput, request: modalInput,
}); });
if (
if (result.output.type === 'element' || !result.output.type) { result.output &&
(result.output.type === 'element' || !result.output.type)
) {
setSubView({ setSubView({
mode: 'modal', mode: 'modal',
initialInput: modalInput, initialInput: modalInput,