diff --git a/.changeset/light-candles-trade.md b/.changeset/light-candles-trade.md new file mode 100644 index 0000000000..1207a55ac8 --- /dev/null +++ b/.changeset/light-candles-trade.md @@ -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 diff --git a/.changeset/purple-pugs-attend.md b/.changeset/purple-pugs-attend.md new file mode 100644 index 0000000000..736e24fbff --- /dev/null +++ b/.changeset/purple-pugs-attend.md @@ -0,0 +1,5 @@ +--- +'gitbook': patch +--- + +Fix flickering when displaying an "Ask" answer with code blocks diff --git a/packages/gitbook/src/components/Ads/AdClassicRendering.tsx b/packages/gitbook/src/components/Ads/AdClassicRendering.tsx index e51abe59b0..ac737bcf36 100644 --- a/packages/gitbook/src/components/Ads/AdClassicRendering.tsx +++ b/packages/gitbook/src/components/Ads/AdClassicRendering.tsx @@ -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 ( ) : ( @@ -43,7 +43,7 @@ export async function AdClassicRendering({ ad }: { ad: AdItem }) { > Ads logo )} diff --git a/packages/gitbook/src/components/Ads/AdCoverRendering.tsx b/packages/gitbook/src/components/Ads/AdCoverRendering.tsx index e5d8d1c472..90416df30e 100644 --- a/packages/gitbook/src/components/Ads/AdCoverRendering.tsx +++ b/packages/gitbook/src/components/Ads/AdCoverRendering.tsx @@ -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 ( 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; } @@ -53,79 +55,88 @@ function nullIfNever(value: never): null { } export function Block(props: BlockProps) { - const { block, style, ...contextProps } = props; + const { block, style, isEstimatedOffscreen, context } = props; const content = (() => { switch (block.type) { case 'paragraph': - return ; + return ; case 'heading-1': case 'heading-2': case 'heading-3': - return ; + return ; case 'list-ordered': - return ; + return ; case 'list-unordered': - return ; + return ; case 'list-tasks': - return ; + return ; case 'list-item': - return ; + return ; case 'code': - return ; + return ; case 'hint': - return ; + return ; case 'images': - return ; + return ; case 'tabs': - return ; + return ; case 'expandable': - return ; + return ; case 'table': - return ; + return
; case 'swagger': - return ; + return ; case 'embed': - return ; + return ; case 'blockquote': - return ; + return ; case 'math': - return ; + return ; case 'file': - return ; + return ; case 'divider': - return ; + return ; case 'drawing': - return ; + return ; case 'content-ref': - return ; + return ; case 'image': case 'code-line': case 'tabs-item': throw new Error('Blocks should be directly rendered by parent'); case 'integration': - return ; + return ; case 'synced-block': - return ; + return ; case 'reusable-content': - return ; + return ; case 'stepper': - return ; + return ; case 'stepper-step': - return ; + return ; 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 ( - }> + }> {content} ); } -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; diff --git a/packages/gitbook/src/components/DocumentView/Blocks.tsx b/packages/gitbook/src/components/DocumentView/Blocks.tsx index defb40b333..301f1424ac 100644 --- a/packages/gitbook/src/components/DocumentView/Blocks.tsx +++ b/packages/gitbook/src/components/DocumentView/Blocks.tsx @@ -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. @@ -49,22 +50,35 @@ type UnwrappedBlocksProps = DocumentContextProps & export function UnwrappedBlocks(props: UnwrappedBlocksProps) { const { nodes, blockStyle, ...contextProps } = props; + let isOffscreen = false; + return ( <> - {nodes.map((node) => ( - - ))} + {nodes.map((node) => { + isOffscreen = + isOffscreen || + isBlockOffscreen({ + document: props.document, + block: node, + ancestorBlocks: props.ancestorBlocks, + }); + + return ( + + ); + })} ); } diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/PlainCodeBlock.tsx b/packages/gitbook/src/components/DocumentView/CodeBlock/PlainCodeBlock.tsx index b80ad49c57..4cbee012be 100644 --- a/packages/gitbook/src/components/DocumentView/CodeBlock/PlainCodeBlock.tsx +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/PlainCodeBlock.tsx @@ -54,6 +54,7 @@ export function PlainCodeBlock(props: { code: string; syntax: string }) { }} block={block} ancestorBlocks={[]} + isEstimatedOffscreen={false} /> ); } diff --git a/packages/gitbook/src/components/DocumentView/DocumentView.tsx b/packages/gitbook/src/components/DocumentView/DocumentView.tsx index eb3ad88150..ff9e93dc00 100644 --- a/packages/gitbook/src/components/DocumentView/DocumentView.tsx +++ b/packages/gitbook/src/components/DocumentView/DocumentView.tsx @@ -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 { @@ -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 { @@ -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 ( +
+ {document.nodes.map((block, index) => ( + + ))} +
+ ); +} diff --git a/packages/gitbook/src/components/DocumentView/Images.tsx b/packages/gitbook/src/components/DocumentView/Images.tsx index 256a11a285..063042a262 100644 --- a/packages/gitbook/src/components/DocumentView/Images.tsx +++ b/packages/gitbook/src/components/DocumentView/Images.tsx @@ -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) { - 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; @@ -41,7 +39,7 @@ export function Images(props: BlockProps) { style={[]} siblings={block.nodes.length} context={context} - isOffscreen={isOffscreen} + isEstimatedOffscreen={isEstimatedOffscreen} /> ))} @@ -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), @@ -97,7 +95,7 @@ async function ImageBlock(props: { } : null, }} - priority={isOffscreen ? 'lazy' : 'high'} + priority={isEstimatedOffscreen ? 'lazy' : 'high'} preload zoom inlineStyle={{ diff --git a/packages/gitbook/src/components/PageBody/PageBody.tsx b/packages/gitbook/src/components/PageBody/PageBody.tsx index 932a974fcd..9af6471c16 100644 --- a/packages/gitbook/src/components/PageBody/PageBody.tsx +++ b/packages/gitbook/src/components/PageBody/PageBody.tsx @@ -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'; @@ -82,19 +82,28 @@ export function PageBody(props: { {document && !isNodeEmpty(document) ? ( - *+*]:mt-5', 'grid']} - blockStyle={['page-api-block:ml-0']} - context={{ - mode: 'default', - content: contentTarget, - contentRefContext: context, - resolveContentRef: (ref, options) => - resolveContentRef(ref, context, options), - shouldHighlightCode, - }} - /> + + } + > + *+*]:mt-5', 'grid']} + blockStyle={['page-api-block:ml-0']} + context={{ + mode: 'default', + content: contentTarget, + contentRefContext: context, + resolveContentRef: (ref, options) => + resolveContentRef(ref, context, options), + shouldHighlightCode, + }} + /> + ) : ( )} diff --git a/packages/gitbook/src/components/Search/SearchAskAnswer.tsx b/packages/gitbook/src/components/Search/SearchAskAnswer.tsx index 962c42ff6d..c6a32ad9f1 100644 --- a/packages/gitbook/src/components/Search/SearchAskAnswer.tsx +++ b/packages/gitbook/src/components/Search/SearchAskAnswer.tsx @@ -78,13 +78,18 @@ export function SearchAskAnswer(props: { spaceId: string; query: string }) { if (cancelled) { return; } + setState({ type: 'error', }); }); return () => { - cancelled = true; + // During development, the useEffect is called twice and the second call doesn't process the stream, + // causing the component to get stuck in the loading state. + if (process.env.NODE_ENV !== 'development') { + cancelled = true; + } }; }, [spaceId, query, setSearchState, setState]); @@ -94,10 +99,11 @@ export function SearchAskAnswer(props: { spaceId: string; query: string }) { }; }, [setState]); - let hasAnswer = false; - if (state && 'answer' in state) { - hasAnswer = !!state?.answer?.body; - } + const loading = ( +
+ +
+ ); return (
{state.answer ? ( -
- -
+ + + ) : (
{t(language, 'search_ask_no_answer')}
)} @@ -123,12 +129,32 @@ export function SearchAskAnswer(props: { spaceId: string; query: string }) { {state?.type === 'error' ? (
{t(language, 'search_ask_error')}
) : null} - {state?.type === 'loading' ? ( -
- -
- ) : null} + {state?.type === 'loading' ? loading : null} +
+ ); +} + +/** + * Since the answer can be an async component that could suspend rendering, + * we need to wrap it in a transition to avoid flickering. + */ +function TransitionAnswerBody(props: { answer: AskAnswerResult; placeholder: React.ReactNode }) { + const { answer, placeholder } = props; + const [display, setDisplay] = React.useState(null); + const [isPending, startTransition] = React.useTransition(); + + React.useEffect(() => { + startTransition(() => { + setDisplay(answer); + }); + }, [answer]); + + return display ? ( +
+
+ ) : ( + <>{placeholder} ); } diff --git a/packages/gitbook/src/components/Search/server-actions.tsx b/packages/gitbook/src/components/Search/server-actions.tsx index cd065af838..cf60df8ac7 100644 --- a/packages/gitbook/src/components/Search/server-actions.tsx +++ b/packages/gitbook/src/components/Search/server-actions.tsx @@ -1,14 +1,7 @@ 'use server'; -import { - Collection, - RevisionPage, - SearchAIAnswer, - SearchPageResult, - Site, - SiteSpace, - Space, -} from '@gitbook/api'; +import { RevisionPage, SearchAIAnswer, SearchPageResult, SiteSpace, Space } from '@gitbook/api'; +import * as React from 'react'; import { streamResponse } from '@/lib/actions'; import * as api from '@/lib/api'; @@ -189,7 +182,8 @@ function transformAnswer( mode: 'default', contentRefContext: null, resolveContentRef: async () => null, - shouldHighlightCode: () => false, + shouldHighlightCode: () => true, + wrapBlocksInSuspense: false, }} style={['space-y-5']} /> diff --git a/packages/gitbook/src/components/primitives/Skeleton.tsx b/packages/gitbook/src/components/primitives/Skeleton.tsx index 555eaa5c05..794767a4cd 100644 --- a/packages/gitbook/src/components/primitives/Skeleton.tsx +++ b/packages/gitbook/src/components/primitives/Skeleton.tsx @@ -28,10 +28,10 @@ export function SkeletonParagraph(props: { id?: string; style?: ClassValue }) { export function SkeletonHeading(props: { id?: string; style?: ClassValue }) { const { id, style } = props; return ( -
+
); @@ -43,16 +43,10 @@ export function SkeletonHeading(props: { id?: string; style?: ClassValue }) { export function SkeletonImage(props: { id?: string; style?: ClassValue }) { const { id, style } = props; return ( -
+
); @@ -64,12 +58,7 @@ export function SkeletonImage(props: { id?: string; style?: ClassValue }) { export function SkeletonCard(props: { id?: string; style?: ClassValue }) { const { id, style } = props; return ( -
+
@@ -83,10 +72,10 @@ export function SkeletonCard(props: { id?: string; style?: ClassValue }) { export function SkeletonSmall(props: { id?: string; style?: ClassValue }) { const { id, style } = props; return ( -
+
); diff --git a/packages/gitbook/src/components/utils/Image.tsx b/packages/gitbook/src/components/utils/Image.tsx index 7ce577e21c..e709af3756 100644 --- a/packages/gitbook/src/components/utils/Image.tsx +++ b/packages/gitbook/src/components/utils/Image.tsx @@ -15,6 +15,12 @@ type ImageSource = { aspectRatio?: string; }; +type ImageSourceSized = { + src: string; + size: ImageSize | null; + aspectRatio?: string; +}; + export type ImageResponsiveSize = { /** Media query to apply this width for */ media?: string; @@ -96,7 +102,7 @@ interface ImgDOMPropsWithSrc extends React.ComponentPropsWithoutRef<'img'> { * We don't use the `next/image` component because we need to load images from external sources, * and we want to avoid client components. */ -export async function Image( +export function Image( props: PolymorphicComponentProp< 'img', { @@ -145,26 +151,114 @@ export async function Image( ); } +function ImagePicture( + props: PolymorphicComponentProp< + 'img', + { + source: ImageSource; + } & ImageCommonProps + >, +) { + const { source, ...rest } = props; + const { size } = source; + + return size ? ( + + ) : ( + + ); +} + +async function ImagePictureUnsized( + props: PolymorphicComponentProp< + 'img', + { + source: ImageSource; + } & ImageCommonProps + >, +) { + const { source, ...rest } = props; + + const size = await getImageSize(source.src); + return ; +} + +function ImagePictureSized( + props: PolymorphicComponentProp< + 'img', + { + source: ImageSourceSized; + } & ImageCommonProps + >, +) { + const { + source, + sizes, + style: _style, + alt, + quality = 100, + priority = 'normal', + inline = false, + zoom = false, + resize = true, + preload = false, + inlineStyle, + ...rest + } = props; + + if (process.env.NODE_ENV === 'development' && sizes.length === 0) { + throw new Error('You must provide at least one size for the image.'); + } + + const attrs = getImageAttributes({ sizes, source, quality, resize }); + const canBeFetched = checkIsHttpURL(attrs.src); + const fetchPriority = canBeFetched ? getFetchPriority(priority) : undefined; + const loading = priority === 'lazy' ? 'lazy' : undefined; + const aspectRatioStyle = source.aspectRatio ? { aspectRatio: source.aspectRatio } : {}; + const style = { ...aspectRatioStyle, ...inlineStyle }; + + // Preload the image if needed. + if (fetchPriority === 'high' || preload) { + ReactDOM.preload(attrs.src, { + as: 'image', + imageSrcSet: attrs.srcSet, + imageSizes: attrs.sizes, + fetchPriority, + }); + } + + const imgProps: ImgDOMPropsWithSrc = { + alt, + style, + loading, + fetchPriority, + ...rest, + ...attrs, + }; + + return zoom ? : {imgProps.alt; +} + /** * Get the attributes for an image. * src, srcSet, sizes, width, height, etc. */ -async function getImageAttributes(params: { +function getImageAttributes(params: { sizes: ImageResponsiveSize[]; - source: ImageSource; + source: ImageSourceSized; quality: number; resize: boolean; -}): Promise<{ +}): { src: string; srcSet?: string; sizes?: string; width?: number; height?: number; -}> { +} { const { sizes, source, quality, resize } = params; let src = source.src; - const getURL = resize ? await getResizedImageURLFactory(source.src) : null; + const getURL = resize ? getResizedImageURLFactory(source.src) : null; if (!getURL) { return { @@ -204,11 +298,7 @@ async function getImageAttributes(params: { sourceSizes.push(`${defaultSize.width}px`); } - // If we don't know the size of the image, we can try reading it from the image itself. - const size = - source.size ?? (await getImageSize(source.src, { width: defaultSize?.width, dpr: 3 })); - - if (!size) { + if (!source.size) { return { src }; } @@ -216,7 +306,7 @@ async function getImageAttributes(params: { src, srcSet: sources.join(', '), sizes: sourceSizes.join(', '), - ...size, + ...source.size, }; } @@ -230,59 +320,3 @@ function getFetchPriority(priority: ImageCommonProps['priority']) { return undefined; } } - -async function ImagePicture( - props: PolymorphicComponentProp< - 'img', - { - source: ImageSource; - } & ImageCommonProps - >, -) { - const { - source, - sizes, - style: _style, - alt, - quality = 100, - priority = 'normal', - inline = false, - zoom = false, - resize = true, - preload = false, - inlineStyle, - ...rest - } = props; - - if (process.env.NODE_ENV === 'development' && sizes.length === 0) { - throw new Error('You must provide at least one size for the image.'); - } - - const attrs = await getImageAttributes({ sizes, source, quality, resize }); - const canBeFetched = checkIsHttpURL(attrs.src); - const fetchPriority = canBeFetched ? getFetchPriority(priority) : undefined; - const loading = priority === 'lazy' ? 'lazy' : undefined; - const aspectRatioStyle = source.aspectRatio ? { aspectRatio: source.aspectRatio } : {}; - const style = { ...aspectRatioStyle, ...inlineStyle }; - - // Preload the image if needed. - if (fetchPriority === 'high' || preload) { - ReactDOM.preload(attrs.src, { - as: 'image', - imageSrcSet: attrs.srcSet, - imageSizes: attrs.sizes, - fetchPriority, - }); - } - - const imgProps: ImgDOMPropsWithSrc = { - alt, - style, - loading, - fetchPriority, - ...rest, - ...attrs, - }; - - return zoom ? : {imgProps.alt; -} diff --git a/packages/gitbook/src/lib/images.ts b/packages/gitbook/src/lib/images.ts index 9c2030dc04..a9567523af 100644 --- a/packages/gitbook/src/lib/images.ts +++ b/packages/gitbook/src/lib/images.ts @@ -76,14 +76,14 @@ interface ResizeImageOptions { /** * Create a function to get resized image URLs for a given image URL. */ -export async function getResizedImageURLFactory( +export function getResizedImageURLFactory( input: string, -): Promise<((options: ResizeImageOptions) => string) | null> { +): ((options: ResizeImageOptions) => string) | null { if (!checkIsSizableImageURL(input)) { return null; } - const signature = await generateSignatureV1(input); + const signature = generateSignatureV1(input); return (options) => { const url = new URL('/~gitbook/image', rootUrl()); @@ -113,11 +113,8 @@ export async function getResizedImageURLFactory( * Create a new URL for an image with resized parameters. * The URL is signed and verified by the server. */ -export async function getResizedImageURL( - input: string, - options: ResizeImageOptions, -): Promise { - const factory = await getResizedImageURLFactory(input); +export function getResizedImageURL(input: string, options: ResizeImageOptions): string { + const factory = getResizedImageURLFactory(input); return factory?.(options) ?? input; } @@ -129,7 +126,7 @@ export async function verifyImageSignature( { signature, version }: { signature: string; version: '1' | '0' }, ): Promise { const expectedSignature = - version === '1' ? await generateSignatureV1(input) : await generateSignatureV0(input); + version === '1' ? generateSignatureV1(input) : await generateSignatureV0(input); return expectedSignature === signature; } @@ -236,7 +233,7 @@ const fnv1aUtf8Buffer = new Uint8Array(512); * When setting it in a URL, we use version '1' for the 'sv' querystring parameneter * to know that it was the algorithm that was used. */ -async function generateSignatureV1(input: string): Promise { +function generateSignatureV1(input: string): string { const all = [input, process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY].filter(Boolean).join(':'); return fnv1a(all, { utf8Buffer: fnv1aUtf8Buffer }).toString(16); }