Skip to content

Commit 11a6511

Browse files
authored
Fix crash when integration script fails to render block (#3332)
1 parent af98402 commit 11a6511

File tree

7 files changed

+129
-42
lines changed

7 files changed

+129
-42
lines changed

.changeset/famous-melons-compete.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"gitbook": patch
3+
---
4+
5+
Fix crash when integration script fails to render block.

.changeset/orange-ears-drop.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@gitbook/react-contentkit": patch
3+
---
4+
5+
Add basic error handling when transitioning between states.

packages/gitbook-v2/src/lib/data/errors.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,7 @@ export function getDataOrNull<T>(
4747
return response.then((result) => getDataOrNull(result, ignoreErrors));
4848
}
4949

50-
if (response.error) {
51-
if (ignoreErrors.includes(response.error.code)) return null;
52-
throw new DataFetcherError(response.error.message, response.error.code);
53-
}
54-
return response.data;
50+
return ignoreDataFetcherErrors(response, ignoreErrors).data ?? null;
5551
}
5652

5753
/**
@@ -93,6 +89,34 @@ export async function wrapDataFetcherError<T>(
9389
}
9490
}
9591

92+
/**
93+
* Ignore some data fetcher errors.
94+
*/
95+
export function ignoreDataFetcherErrors<T>(
96+
response: DataFetcherResponse<T>,
97+
ignoreErrors?: number[]
98+
): DataFetcherResponse<T>;
99+
export function ignoreDataFetcherErrors<T>(
100+
response: Promise<DataFetcherResponse<T>>,
101+
ignoreErrors?: number[]
102+
): Promise<DataFetcherResponse<T>>;
103+
export function ignoreDataFetcherErrors<T>(
104+
response: DataFetcherResponse<T> | Promise<DataFetcherResponse<T>>,
105+
ignoreErrors: number[] = [404]
106+
): DataFetcherResponse<T> | Promise<DataFetcherResponse<T>> {
107+
if (response instanceof Promise) {
108+
return response.then((result) => ignoreDataFetcherErrors(result, ignoreErrors));
109+
}
110+
111+
if (response.error) {
112+
if (ignoreErrors.includes(response.error.code)) {
113+
return response;
114+
}
115+
throw new DataFetcherError(response.error.message, response.error.code);
116+
}
117+
return response;
118+
}
119+
96120
/**
97121
* Get a data fetcher exposable error from a JS error.
98122
*/

packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { GITBOOK_INTEGRATIONS_HOST } from '@v2/lib/env';
55

66
import type { BlockProps } from '../Block';
77
import './contentkit.css';
8-
import { getDataOrNull } from '@v2/lib/data';
98
import { contentKitServerContext } from './contentkit';
9+
import { fetchSafeIntegrationUI } from './render';
1010
import { renderIntegrationUi } from './server-actions';
1111

1212
export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegration>) {
@@ -16,8 +16,6 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
1616
throw new Error('integration block requires a content.spaceId');
1717
}
1818

19-
const { dataFetcher } = context.contentContext;
20-
2119
const initialInput: RenderIntegrationUI = {
2220
componentId: block.data.block,
2321
props: block.data.props,
@@ -30,17 +28,27 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
3028
},
3129
};
3230

33-
const initialOutput = await getDataOrNull(
34-
dataFetcher.renderIntegrationUi({
35-
integrationName: block.data.integration,
36-
request: initialInput,
37-
}),
31+
const initialResponse = await fetchSafeIntegrationUI(context.contentContext, {
32+
integrationName: block.data.integration,
33+
request: initialInput,
34+
});
3835

39-
// The API can respond with a 400 error if the integration is not installed
40-
// and 404 if the integration is not found.
41-
[404, 400]
42-
);
43-
if (!initialOutput || initialOutput.type === 'complete') {
36+
if (initialResponse.error) {
37+
if (initialResponse.error.code === 404) {
38+
return null;
39+
}
40+
41+
return (
42+
<div className={tcls(style)}>
43+
<pre>
44+
Unexpected error with integration {block.data.integration}:{' '}
45+
{initialResponse.error.message}
46+
</pre>
47+
</div>
48+
);
49+
}
50+
const initialOutput = initialResponse.data;
51+
if (initialOutput.type === 'complete') {
4452
return null;
4553
}
4654

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { RenderIntegrationUI } from '@gitbook/api';
2+
import type { GitBookBaseContext } from '@v2/lib/context';
3+
import { ignoreDataFetcherErrors } from '@v2/lib/data';
4+
5+
/**
6+
* Render an integration UI while ignoring some errors.
7+
*/
8+
export async function fetchSafeIntegrationUI(
9+
context: GitBookBaseContext,
10+
{
11+
integrationName,
12+
request,
13+
}: {
14+
integrationName: string;
15+
request: RenderIntegrationUI;
16+
}
17+
) {
18+
const output = await ignoreDataFetcherErrors(
19+
context.dataFetcher.renderIntegrationUi({
20+
integrationName,
21+
request,
22+
}),
23+
24+
// The API can respond with a 400 error if the integration is not installed
25+
// and 404 if the integration is not found.
26+
// The API can also respond with a 502 error if the integration is not generating a proper response.
27+
[404, 400, 502]
28+
);
29+
30+
return output;
31+
}

packages/gitbook/src/components/DocumentView/Integration/server-actions.tsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import { getV1BaseContext } from '@/lib/v1';
44
import { isV2 } from '@/lib/v2';
55
import type { RenderIntegrationUI } from '@gitbook/api';
66
import { ContentKitOutput } from '@gitbook/react-contentkit';
7-
import { throwIfDataError } from '@v2/lib/data';
87
import { getServerActionBaseContext } from '@v2/lib/server-actions';
98
import { contentKitServerContext } from './contentkit';
9+
import { fetchSafeIntegrationUI } from './render';
1010

1111
/**
1212
* Server action to render an integration UI request from <ContentKit />.
@@ -22,16 +22,19 @@ export async function renderIntegrationUi({
2222
request: RenderIntegrationUI;
2323
}) {
2424
const serverAction = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
25+
const output = await fetchSafeIntegrationUI(serverAction, {
26+
integrationName: renderContext.integrationName,
27+
request,
28+
});
2529

26-
const output = await throwIfDataError(
27-
serverAction.dataFetcher.renderIntegrationUi({
28-
integrationName: renderContext.integrationName,
29-
request,
30-
})
31-
);
30+
if (output.error) {
31+
return {
32+
error: output.error.message,
33+
};
34+
}
3235

3336
return {
34-
children: <ContentKitOutput output={output} context={contentKitServerContext} />,
35-
output: output,
37+
children: <ContentKitOutput output={output.data} context={contentKitServerContext} />,
38+
output: output.data,
3639
};
3740
}

packages/react-contentkit/src/ContentKit.tsx

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,18 @@ export function ContentKit<RenderContext>(props: {
3838
render: (input: {
3939
renderContext: RenderContext;
4040
request: RequestRenderIntegrationUI;
41-
}) => Promise<{
42-
children: React.ReactNode;
43-
output: ContentKitRenderOutput;
44-
}>;
41+
}) => Promise<
42+
| {
43+
error?: undefined;
44+
children: React.ReactNode;
45+
output: ContentKitRenderOutput;
46+
}
47+
| {
48+
error: string;
49+
children?: undefined;
50+
output?: undefined;
51+
}
52+
>;
4553
/** Callback when an action is triggered */
4654
onAction?: (action: ContentKitAction) => void;
4755
/** Callback when the flow is completed */
@@ -98,17 +106,18 @@ export function ContentKit<RenderContext>(props: {
98106
request: newInput,
99107
});
100108
const output = result.output;
109+
if (output) {
110+
if (output.type === 'complete') {
111+
return onComplete?.(output.returnValue);
112+
}
101113

102-
if (output.type === 'complete') {
103-
return onComplete?.(output.returnValue);
114+
setCurrent((prev) => ({
115+
input: newInput,
116+
children: result.children,
117+
output: output,
118+
state: prev.state,
119+
}));
104120
}
105-
106-
setCurrent((prev) => ({
107-
input: newInput,
108-
children: result.children,
109-
output: output,
110-
state: prev.state,
111-
}));
112121
},
113122
[setCurrent, current, render, onComplete]
114123
);
@@ -147,8 +156,10 @@ export function ContentKit<RenderContext>(props: {
147156
renderContext,
148157
request: modalInput,
149158
});
150-
151-
if (result.output.type === 'element' || !result.output.type) {
159+
if (
160+
result.output &&
161+
(result.output.type === 'element' || !result.output.type)
162+
) {
152163
setSubView({
153164
mode: 'modal',
154165
initialInput: modalInput,

0 commit comments

Comments
 (0)