Skip to content

Commit 392f594

Browse files
authored
Improve performance of InlineLinkTooltip (#3339)
1 parent b4918f6 commit 392f594

File tree

11 files changed

+325
-129
lines changed

11 files changed

+325
-129
lines changed

.changeset/soft-walls-change.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"gitbook": patch
3+
"gitbook-v2": patch
4+
---
5+
6+
Fix InlineLinkTooltip having a negative impact on performance, especially on larger pages.

packages/gitbook/src/components/DocumentView/DocumentView.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ export interface DocumentContext {
2828
* @default true
2929
*/
3030
wrapBlocksInSuspense?: boolean;
31+
32+
/**
33+
* True if link previews should be rendered.
34+
* This is used to limit the number of link previews rendered in a document.
35+
* If false, no link previews will be rendered.
36+
* @default false
37+
*/
38+
shouldRenderLinkPreviews?: boolean;
3139
}
3240

3341
export interface DocumentContextProps {

packages/gitbook/src/components/DocumentView/InlineLink.tsx

Lines changed: 0 additions & 61 deletions
This file was deleted.
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { type DocumentInlineLink, SiteInsightsLinkPosition } from '@gitbook/api';
2+
3+
import { getSpaceLanguage, tString } from '@/intl/server';
4+
import { languages } from '@/intl/translations';
5+
import { type ResolvedContentRef, resolveContentRef } from '@/lib/references';
6+
import { Icon } from '@gitbook/icons';
7+
import type { GitBookAnyContext } from '@v2/lib/context';
8+
import { StyledLink } from '../../primitives';
9+
import type { InlineProps } from '../Inline';
10+
import { Inlines } from '../Inlines';
11+
import { InlineLinkTooltip } from './InlineLinkTooltip';
12+
13+
export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
14+
const { inline, document, context, ancestorInlines } = props;
15+
16+
const resolved = context.contentContext
17+
? await resolveContentRef(inline.data.ref, context.contentContext, {
18+
// 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.
19+
resolveAnchorText: false,
20+
})
21+
: null;
22+
23+
if (!context.contentContext || !resolved) {
24+
return (
25+
<span title="Broken link" className="underline">
26+
<Inlines
27+
context={context}
28+
document={document}
29+
nodes={inline.nodes}
30+
ancestorInlines={[...ancestorInlines, inline]}
31+
/>
32+
</span>
33+
);
34+
}
35+
const isExternal = inline.data.ref.kind === 'url';
36+
const content = (
37+
<StyledLink
38+
href={resolved.href}
39+
insights={{
40+
type: 'link_click',
41+
link: {
42+
target: inline.data.ref,
43+
position: SiteInsightsLinkPosition.Content,
44+
},
45+
}}
46+
>
47+
<Inlines
48+
context={context}
49+
document={document}
50+
nodes={inline.nodes}
51+
ancestorInlines={[...ancestorInlines, inline]}
52+
/>
53+
{isExternal ? (
54+
<Icon
55+
icon="arrow-up-right"
56+
className="ml-0.5 inline size-3 links-accent:text-tint-subtle"
57+
/>
58+
) : null}
59+
</StyledLink>
60+
);
61+
62+
if (context.shouldRenderLinkPreviews) {
63+
return (
64+
<InlineLinkTooltipWrapper
65+
inline={inline}
66+
context={context.contentContext}
67+
resolved={resolved}
68+
>
69+
{content}
70+
</InlineLinkTooltipWrapper>
71+
);
72+
}
73+
74+
return content;
75+
}
76+
77+
/**
78+
* An SSR component that renders a link with a tooltip.
79+
* Essentially it pulls the minimum amount of props from the context to render the tooltip.
80+
*/
81+
function InlineLinkTooltipWrapper(props: {
82+
inline: DocumentInlineLink;
83+
context: GitBookAnyContext;
84+
children: React.ReactNode;
85+
resolved: ResolvedContentRef;
86+
}) {
87+
const { inline, context, resolved, children } = props;
88+
89+
let breadcrumbs = resolved.ancestors ?? [];
90+
const language =
91+
'customization' in context ? getSpaceLanguage(context.customization) : languages.en;
92+
const isExternal = inline.data.ref.kind === 'url';
93+
const isSamePage = inline.data.ref.kind === 'anchor' && inline.data.ref.page === undefined;
94+
if (isExternal) {
95+
breadcrumbs = [
96+
{
97+
label: tString(language, 'link_tooltip_external_link'),
98+
},
99+
];
100+
}
101+
if (isSamePage) {
102+
breadcrumbs = [
103+
{
104+
label: tString(language, 'link_tooltip_page_anchor'),
105+
icon: <Icon icon="arrow-down-short-wide" className="size-3" />,
106+
},
107+
];
108+
resolved.subText = undefined;
109+
}
110+
111+
const aiSummary: { pageId: string; spaceId: string } | undefined = (() => {
112+
if (isExternal) {
113+
return;
114+
}
115+
116+
if (isSamePage) {
117+
return;
118+
}
119+
120+
if (!('customization' in context) || !context.customization.ai?.pageLinkSummaries.enabled) {
121+
return;
122+
}
123+
124+
if (!('page' in context) || !('page' in inline.data.ref)) {
125+
return;
126+
}
127+
128+
if (inline.data.ref.kind === 'page' || inline.data.ref.kind === 'anchor') {
129+
return {
130+
pageId: resolved.page?.id ?? inline.data.ref.page ?? context.page.id,
131+
spaceId: inline.data.ref.space ?? context.space.id,
132+
};
133+
}
134+
})();
135+
136+
return (
137+
<InlineLinkTooltip
138+
breadcrumbs={breadcrumbs}
139+
isExternal={isExternal}
140+
isSamePage={isSamePage}
141+
aiSummary={aiSummary}
142+
openInNewTabLabel={tString(language, 'open_in_new_tab')}
143+
target={{
144+
href: resolved.href,
145+
text: resolved.text,
146+
subText: resolved.subText,
147+
icon: resolved.icon,
148+
}}
149+
>
150+
{children}
151+
</InlineLinkTooltip>
152+
);
153+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
'use client';
2+
import dynamic from 'next/dynamic';
3+
import React from 'react';
4+
5+
const LoadingValueContext = React.createContext<React.ReactNode>(null);
6+
7+
// To avoid polluting the RSC payload with the tooltip implementation,
8+
// we lazily load it on the client side. This way, the tooltip is only loaded
9+
// when the user interacts with the link, and it doesn't block the initial render.
10+
11+
const InlineLinkTooltipImpl = dynamic(
12+
() => import('./InlineLinkTooltipImpl').then((mod) => mod.InlineLinkTooltipImpl),
13+
{
14+
// Disable server-side rendering for this component, it's only
15+
// visible on user interaction.
16+
ssr: false,
17+
loading: () => {
18+
// The fallback should be the children (the content of the link),
19+
// but as next/dynamic is aiming for feature parity with React.lazy,
20+
// it doesn't support passing children to the loading component.
21+
// https://github.com/vercel/next.js/issues/7906
22+
const children = React.useContext(LoadingValueContext);
23+
return <>{children}</>;
24+
},
25+
}
26+
);
27+
28+
/**
29+
* Tooltip for inline links. It's lazily loaded to avoid blocking the initial render
30+
* and polluting the RSC payload.
31+
*
32+
* The link text and href have already been rendered on the server for good SEO,
33+
* so we can be as lazy as possible with the tooltip.
34+
*/
35+
export function InlineLinkTooltip(props: {
36+
isSamePage: boolean;
37+
isExternal: boolean;
38+
aiSummary?: { pageId: string; spaceId: string };
39+
breadcrumbs: Array<{ href?: string; label: string; icon?: React.ReactNode }>;
40+
target: {
41+
href: string;
42+
text: string;
43+
subText?: string;
44+
icon?: React.ReactNode;
45+
};
46+
openInNewTabLabel: string;
47+
children: React.ReactNode;
48+
}) {
49+
const { children, ...rest } = props;
50+
const [shouldLoad, setShouldLoad] = React.useState(false);
51+
52+
// Once the browser is idle, we set shouldLoad to true.
53+
// NOTE: to be slightly more performant, we could load when a link is hovered.
54+
// But I found this was too much of a delay for the tooltip to appear.
55+
// Loading on idle is a good compromise, as it allows the initial render to be fast,
56+
// while still loading the tooltip in the background and not polluting the RSC payload.
57+
React.useEffect(() => {
58+
if ('requestIdleCallback' in window) {
59+
(window as globalThis.Window).requestIdleCallback(() => setShouldLoad(true));
60+
} else {
61+
// fallback for old browsers
62+
setTimeout(() => setShouldLoad(true), 2000);
63+
}
64+
}, []);
65+
66+
return shouldLoad ? (
67+
<LoadingValueContext.Provider value={children}>
68+
<InlineLinkTooltipImpl {...rest}>{children}</InlineLinkTooltipImpl>
69+
</LoadingValueContext.Provider>
70+
) : (
71+
children
72+
);
73+
}

0 commit comments

Comments
 (0)