mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-21 10:03:31 +00:00
Improve performance of InlineLinkTooltip (#3339)
This commit is contained in:
@@ -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
|
||||
);
|
||||
}
|
||||
+34
-67
@@ -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']}
|
||||
/>
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user