Skip to content

Commit e3a3d6a

Browse files
authored
Improve perf perception and fix flickering in Ask answer (#2534)
1 parent 175171d commit e3a3d6a

File tree

15 files changed

+303
-188
lines changed

15 files changed

+303
-188
lines changed

.changeset/light-candles-trade.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+
Improve perception of fast loading by not rendering skeletons for individual blocks in the top part of the viewport

.changeset/purple-pugs-attend.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 flickering when displaying an "Ask" answer with code blocks

packages/gitbook/src/components/Ads/AdClassicRendering.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { AdItem } from './types';
88
/**
99
* Classic rendering for an ad.
1010
*/
11-
export async function AdClassicRendering({ ad }: { ad: AdItem }) {
11+
export function AdClassicRendering({ ad }: { ad: AdItem }) {
1212
return (
1313
<a
1414
className={tcls(
@@ -33,7 +33,7 @@ export async function AdClassicRendering({ ad }: { ad: AdItem }) {
3333
<img
3434
alt="Ads logo"
3535
className={tcls('rounded-md')}
36-
src={await getResizedImageURL(ad.smallImage, { width: 192, dpr: 2 })}
36+
src={getResizedImageURL(ad.smallImage, { width: 192, dpr: 2 })}
3737
/>
3838
</div>
3939
) : (
@@ -43,7 +43,7 @@ export async function AdClassicRendering({ ad }: { ad: AdItem }) {
4343
>
4444
<img
4545
alt="Ads logo"
46-
src={await getResizedImageURL(ad.logo, { width: 192 - 48, dpr: 2 })}
46+
src={getResizedImageURL(ad.logo, { width: 192 - 48, dpr: 2 })}
4747
/>
4848
</div>
4949
)}

packages/gitbook/src/components/Ads/AdCoverRendering.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import { AdCover } from './types';
99
/**
1010
* Cover rendering for an ad.
1111
*/
12-
export async function AdCoverRendering({ ad }: { ad: AdCover }) {
13-
const largeImage = await getResizedImageURL(ad.largeImage, { width: 128, dpr: 2 });
12+
export function AdCoverRendering({ ad }: { ad: AdCover }) {
13+
const largeImage = getResizedImageURL(ad.largeImage, { width: 128, dpr: 2 });
1414

1515
return (
1616
<a

packages/gitbook/src/components/DocumentView/Block.tsx

Lines changed: 40 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { DocumentBlock, JSONDocument } from '@gitbook/api';
2-
import assertNever from 'assert-never';
32
import React from 'react';
43

54
import {
@@ -42,6 +41,9 @@ export interface BlockProps<Block extends DocumentBlock> extends DocumentContext
4241
block: Block;
4342
document: JSONDocument;
4443
ancestorBlocks: DocumentBlock[];
44+
/** If true, we estimate that the block will be outside the initial viewport */
45+
isEstimatedOffscreen: boolean;
46+
/** Class names to be passed to the underlying DOM element */
4547
style?: ClassValue;
4648
}
4749

@@ -53,79 +55,88 @@ function nullIfNever(value: never): null {
5355
}
5456

5557
export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
56-
const { block, style, ...contextProps } = props;
58+
const { block, style, isEstimatedOffscreen, context } = props;
5759

5860
const content = (() => {
5961
switch (block.type) {
6062
case 'paragraph':
61-
return <Paragraph {...props} {...contextProps} block={block} />;
63+
return <Paragraph {...props} block={block} />;
6264
case 'heading-1':
6365
case 'heading-2':
6466
case 'heading-3':
65-
return <Heading {...props} {...contextProps} block={block} />;
67+
return <Heading {...props} block={block} />;
6668
case 'list-ordered':
67-
return <ListOrdered {...props} {...contextProps} block={block} />;
69+
return <ListOrdered {...props} block={block} />;
6870
case 'list-unordered':
69-
return <ListUnordered {...props} {...contextProps} block={block} />;
71+
return <ListUnordered {...props} block={block} />;
7072
case 'list-tasks':
71-
return <ListTasks {...props} {...contextProps} block={block} />;
73+
return <ListTasks {...props} block={block} />;
7274
case 'list-item':
73-
return <ListItem {...props} {...contextProps} block={block} />;
75+
return <ListItem {...props} block={block} />;
7476
case 'code':
75-
return <CodeBlock {...props} {...contextProps} block={block} />;
77+
return <CodeBlock {...props} block={block} />;
7678
case 'hint':
77-
return <Hint {...props} {...contextProps} block={block} />;
79+
return <Hint {...props} block={block} />;
7880
case 'images':
79-
return <Images {...props} {...contextProps} block={block} />;
81+
return <Images {...props} block={block} />;
8082
case 'tabs':
81-
return <Tabs {...props} {...contextProps} block={block} />;
83+
return <Tabs {...props} block={block} />;
8284
case 'expandable':
83-
return <Expandable {...props} {...contextProps} block={block} />;
85+
return <Expandable {...props} block={block} />;
8486
case 'table':
85-
return <Table {...props} {...contextProps} block={block} />;
87+
return <Table {...props} block={block} />;
8688
case 'swagger':
87-
return <OpenAPI {...props} {...contextProps} block={block} />;
89+
return <OpenAPI {...props} block={block} />;
8890
case 'embed':
89-
return <Embed {...props} {...contextProps} block={block} />;
91+
return <Embed {...props} block={block} />;
9092
case 'blockquote':
91-
return <Quote {...props} {...contextProps} block={block} />;
93+
return <Quote {...props} block={block} />;
9294
case 'math':
93-
return <BlockMath {...props} {...contextProps} block={block} />;
95+
return <BlockMath {...props} block={block} />;
9496
case 'file':
95-
return <File {...props} {...contextProps} block={block} />;
97+
return <File {...props} block={block} />;
9698
case 'divider':
97-
return <Divider {...props} {...contextProps} block={block} />;
99+
return <Divider {...props} block={block} />;
98100
case 'drawing':
99-
return <Drawing {...props} {...contextProps} block={block} />;
101+
return <Drawing {...props} block={block} />;
100102
case 'content-ref':
101-
return <BlockContentRef {...props} {...contextProps} block={block} />;
103+
return <BlockContentRef {...props} block={block} />;
102104
case 'image':
103105
case 'code-line':
104106
case 'tabs-item':
105107
throw new Error('Blocks should be directly rendered by parent');
106108
case 'integration':
107-
return <IntegrationBlock {...props} {...contextProps} block={block} />;
109+
return <IntegrationBlock {...props} block={block} />;
108110
case 'synced-block':
109-
return <BlockSyncedBlock {...props} {...contextProps} block={block} />;
111+
return <BlockSyncedBlock {...props} block={block} />;
110112
case 'reusable-content':
111-
return <ReusableContent {...props} {...contextProps} block={block} />;
113+
return <ReusableContent {...props} block={block} />;
112114
case 'stepper':
113-
return <Stepper {...props} {...contextProps} block={block} />;
115+
return <Stepper {...props} block={block} />;
114116
case 'stepper-step':
115-
return <StepperStep {...props} {...contextProps} block={block} />;
117+
return <StepperStep {...props} block={block} />;
116118
default:
117119
return nullIfNever(block);
118120
}
119121
})();
120122

123+
if (!isEstimatedOffscreen || context.wrapBlocksInSuspense === false) {
124+
// When blocks are estimated to be on the initial viewport, we render them immediately
125+
// to avoid a flash of a loading skeleton.
126+
return content;
127+
}
128+
121129
return (
122-
<React.Suspense fallback={<BlockPlaceholder block={block} style={style} />}>
130+
<React.Suspense fallback={<BlockSkeleton block={block} style={style} />}>
123131
{content}
124132
</React.Suspense>
125133
);
126134
}
127135

128-
function BlockPlaceholder(props: { block: DocumentBlock; style: ClassValue }) {
136+
/**
137+
* Skeleton for a block while it is being loaded.
138+
*/
139+
export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }) {
129140
const { block, style } = props;
130141
const id = 'meta' in block && block.meta && 'id' in block.meta ? block.meta.id : undefined;
131142

packages/gitbook/src/components/DocumentView/Blocks.tsx

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { tcls, ClassValue } from '@/lib/tailwind';
44

55
import { Block } from './Block';
66
import { DocumentContextProps } from './DocumentView';
7+
import { isBlockOffscreen } from './utils';
78

89
/**
910
* Renders a list of blocks with a wrapper element.
@@ -49,22 +50,35 @@ type UnwrappedBlocksProps<TBlock extends DocumentBlock> = DocumentContextProps &
4950
export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBlocksProps<TBlock>) {
5051
const { nodes, blockStyle, ...contextProps } = props;
5152

53+
let isOffscreen = false;
54+
5255
return (
5356
<>
54-
{nodes.map((node) => (
55-
<Block
56-
key={node.key}
57-
block={node}
58-
style={[
59-
'w-full mx-auto decoration-primary/6',
60-
node.data && 'fullWidth' in node.data && node.data.fullWidth
61-
? 'max-w-screen-xl'
62-
: 'max-w-3xl',
63-
blockStyle,
64-
]}
65-
{...contextProps}
66-
/>
67-
))}
57+
{nodes.map((node) => {
58+
isOffscreen =
59+
isOffscreen ||
60+
isBlockOffscreen({
61+
document: props.document,
62+
block: node,
63+
ancestorBlocks: props.ancestorBlocks,
64+
});
65+
66+
return (
67+
<Block
68+
key={node.key}
69+
block={node}
70+
style={[
71+
'w-full mx-auto decoration-primary/6',
72+
node.data && 'fullWidth' in node.data && node.data.fullWidth
73+
? 'max-w-screen-xl'
74+
: 'max-w-3xl',
75+
blockStyle,
76+
]}
77+
isEstimatedOffscreen={isOffscreen}
78+
{...contextProps}
79+
/>
80+
);
81+
})}
6882
</>
6983
);
7084
}

packages/gitbook/src/components/DocumentView/CodeBlock/PlainCodeBlock.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export function PlainCodeBlock(props: { code: string; syntax: string }) {
5454
}}
5555
block={block}
5656
ancestorBlocks={[]}
57+
isEstimatedOffscreen={false}
5758
/>
5859
);
5960
}

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ContentTarget } from '@/lib/api';
44
import { ContentRefContext, ResolveContentRefOptions, ResolvedContentRef } from '@/lib/references';
55
import { ClassValue } from '@/lib/tailwind';
66

7+
import { BlockSkeleton } from './Block';
78
import { Blocks } from './Blocks';
89

910
export interface DocumentContext {
@@ -46,6 +47,12 @@ export interface DocumentContext {
4647
* https://linear.app/gitbook-x/issue/RND-3588/gitbook-open-code-syntax-highlighting-runs-out-of-memory-after-a
4748
*/
4849
shouldHighlightCode: (spaceId: string | undefined) => boolean;
50+
51+
/**
52+
* True if the blocks should be wrapped in suspense boundary for isolated loading skeletons.
53+
* @default true
54+
*/
55+
wrapBlocksInSuspense?: boolean;
4956
}
5057

5158
export interface DocumentContextProps {
@@ -83,3 +90,28 @@ export function DocumentView(
8390
/>
8491
);
8592
}
93+
94+
/**
95+
* Placeholder for the entire document layout.
96+
*/
97+
export function DocumentViewSkeleton(props: { document: JSONDocument; blockStyle: ClassValue }) {
98+
const { document, blockStyle } = props;
99+
100+
return (
101+
<div className="flex flex-col gap-4">
102+
{document.nodes.map((block, index) => (
103+
<BlockSkeleton
104+
key={block.key!}
105+
block={block}
106+
style={[
107+
'w-full mx-auto decoration-primary/6',
108+
block.data && 'fullWidth' in block.data && block.data.fullWidth
109+
? 'max-w-screen-xl'
110+
: 'max-w-3xl',
111+
blockStyle,
112+
]}
113+
/>
114+
))}
115+
</div>
116+
);
117+
}

packages/gitbook/src/components/DocumentView/Images.tsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,10 @@ import { ClassValue, tcls } from '@/lib/tailwind';
1111
import { BlockProps } from './Block';
1212
import { Caption } from './Caption';
1313
import { DocumentContext } from './DocumentView';
14-
import { isBlockOffscreen } from './utils';
1514

1615
export function Images(props: BlockProps<DocumentBlockImages>) {
17-
const { document, block, ancestorBlocks, style, context } = props;
16+
const { document, block, ancestorBlocks, style, context, isEstimatedOffscreen } = props;
1817

19-
const isOffscreen = isBlockOffscreen({ document, block, ancestorBlocks });
2018
const isMultipleImages = block.nodes.length > 1;
2119
const { align = 'center' } = block.data;
2220

@@ -41,7 +39,7 @@ export function Images(props: BlockProps<DocumentBlockImages>) {
4139
style={[]}
4240
siblings={block.nodes.length}
4341
context={context}
44-
isOffscreen={isOffscreen}
42+
isEstimatedOffscreen={isEstimatedOffscreen}
4543
/>
4644
))}
4745
</div>
@@ -67,9 +65,9 @@ async function ImageBlock(props: {
6765
style: ClassValue;
6866
context: DocumentContext;
6967
siblings: number;
70-
isOffscreen: boolean;
68+
isEstimatedOffscreen: boolean;
7169
}) {
72-
const { block, context, isOffscreen } = props;
70+
const { block, context, isEstimatedOffscreen } = props;
7371

7472
const [src, darkSrc] = await Promise.all([
7573
context.resolveContentRef(block.data.ref),
@@ -97,7 +95,7 @@ async function ImageBlock(props: {
9795
}
9896
: null,
9997
}}
100-
priority={isOffscreen ? 'lazy' : 'high'}
98+
priority={isEstimatedOffscreen ? 'lazy' : 'high'}
10199
preload
102100
zoom
103101
inlineStyle={{

packages/gitbook/src/components/PageBody/PageBody.tsx

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { PageFooterNavigation } from './PageFooterNavigation';
2121
import { PageHeader } from './PageHeader';
2222
import { PreservePageLayout } from './PreservePageLayout';
2323
import { TrackPageView } from './TrackPageView';
24-
import { DocumentView, createHighlightingContext } from '../DocumentView';
24+
import { DocumentView, DocumentViewSkeleton, createHighlightingContext } from '../DocumentView';
2525
import { PageFeedbackForm } from '../PageFeedback';
2626
import { DateRelative } from '../primitives';
2727

@@ -82,19 +82,28 @@ export function PageBody(props: {
8282

8383
<PageHeader page={page} />
8484
{document && !isNodeEmpty(document) ? (
85-
<DocumentView
86-
document={document}
87-
style={['[&>*+*]:mt-5', 'grid']}
88-
blockStyle={['page-api-block:ml-0']}
89-
context={{
90-
mode: 'default',
91-
content: contentTarget,
92-
contentRefContext: context,
93-
resolveContentRef: (ref, options) =>
94-
resolveContentRef(ref, context, options),
95-
shouldHighlightCode,
96-
}}
97-
/>
85+
<React.Suspense
86+
fallback={
87+
<DocumentViewSkeleton
88+
document={document}
89+
blockStyle={['page-api-block:ml-0']}
90+
/>
91+
}
92+
>
93+
<DocumentView
94+
document={document}
95+
style={['[&>*+*]:mt-5', 'grid']}
96+
blockStyle={['page-api-block:ml-0']}
97+
context={{
98+
mode: 'default',
99+
content: contentTarget,
100+
contentRefContext: context,
101+
resolveContentRef: (ref, options) =>
102+
resolveContentRef(ref, context, options),
103+
shouldHighlightCode,
104+
}}
105+
/>
106+
</React.Suspense>
98107
) : (
99108
<PageBodyBlankslate page={page} rootPages={context.pages} context={context} />
100109
)}

0 commit comments

Comments
 (0)