diff --git a/.changeset/soft-walls-change.md b/.changeset/soft-walls-change.md new file mode 100644 index 000000000..2086de031 --- /dev/null +++ b/.changeset/soft-walls-change.md @@ -0,0 +1,6 @@ +--- +"gitbook": patch +"gitbook-v2": patch +--- + +Fix InlineLinkTooltip having a negative impact on performance, especially on larger pages. diff --git a/packages/gitbook/src/components/DocumentView/DocumentView.tsx b/packages/gitbook/src/components/DocumentView/DocumentView.tsx index 724a7f2b0..1ff8542ee 100644 --- a/packages/gitbook/src/components/DocumentView/DocumentView.tsx +++ b/packages/gitbook/src/components/DocumentView/DocumentView.tsx @@ -28,6 +28,14 @@ export interface DocumentContext { * @default true */ wrapBlocksInSuspense?: boolean; + + /** + * True if link previews should be rendered. + * This is used to limit the number of link previews rendered in a document. + * If false, no link previews will be rendered. + * @default false + */ + shouldRenderLinkPreviews?: boolean; } export interface DocumentContextProps { diff --git a/packages/gitbook/src/components/DocumentView/InlineLink.tsx b/packages/gitbook/src/components/DocumentView/InlineLink.tsx deleted file mode 100644 index f5bd7f798..000000000 --- a/packages/gitbook/src/components/DocumentView/InlineLink.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { type DocumentInlineLink, SiteInsightsLinkPosition } from '@gitbook/api'; - -import { resolveContentRef } from '@/lib/references'; -import { Icon } from '@gitbook/icons'; -import { StyledLink } from '../primitives'; -import type { InlineProps } from './Inline'; -import { InlineLinkTooltip } from './InlineLinkTooltip'; -import { Inlines } from './Inlines'; - -export async function InlineLink(props: InlineProps) { - const { inline, document, context, ancestorInlines } = props; - - const resolved = context.contentContext - ? await resolveContentRef(inline.data.ref, context.contentContext, { - // We don't want to resolve the anchor text here, as it can be very expensive and will block rendering if there is a lot of anchors link. - resolveAnchorText: false, - }) - : null; - - if (!context.contentContext || !resolved) { - return ( - - - - ); - } - const isExternal = inline.data.ref.kind === 'url'; - - return ( - - - - {isExternal ? ( - - ) : null} - - - ); -} diff --git a/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx new file mode 100644 index 000000000..8ad287a35 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx @@ -0,0 +1,153 @@ +import { type DocumentInlineLink, SiteInsightsLinkPosition } from '@gitbook/api'; + +import { getSpaceLanguage, tString } from '@/intl/server'; +import { languages } from '@/intl/translations'; +import { type ResolvedContentRef, resolveContentRef } from '@/lib/references'; +import { Icon } from '@gitbook/icons'; +import type { GitBookAnyContext } from '@v2/lib/context'; +import { StyledLink } from '../../primitives'; +import type { InlineProps } from '../Inline'; +import { Inlines } from '../Inlines'; +import { InlineLinkTooltip } from './InlineLinkTooltip'; + +export async function InlineLink(props: InlineProps) { + const { inline, document, context, ancestorInlines } = props; + + const resolved = context.contentContext + ? await resolveContentRef(inline.data.ref, context.contentContext, { + // We don't want to resolve the anchor text here, as it can be very expensive and will block rendering if there is a lot of anchors link. + resolveAnchorText: false, + }) + : null; + + if (!context.contentContext || !resolved) { + return ( + + + + ); + } + const isExternal = inline.data.ref.kind === 'url'; + const content = ( + + + {isExternal ? ( + + ) : null} + + ); + + if (context.shouldRenderLinkPreviews) { + return ( + + {content} + + ); + } + + return content; +} + +/** + * An SSR component that renders a link with a tooltip. + * Essentially it pulls the minimum amount of props from the context to render the tooltip. + */ +function InlineLinkTooltipWrapper(props: { + inline: DocumentInlineLink; + context: GitBookAnyContext; + children: React.ReactNode; + resolved: ResolvedContentRef; +}) { + const { inline, context, resolved, children } = props; + + let breadcrumbs = resolved.ancestors ?? []; + const language = + 'customization' in context ? getSpaceLanguage(context.customization) : languages.en; + const isExternal = inline.data.ref.kind === 'url'; + const isSamePage = inline.data.ref.kind === 'anchor' && inline.data.ref.page === undefined; + if (isExternal) { + breadcrumbs = [ + { + label: tString(language, 'link_tooltip_external_link'), + }, + ]; + } + if (isSamePage) { + breadcrumbs = [ + { + label: tString(language, 'link_tooltip_page_anchor'), + icon: , + }, + ]; + resolved.subText = undefined; + } + + const aiSummary: { pageId: string; spaceId: string } | undefined = (() => { + if (isExternal) { + return; + } + + if (isSamePage) { + return; + } + + if (!('customization' in context) || !context.customization.ai?.pageLinkSummaries.enabled) { + return; + } + + if (!('page' in context) || !('page' in inline.data.ref)) { + return; + } + + if (inline.data.ref.kind === 'page' || inline.data.ref.kind === 'anchor') { + return { + pageId: resolved.page?.id ?? inline.data.ref.page ?? context.page.id, + spaceId: inline.data.ref.space ?? context.space.id, + }; + } + })(); + + return ( + + {children} + + ); +} diff --git a/packages/gitbook/src/components/DocumentView/InlineLink/InlineLinkTooltip.tsx b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLinkTooltip.tsx new file mode 100644 index 000000000..45be0c717 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLinkTooltip.tsx @@ -0,0 +1,73 @@ +'use client'; +import dynamic from 'next/dynamic'; +import React from 'react'; + +const LoadingValueContext = React.createContext(null); + +// To avoid polluting the RSC payload with the tooltip implementation, +// we lazily load it on the client side. This way, the tooltip is only loaded +// when the user interacts with the link, and it doesn't block the initial render. + +const InlineLinkTooltipImpl = dynamic( + () => import('./InlineLinkTooltipImpl').then((mod) => mod.InlineLinkTooltipImpl), + { + // Disable server-side rendering for this component, it's only + // visible on user interaction. + ssr: false, + loading: () => { + // The fallback should be the children (the content of the link), + // but as next/dynamic is aiming for feature parity with React.lazy, + // it doesn't support passing children to the loading component. + // https://github.com/vercel/next.js/issues/7906 + const children = React.useContext(LoadingValueContext); + return <>{children}; + }, + } +); + +/** + * Tooltip for inline links. It's lazily loaded to avoid blocking the initial render + * and polluting the RSC payload. + * + * The link text and href have already been rendered on the server for good SEO, + * so we can be as lazy as possible with the tooltip. + */ +export function InlineLinkTooltip(props: { + isSamePage: boolean; + isExternal: boolean; + aiSummary?: { pageId: string; spaceId: string }; + breadcrumbs: Array<{ href?: string; label: string; icon?: React.ReactNode }>; + target: { + href: string; + text: string; + subText?: string; + icon?: React.ReactNode; + }; + openInNewTabLabel: string; + children: React.ReactNode; +}) { + const { children, ...rest } = props; + const [shouldLoad, setShouldLoad] = React.useState(false); + + // Once the browser is idle, we set shouldLoad to true. + // NOTE: to be slightly more performant, we could load when a link is hovered. + // But I found this was too much of a delay for the tooltip to appear. + // Loading on idle is a good compromise, as it allows the initial render to be fast, + // while still loading the tooltip in the background and not polluting the RSC payload. + React.useEffect(() => { + if ('requestIdleCallback' in window) { + (window as globalThis.Window).requestIdleCallback(() => setShouldLoad(true)); + } else { + // fallback for old browsers + setTimeout(() => setShouldLoad(true), 2000); + } + }, []); + + return shouldLoad ? ( + + {children} + + ) : ( + children + ); +} diff --git a/packages/gitbook/src/components/DocumentView/InlineLinkTooltip.tsx b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLinkTooltipImpl.tsx similarity index 64% rename from packages/gitbook/src/components/DocumentView/InlineLinkTooltip.tsx rename to packages/gitbook/src/components/DocumentView/InlineLink/InlineLinkTooltipImpl.tsx index 1a5cefa1e..333184a2c 100644 --- a/packages/gitbook/src/components/DocumentView/InlineLinkTooltip.tsx +++ b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLinkTooltipImpl.tsx @@ -1,55 +1,27 @@ -import type { DocumentInlineLink } from '@gitbook/api'; - -import type { ResolvedContentRef } from '@/lib/references'; - -import { getSpaceLanguage } from '@/intl/server'; -import { tString } from '@/intl/translate'; -import { languages } from '@/intl/translations'; -import { getNodeText } from '@/lib/document'; +'use client'; import { tcls } from '@/lib/tailwind'; import { Icon } from '@gitbook/icons'; import * as Tooltip from '@radix-ui/react-tooltip'; -import type { GitBookAnyContext } from '@v2/lib/context'; import { Fragment } from 'react'; -import { AIPageLinkSummary } from '../Adaptive/AIPageLinkSummary'; -import { Button, StyledLink } from '../primitives'; +import { AIPageLinkSummary } from '../../Adaptive'; +import { Button, StyledLink } from '../../primitives'; -export async function InlineLinkTooltip(props: { - inline: DocumentInlineLink; - context: GitBookAnyContext; +export function InlineLinkTooltipImpl(props: { + isSamePage: boolean; + isExternal: boolean; + aiSummary?: { pageId: string; spaceId: string }; + breadcrumbs: Array<{ href?: string; label: string; icon?: React.ReactNode }>; + target: { + href: string; + text: string; + subText?: string; + icon?: React.ReactNode; + }; + openInNewTabLabel: string; children: React.ReactNode; - resolved: ResolvedContentRef; }) { - const { inline, context, resolved, children } = props; - - let breadcrumbs = resolved.ancestors; - const language = - 'customization' in context ? getSpaceLanguage(context.customization) : languages.en; - const isExternal = inline.data.ref.kind === 'url'; - const isSamePage = inline.data.ref.kind === 'anchor' && inline.data.ref.page === undefined; - if (isExternal) { - breadcrumbs = [ - { - label: tString(language, 'link_tooltip_external_link'), - }, - ]; - } - if (isSamePage) { - breadcrumbs = [ - { - label: tString(language, 'link_tooltip_page_anchor'), - icon: , - }, - ]; - resolved.subText = undefined; - } - - const hasAISummary = - !isExternal && - !isSamePage && - 'customization' in context && - context.customization.ai?.pageLinkSummaries.enabled && - (inline.data.ref.kind === 'page' || inline.data.ref.kind === 'anchor'); + const { isSamePage, isExternal, aiSummary, openInNewTabLabel, target, breadcrumbs, children } = + props; return ( @@ -100,15 +72,15 @@ export async function InlineLinkTooltip(props: { isExternal && 'text-sm [overflow-wrap:anywhere]' )} > - {resolved.icon ? ( + {target.icon ? (
- {resolved.icon} + {target.icon}
) : null} -
{resolved.text}
+
{target.text}
- {!isSamePage && resolved.href ? ( + {!isSamePage && target.href ? (