Improve performance of InlineLinkTooltip (#3339)

This commit is contained in:
Steven H
2025-06-18 12:15:45 +01:00
committed by GitHub
parent b4918f60ce
commit 392f59450c
11 changed files with 325 additions and 129 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"gitbook": patch
"gitbook-v2": patch
---
Fix InlineLinkTooltip having a negative impact on performance, especially on larger pages.
@@ -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 {
@@ -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<DocumentInlineLink>) {
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 (
<span title="Broken link" className="underline">
<Inlines
context={context}
document={document}
nodes={inline.nodes}
ancestorInlines={[...ancestorInlines, inline]}
/>
</span>
);
}
const isExternal = inline.data.ref.kind === 'url';
return (
<InlineLinkTooltip inline={inline} context={context.contentContext} resolved={resolved}>
<StyledLink
href={resolved.href}
insights={{
type: 'link_click',
link: {
target: inline.data.ref,
position: SiteInsightsLinkPosition.Content,
},
}}
>
<Inlines
context={context}
document={document}
nodes={inline.nodes}
ancestorInlines={[...ancestorInlines, inline]}
/>
{isExternal ? (
<Icon
icon="arrow-up-right"
className="ml-0.5 inline size-3 links-accent:text-tint-subtle"
/>
) : null}
</StyledLink>
</InlineLinkTooltip>
);
}
@@ -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<DocumentInlineLink>) {
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 (
<span title="Broken link" className="underline">
<Inlines
context={context}
document={document}
nodes={inline.nodes}
ancestorInlines={[...ancestorInlines, inline]}
/>
</span>
);
}
const isExternal = inline.data.ref.kind === 'url';
const content = (
<StyledLink
href={resolved.href}
insights={{
type: 'link_click',
link: {
target: inline.data.ref,
position: SiteInsightsLinkPosition.Content,
},
}}
>
<Inlines
context={context}
document={document}
nodes={inline.nodes}
ancestorInlines={[...ancestorInlines, inline]}
/>
{isExternal ? (
<Icon
icon="arrow-up-right"
className="ml-0.5 inline size-3 links-accent:text-tint-subtle"
/>
) : null}
</StyledLink>
);
if (context.shouldRenderLinkPreviews) {
return (
<InlineLinkTooltipWrapper
inline={inline}
context={context.contentContext}
resolved={resolved}
>
{content}
</InlineLinkTooltipWrapper>
);
}
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: <Icon icon="arrow-down-short-wide" className="size-3" />,
},
];
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 (
<InlineLinkTooltip
breadcrumbs={breadcrumbs}
isExternal={isExternal}
isSamePage={isSamePage}
aiSummary={aiSummary}
openInNewTabLabel={tString(language, 'open_in_new_tab')}
target={{
href: resolved.href,
text: resolved.text,
subText: resolved.subText,
icon: resolved.icon,
}}
>
{children}
</InlineLinkTooltip>
);
}
@@ -0,0 +1,73 @@
'use client';
import dynamic from 'next/dynamic';
import React from 'react';
const LoadingValueContext = React.createContext<React.ReactNode>(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 ? (
<LoadingValueContext.Provider value={children}>
<InlineLinkTooltipImpl {...rest}>{children}</InlineLinkTooltipImpl>
</LoadingValueContext.Provider>
) : (
children
);
}
@@ -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: <Icon icon="arrow-down-short-wide" className="size-3" />,
},
];
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 (
<Tooltip.Provider delayDuration={200}>
@@ -100,15 +72,15 @@ export async function InlineLinkTooltip(props: {
isExternal && 'text-sm [overflow-wrap:anywhere]'
)}
>
{resolved.icon ? (
{target.icon ? (
<div className="mt-1 text-tint-subtle empty:hidden">
{resolved.icon}
{target.icon}
</div>
) : null}
<h5 className="font-semibold">{resolved.text}</h5>
<h5 className="font-semibold">{target.text}</h5>
</div>
</div>
{!isSamePage && resolved.href ? (
{!isSamePage && target.href ? (
<Button
className={tcls(
'-mx-2 -my-2 ml-auto',
@@ -117,40 +89,35 @@ export async function InlineLinkTooltip(props: {
: null
)}
variant="blank"
href={resolved.href}
href={target.href}
target="_blank"
label={tString(language, 'open_in_new_tab')}
label={openInNewTabLabel}
size="small"
icon="arrow-up-right-from-square"
iconOnly={true}
/>
) : null}
</div>
{resolved.subText ? (
<p className="mt-1 text-sm text-tint">{resolved.subText}</p>
{target.subText ? (
<p className="mt-1 text-sm text-tint">{target.subText}</p>
) : null}
</div>
{hasAISummary && 'page' in context && 'page' in inline.data.ref ? (
{aiSummary ? (
<div className="border-tint-subtle border-t bg-tint p-4">
<AIPageLinkSummary
targetPageId={
resolved.page?.id ??
inline.data.ref.page ??
context.page.id
}
targetSpaceId={inline.data.ref.space ?? context.space.id}
linkTitle={getNodeText(inline)}
linkPreview={`**${resolved.text}**: ${resolved.subText}`}
showTrademark={
'customization' in context &&
context.customization.trademark.enabled
}
targetPageId={aiSummary.pageId}
targetSpaceId={aiSummary.spaceId}
showTrademark
/>
</div>
) : null}
</div>
<Tooltip.Arrow className={hasAISummary ? 'fill-tint-3' : 'fill-tint-1'} />
<Tooltip.Arrow
className={
typeof aiSummary !== 'undefined' ? 'fill-tint-3' : 'fill-tint-1'
}
/>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
@@ -0,0 +1 @@
export * from './InlineLink';
@@ -245,6 +245,7 @@ async function PDFPageDocument(props: {
page,
},
getId: (id) => getPagePDFContainerId(page, id),
shouldRenderLinkPreviews: false, // We don't want to render link previews in the PDF.
}}
// We consider all pages as offscreen in PDF mode
// to ensure we can efficiently render as many pages as possible
@@ -4,7 +4,7 @@ import React from 'react';
import { getSpaceLanguage } from '@/intl/server';
import { t } from '@/intl/translate';
import { hasFullWidthBlock, isNodeEmpty } from '@/lib/document';
import { hasFullWidthBlock, hasMoreThan, isNodeEmpty } from '@/lib/document';
import type { AncestorRevisionPage } from '@/lib/pages';
import { tcls } from '@/lib/tailwind';
import { DocumentView, DocumentViewSkeleton } from '../DocumentView';
@@ -17,6 +17,8 @@ import { PageFooterNavigation } from './PageFooterNavigation';
import { PageHeader } from './PageHeader';
import { PreservePageLayout } from './PreservePageLayout';
const LINK_PREVIEW_MAX_COUNT = 100;
export function PageBody(props: {
context: GitBookSiteContext;
page: RevisionPageDocument;
@@ -28,6 +30,15 @@ export function PageBody(props: {
const { customization } = context;
const contentFullWidth = document ? hasFullWidthBlock(document) : false;
// Render link previews only if there are less than LINK_PREVIEW_MAX_COUNT links in the document.
const shouldRenderLinkPreviews = document
? !hasMoreThan(
document,
(inline) => inline.object === 'inline' && inline.type === 'link',
LINK_PREVIEW_MAX_COUNT
)
: false;
const pageFullWidth = page.id === 'wtthNFMqmEQmnt5LKR0q';
const asFullWidth = pageFullWidth || contentFullWidth;
const language = getSpaceLanguage(customization);
@@ -68,6 +79,7 @@ export function PageBody(props: {
context={{
mode: 'default',
contentContext: context,
shouldRenderLinkPreviews,
}}
/>
</React.Suspense>
@@ -345,6 +345,7 @@ async function transformAnswer(
mode: 'default',
contentContext: undefined,
wrapBlocksInSuspense: false,
shouldRenderLinkPreviews: false, // We don't want to render link previews in the AI answer.
}}
style={['space-y-5']}
/>
+35
View File
@@ -30,6 +30,41 @@ export function hasFullWidthBlock(document: JSONDocument): boolean {
return false;
}
/**
* Returns true if the document has more than `limit` blocks and/or inlines that match the `check` predicate.
*/
export function hasMoreThan(
document: JSONDocument | DocumentBlock,
check: (block: DocumentBlock | DocumentInline) => boolean,
limit = 1
): boolean {
let count = 0;
function traverse(node: JSONDocument | DocumentBlock | DocumentFragment): boolean {
for (const child of 'nodes' in node ? node.nodes : []) {
if (child.object === 'text') continue;
if (check(child)) {
count++;
if (count > limit) return true;
}
if (child.object === 'block' && 'nodes' in child) {
if (traverse(child)) return true;
}
if (child.object === 'block' && 'fragments' in child) {
for (const fragment of child.fragments) {
if (traverse(fragment)) return true;
}
}
}
return false;
}
return traverse(document);
}
/**
* Get the text of a block/inline.
*/