Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/light-candles-trade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'gitbook': patch
---

Improve perception of fast loading by not rendering skeletons for individual blocks in the top part of the viewport
5 changes: 5 additions & 0 deletions .changeset/purple-pugs-attend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'gitbook': patch
---

Fix flickering when displaying an "Ask" answer with code blocks
6 changes: 3 additions & 3 deletions packages/gitbook/src/components/Ads/AdClassicRendering.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { AdItem } from './types';
/**
* Classic rendering for an ad.
*/
export async function AdClassicRendering({ ad }: { ad: AdItem }) {
export function AdClassicRendering({ ad }: { ad: AdItem }) {
return (
<a
className={tcls(
Expand All @@ -33,7 +33,7 @@ export async function AdClassicRendering({ ad }: { ad: AdItem }) {
<img
alt="Ads logo"
className={tcls('rounded-md')}
src={await getResizedImageURL(ad.smallImage, { width: 192, dpr: 2 })}
src={getResizedImageURL(ad.smallImage, { width: 192, dpr: 2 })}
/>
</div>
) : (
Expand All @@ -43,7 +43,7 @@ export async function AdClassicRendering({ ad }: { ad: AdItem }) {
>
<img
alt="Ads logo"
src={await getResizedImageURL(ad.logo, { width: 192 - 48, dpr: 2 })}
src={getResizedImageURL(ad.logo, { width: 192 - 48, dpr: 2 })}
/>
</div>
)}
Expand Down
4 changes: 2 additions & 2 deletions packages/gitbook/src/components/Ads/AdCoverRendering.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import { AdCover } from './types';
/**
* Cover rendering for an ad.
*/
export async function AdCoverRendering({ ad }: { ad: AdCover }) {
const largeImage = await getResizedImageURL(ad.largeImage, { width: 128, dpr: 2 });
export function AdCoverRendering({ ad }: { ad: AdCover }) {
const largeImage = getResizedImageURL(ad.largeImage, { width: 128, dpr: 2 });

return (
<a
Expand Down
69 changes: 40 additions & 29 deletions packages/gitbook/src/components/DocumentView/Block.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { DocumentBlock, JSONDocument } from '@gitbook/api';
import assertNever from 'assert-never';
import React from 'react';

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

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

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

const content = (() => {
switch (block.type) {
case 'paragraph':
return <Paragraph {...props} {...contextProps} block={block} />;
return <Paragraph {...props} block={block} />;
case 'heading-1':
case 'heading-2':
case 'heading-3':
return <Heading {...props} {...contextProps} block={block} />;
return <Heading {...props} block={block} />;
case 'list-ordered':
return <ListOrdered {...props} {...contextProps} block={block} />;
return <ListOrdered {...props} block={block} />;
case 'list-unordered':
return <ListUnordered {...props} {...contextProps} block={block} />;
return <ListUnordered {...props} block={block} />;
case 'list-tasks':
return <ListTasks {...props} {...contextProps} block={block} />;
return <ListTasks {...props} block={block} />;
case 'list-item':
return <ListItem {...props} {...contextProps} block={block} />;
return <ListItem {...props} block={block} />;
case 'code':
return <CodeBlock {...props} {...contextProps} block={block} />;
return <CodeBlock {...props} block={block} />;
case 'hint':
return <Hint {...props} {...contextProps} block={block} />;
return <Hint {...props} block={block} />;
case 'images':
return <Images {...props} {...contextProps} block={block} />;
return <Images {...props} block={block} />;
case 'tabs':
return <Tabs {...props} {...contextProps} block={block} />;
return <Tabs {...props} block={block} />;
case 'expandable':
return <Expandable {...props} {...contextProps} block={block} />;
return <Expandable {...props} block={block} />;
case 'table':
return <Table {...props} {...contextProps} block={block} />;
return <Table {...props} block={block} />;
case 'swagger':
return <OpenAPI {...props} {...contextProps} block={block} />;
return <OpenAPI {...props} block={block} />;
case 'embed':
return <Embed {...props} {...contextProps} block={block} />;
return <Embed {...props} block={block} />;
case 'blockquote':
return <Quote {...props} {...contextProps} block={block} />;
return <Quote {...props} block={block} />;
case 'math':
return <BlockMath {...props} {...contextProps} block={block} />;
return <BlockMath {...props} block={block} />;
case 'file':
return <File {...props} {...contextProps} block={block} />;
return <File {...props} block={block} />;
case 'divider':
return <Divider {...props} {...contextProps} block={block} />;
return <Divider {...props} block={block} />;
case 'drawing':
return <Drawing {...props} {...contextProps} block={block} />;
return <Drawing {...props} block={block} />;
case 'content-ref':
return <BlockContentRef {...props} {...contextProps} block={block} />;
return <BlockContentRef {...props} block={block} />;
case 'image':
case 'code-line':
case 'tabs-item':
throw new Error('Blocks should be directly rendered by parent');
case 'integration':
return <IntegrationBlock {...props} {...contextProps} block={block} />;
return <IntegrationBlock {...props} block={block} />;
case 'synced-block':
return <BlockSyncedBlock {...props} {...contextProps} block={block} />;
return <BlockSyncedBlock {...props} block={block} />;
case 'reusable-content':
return <ReusableContent {...props} {...contextProps} block={block} />;
return <ReusableContent {...props} block={block} />;
case 'stepper':
return <Stepper {...props} {...contextProps} block={block} />;
return <Stepper {...props} block={block} />;
case 'stepper-step':
return <StepperStep {...props} {...contextProps} block={block} />;
return <StepperStep {...props} block={block} />;
default:
return nullIfNever(block);
}
})();

if (!isEstimatedOffscreen || context.wrapBlocksInSuspense === false) {
// When blocks are estimated to be on the initial viewport, we render them immediately
// to avoid a flash of a loading skeleton.
return content;
}

return (
<React.Suspense fallback={<BlockPlaceholder block={block} style={style} />}>
<React.Suspense fallback={<BlockSkeleton block={block} style={style} />}>
{content}
</React.Suspense>
);
}

function BlockPlaceholder(props: { block: DocumentBlock; style: ClassValue }) {
/**
* Skeleton for a block while it is being loaded.
*/
export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }) {
const { block, style } = props;
const id = 'meta' in block && block.meta && 'id' in block.meta ? block.meta.id : undefined;

Expand Down
42 changes: 28 additions & 14 deletions packages/gitbook/src/components/DocumentView/Blocks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { tcls, ClassValue } from '@/lib/tailwind';

import { Block } from './Block';
import { DocumentContextProps } from './DocumentView';
import { isBlockOffscreen } from './utils';

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

let isOffscreen = false;

return (
<>
{nodes.map((node) => (
<Block
key={node.key}
block={node}
style={[
'w-full mx-auto decoration-primary/6',
node.data && 'fullWidth' in node.data && node.data.fullWidth
? 'max-w-screen-xl'
: 'max-w-3xl',
blockStyle,
]}
{...contextProps}
/>
))}
{nodes.map((node) => {
isOffscreen =
isOffscreen ||
isBlockOffscreen({
document: props.document,
block: node,
ancestorBlocks: props.ancestorBlocks,
});

return (
<Block
key={node.key}
block={node}
style={[
'w-full mx-auto decoration-primary/6',
node.data && 'fullWidth' in node.data && node.data.fullWidth
? 'max-w-screen-xl'
: 'max-w-3xl',
blockStyle,
]}
isEstimatedOffscreen={isOffscreen}
{...contextProps}
/>
);
})}
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export function PlainCodeBlock(props: { code: string; syntax: string }) {
}}
block={block}
ancestorBlocks={[]}
isEstimatedOffscreen={false}
/>
);
}
32 changes: 32 additions & 0 deletions packages/gitbook/src/components/DocumentView/DocumentView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ContentTarget } from '@/lib/api';
import { ContentRefContext, ResolveContentRefOptions, ResolvedContentRef } from '@/lib/references';
import { ClassValue } from '@/lib/tailwind';

import { BlockSkeleton } from './Block';
import { Blocks } from './Blocks';

export interface DocumentContext {
Expand Down Expand Up @@ -46,6 +47,12 @@ export interface DocumentContext {
* https://linear.app/gitbook-x/issue/RND-3588/gitbook-open-code-syntax-highlighting-runs-out-of-memory-after-a
*/
shouldHighlightCode: (spaceId: string | undefined) => boolean;

/**
* True if the blocks should be wrapped in suspense boundary for isolated loading skeletons.
* @default true
*/
wrapBlocksInSuspense?: boolean;
}

export interface DocumentContextProps {
Expand Down Expand Up @@ -83,3 +90,28 @@ export function DocumentView(
/>
);
}

/**
* Placeholder for the entire document layout.
*/
export function DocumentViewSkeleton(props: { document: JSONDocument; blockStyle: ClassValue }) {
const { document, blockStyle } = props;

return (
<div className="flex flex-col gap-4">
{document.nodes.map((block, index) => (
<BlockSkeleton
key={block.key!}
block={block}
style={[
'w-full mx-auto decoration-primary/6',
block.data && 'fullWidth' in block.data && block.data.fullWidth
? 'max-w-screen-xl'
: 'max-w-3xl',
blockStyle,
]}
/>
))}
</div>
);
}
12 changes: 5 additions & 7 deletions packages/gitbook/src/components/DocumentView/Images.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,10 @@ import { ClassValue, tcls } from '@/lib/tailwind';
import { BlockProps } from './Block';
import { Caption } from './Caption';
import { DocumentContext } from './DocumentView';
import { isBlockOffscreen } from './utils';

export function Images(props: BlockProps<DocumentBlockImages>) {
const { document, block, ancestorBlocks, style, context } = props;
const { document, block, ancestorBlocks, style, context, isEstimatedOffscreen } = props;

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

Expand All @@ -41,7 +39,7 @@ export function Images(props: BlockProps<DocumentBlockImages>) {
style={[]}
siblings={block.nodes.length}
context={context}
isOffscreen={isOffscreen}
isEstimatedOffscreen={isEstimatedOffscreen}
/>
))}
</div>
Expand All @@ -67,9 +65,9 @@ async function ImageBlock(props: {
style: ClassValue;
context: DocumentContext;
siblings: number;
isOffscreen: boolean;
isEstimatedOffscreen: boolean;
}) {
const { block, context, isOffscreen } = props;
const { block, context, isEstimatedOffscreen } = props;

const [src, darkSrc] = await Promise.all([
context.resolveContentRef(block.data.ref),
Expand Down Expand Up @@ -97,7 +95,7 @@ async function ImageBlock(props: {
}
: null,
}}
priority={isOffscreen ? 'lazy' : 'high'}
priority={isEstimatedOffscreen ? 'lazy' : 'high'}
preload
zoom
inlineStyle={{
Expand Down
37 changes: 23 additions & 14 deletions packages/gitbook/src/components/PageBody/PageBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { PageFooterNavigation } from './PageFooterNavigation';
import { PageHeader } from './PageHeader';
import { PreservePageLayout } from './PreservePageLayout';
import { TrackPageView } from './TrackPageView';
import { DocumentView, createHighlightingContext } from '../DocumentView';
import { DocumentView, DocumentViewSkeleton, createHighlightingContext } from '../DocumentView';
import { PageFeedbackForm } from '../PageFeedback';
import { DateRelative } from '../primitives';

Expand Down Expand Up @@ -82,19 +82,28 @@ export function PageBody(props: {

<PageHeader page={page} />
{document && !isNodeEmpty(document) ? (
<DocumentView
document={document}
style={['[&>*+*]:mt-5', 'grid']}
blockStyle={['page-api-block:ml-0']}
context={{
mode: 'default',
content: contentTarget,
contentRefContext: context,
resolveContentRef: (ref, options) =>
resolveContentRef(ref, context, options),
shouldHighlightCode,
}}
/>
<React.Suspense
fallback={
<DocumentViewSkeleton
document={document}
blockStyle={['page-api-block:ml-0']}
/>
}
>
<DocumentView
document={document}
style={['[&>*+*]:mt-5', 'grid']}
blockStyle={['page-api-block:ml-0']}
context={{
mode: 'default',
content: contentTarget,
contentRefContext: context,
resolveContentRef: (ref, options) =>
resolveContentRef(ref, context, options),
shouldHighlightCode,
}}
/>
</React.Suspense>
) : (
<PageBodyBlankslate page={page} rootPages={context.pages} context={context} />
)}
Expand Down
Loading
Loading