diff --git a/.changeset/famous-melons-compete.md b/.changeset/famous-melons-compete.md new file mode 100644 index 0000000000..17512b6f49 --- /dev/null +++ b/.changeset/famous-melons-compete.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix crash when integration script fails to render block. diff --git a/.changeset/orange-ears-drop.md b/.changeset/orange-ears-drop.md new file mode 100644 index 0000000000..12dd7fae5d --- /dev/null +++ b/.changeset/orange-ears-drop.md @@ -0,0 +1,5 @@ +--- +"@gitbook/react-contentkit": patch +--- + +Add basic error handling when transitioning between states. diff --git a/packages/gitbook-v2/src/lib/data/errors.ts b/packages/gitbook-v2/src/lib/data/errors.ts index 784eebdbb5..4059fb5c94 100644 --- a/packages/gitbook-v2/src/lib/data/errors.ts +++ b/packages/gitbook-v2/src/lib/data/errors.ts @@ -47,11 +47,7 @@ export function getDataOrNull( return response.then((result) => getDataOrNull(result, ignoreErrors)); } - if (response.error) { - if (ignoreErrors.includes(response.error.code)) return null; - throw new DataFetcherError(response.error.message, response.error.code); - } - return response.data; + return ignoreDataFetcherErrors(response, ignoreErrors).data ?? null; } /** @@ -93,6 +89,34 @@ export async function wrapDataFetcherError( } } +/** + * Ignore some data fetcher errors. + */ +export function ignoreDataFetcherErrors( + response: DataFetcherResponse, + ignoreErrors?: number[] +): DataFetcherResponse; +export function ignoreDataFetcherErrors( + response: Promise>, + ignoreErrors?: number[] +): Promise>; +export function ignoreDataFetcherErrors( + response: DataFetcherResponse | Promise>, + ignoreErrors: number[] = [404] +): DataFetcherResponse | Promise> { + 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. */ diff --git a/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx b/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx index 6ebec6f030..a26aa3bfd2 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx +++ b/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx @@ -5,8 +5,8 @@ import { GITBOOK_INTEGRATIONS_HOST } from '@v2/lib/env'; import type { BlockProps } from '../Block'; import './contentkit.css'; -import { getDataOrNull } from '@v2/lib/data'; import { contentKitServerContext } from './contentkit'; +import { fetchSafeIntegrationUI } from './render'; import { renderIntegrationUi } from './server-actions'; export async function IntegrationBlock(props: BlockProps) { @@ -16,8 +16,6 @@ export async function IntegrationBlock(props: BlockProps +
+                    Unexpected error with integration {block.data.integration}:{' '}
+                    {initialResponse.error.message}
+                
+ + ); + } + const initialOutput = initialResponse.data; + if (initialOutput.type === 'complete') { return null; } diff --git a/packages/gitbook/src/components/DocumentView/Integration/render.ts b/packages/gitbook/src/components/DocumentView/Integration/render.ts new file mode 100644 index 0000000000..a9a86c9e59 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Integration/render.ts @@ -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; +} diff --git a/packages/gitbook/src/components/DocumentView/Integration/server-actions.tsx b/packages/gitbook/src/components/DocumentView/Integration/server-actions.tsx index 54180e5c89..91d1e0e73c 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/server-actions.tsx +++ b/packages/gitbook/src/components/DocumentView/Integration/server-actions.tsx @@ -4,9 +4,9 @@ import { getV1BaseContext } from '@/lib/v1'; import { isV2 } from '@/lib/v2'; import type { RenderIntegrationUI } from '@gitbook/api'; import { ContentKitOutput } from '@gitbook/react-contentkit'; -import { throwIfDataError } from '@v2/lib/data'; import { getServerActionBaseContext } from '@v2/lib/server-actions'; import { contentKitServerContext } from './contentkit'; +import { fetchSafeIntegrationUI } from './render'; /** * Server action to render an integration UI request from . @@ -22,16 +22,19 @@ export async function renderIntegrationUi({ request: RenderIntegrationUI; }) { const serverAction = isV2() ? await getServerActionBaseContext() : await getV1BaseContext(); + const output = await fetchSafeIntegrationUI(serverAction, { + integrationName: renderContext.integrationName, + request, + }); - const output = await throwIfDataError( - serverAction.dataFetcher.renderIntegrationUi({ - integrationName: renderContext.integrationName, - request, - }) - ); + if (output.error) { + return { + error: output.error.message, + }; + } return { - children: , - output: output, + children: , + output: output.data, }; } diff --git a/packages/react-contentkit/src/ContentKit.tsx b/packages/react-contentkit/src/ContentKit.tsx index 9d53c70de2..4f413f367a 100644 --- a/packages/react-contentkit/src/ContentKit.tsx +++ b/packages/react-contentkit/src/ContentKit.tsx @@ -38,10 +38,18 @@ export function ContentKit(props: { render: (input: { renderContext: RenderContext; request: RequestRenderIntegrationUI; - }) => Promise<{ - children: React.ReactNode; - output: ContentKitRenderOutput; - }>; + }) => Promise< + | { + error?: undefined; + children: React.ReactNode; + output: ContentKitRenderOutput; + } + | { + error: string; + children?: undefined; + output?: undefined; + } + >; /** Callback when an action is triggered */ onAction?: (action: ContentKitAction) => void; /** Callback when the flow is completed */ @@ -98,17 +106,18 @@ export function ContentKit(props: { request: newInput, }); const output = result.output; + if (output) { + if (output.type === 'complete') { + return onComplete?.(output.returnValue); + } - if (output.type === 'complete') { - return onComplete?.(output.returnValue); + setCurrent((prev) => ({ + 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] ); @@ -147,8 +156,10 @@ export function ContentKit(props: { renderContext, request: modalInput, }); - - if (result.output.type === 'element' || !result.output.type) { + if ( + result.output && + (result.output.type === 'element' || !result.output.type) + ) { setSubView({ mode: 'modal', initialInput: modalInput,