Skip to content

Commit 2a23f1f

Browse files
authored
Improve performances of TOC (#3176)
1 parent 88ffdcc commit 2a23f1f

File tree

10 files changed

+344
-286
lines changed

10 files changed

+344
-286
lines changed

packages/gitbook/src/components/SiteSections/SiteSectionList.tsx

+31-21
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,13 @@ export function SiteSectionListItem(props: {
7575
const isMounted = useIsMounted();
7676
React.useEffect(() => {}, [isMounted]); // This updates the useScrollToActiveTOCItem hook once we're mounted, so we can actually scroll to the this item
7777

78-
const linkRef = React.createRef<HTMLAnchorElement>();
79-
useScrollToActiveTOCItem({ linkRef, isActive });
78+
const anchorRef = React.createRef<HTMLAnchorElement>();
79+
useScrollToActiveTOCItem({ anchorRef, isActive });
8080

8181
return (
8282
<Link
83+
ref={anchorRef}
8384
href={section.url}
84-
ref={linkRef}
8585
aria-current={isActive && 'page'}
8686
className={tcls(
8787
'group/section-link flex flex-row items-center gap-3 rounded-md straight-corners:rounded-none px-3 py-2 transition-all hover:bg-tint-hover hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-tint',
@@ -121,18 +121,15 @@ export function SiteSectionGroupItem(props: {
121121

122122
const hasDescendants = group.sections.length > 0;
123123
const isActiveGroup = group.sections.some((section) => section.id === currentSection.id);
124-
const [isVisible, setIsVisible] = React.useState(isActiveGroup);
124+
const shouldOpen = hasDescendants && isActiveGroup;
125+
const [isOpen, setIsOpen] = React.useState(shouldOpen);
125126

126-
// Update the visibility of the children, if we are navigating to a descendant.
127+
// Update the visibility of the children if the group becomes active.
127128
React.useEffect(() => {
128-
if (!hasDescendants) {
129-
return;
129+
if (shouldOpen) {
130+
setIsOpen(shouldOpen);
130131
}
131-
132-
setIsVisible((prev) => prev || isActiveGroup);
133-
}, [isActiveGroup, hasDescendants]);
134-
135-
const { show, hide, scope } = useToggleAnimation({ hasDescendants, isVisible });
132+
}, [shouldOpen]);
136133

137134
return (
138135
<>
@@ -141,7 +138,7 @@ export function SiteSectionGroupItem(props: {
141138
onClick={(event) => {
142139
event.preventDefault();
143140
event.stopPropagation();
144-
setIsVisible((prev) => !prev);
141+
setIsOpen((prev) => !prev);
145142
}}
146143
className={`group/section-link flex w-full flex-row items-center gap-3 rounded-md straight-corners:rounded-none px-3 py-2 text-left transition-all hover:bg-tint-hover hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-tint ${
147144
isActiveGroup
@@ -184,7 +181,7 @@ export function SiteSectionGroupItem(props: {
184181
'after:h-7',
185182
'hover:bg-tint-active',
186183
'hover:text-current',
187-
isActiveGroup ? ['hover:bg-tint-hover'] : []
184+
isActiveGroup && 'hover:bg-tint-hover'
188185
)}
189186
>
190187
<Icon
@@ -201,17 +198,13 @@ export function SiteSectionGroupItem(props: {
201198
'group-hover:opacity-11',
202199
'contrast-more:opacity-11',
203200

204-
isVisible ? ['rotate-90'] : ['rotate-0']
201+
isOpen ? 'rotate-90' : 'rotate-0'
205202
)}
206203
/>
207204
</span>
208205
</button>
209206
{hasDescendants ? (
210-
<motion.div
211-
ref={scope}
212-
className={tcls(isVisible ? null : '[&_ul>li]:opacity-1')}
213-
initial={isVisible ? show : hide}
214-
>
207+
<Descendants isVisible={isOpen}>
215208
{group.sections.map((section) => (
216209
<SiteSectionListItem
217210
section={section}
@@ -220,8 +213,25 @@ export function SiteSectionGroupItem(props: {
220213
className="pl-5"
221214
/>
222215
))}
223-
</motion.div>
216+
</Descendants>
224217
) : null}
225218
</>
226219
);
227220
}
221+
222+
function Descendants(props: {
223+
isVisible: boolean;
224+
children: React.ReactNode;
225+
}) {
226+
const { isVisible, children } = props;
227+
const { show, hide, scope } = useToggleAnimation(isVisible);
228+
return (
229+
<motion.div
230+
ref={scope}
231+
className={isVisible ? undefined : '[&_ul>li]:opacity-1'}
232+
initial={isVisible ? show : hide}
233+
>
234+
{children}
235+
</motion.div>
236+
);
237+
}

packages/gitbook/src/components/TableOfContents/PageDocumentItem.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export async function PageDocumentItem(props: {
2020
const href = context.linker.toPathForPage({ pages: rootPages, page });
2121

2222
return (
23-
<li className={tcls('flex', 'flex-col')}>
23+
<li className="flex flex-col">
2424
<ToggleableLinkItem
2525
href={href}
2626
pathname={getPagePath(rootPages, page)}
@@ -52,7 +52,7 @@ export async function PageDocumentItem(props: {
5252
}
5353
>
5454
{page.emoji || page.icon ? (
55-
<span className={tcls('flex', 'gap-3', 'items-center')}>
55+
<span className="flex items-center gap-3">
5656
<TOCPageIcon page={page} />
5757
{page.title}
5858
</span>

packages/gitbook/src/components/TableOfContents/PageGroupItem.tsx

+3-17
Original file line numberDiff line numberDiff line change
@@ -15,27 +15,13 @@ export function PageGroupItem(props: {
1515
const { rootPages, page, context } = props;
1616

1717
return (
18-
<li className={tcls('flex', 'flex-col', 'group/page-group-item')}>
18+
<li className="group/page-group-item flex flex-col">
1919
<div
2020
className={tcls(
21-
'flex',
22-
'items-center',
23-
24-
'gap-3',
25-
'px-3',
26-
'z-[1]',
27-
'sticky',
28-
'-top-5',
29-
'pt-6',
30-
'group-first/page-group-item:-mt-5',
21+
'-top-5 group-first/page-group-item:-mt-5 sticky z-[1] flex items-center gap-3 px-3 pt-6',
22+
'font-semibold text-xs uppercase tracking-wide',
3123
'pb-3', // Add extra padding to make the header fade a bit nicer
3224
'-mb-1.5', // Then pull the page items a bit closer, effective bottom padding is 1.5 units / 6px.
33-
34-
'text-xs',
35-
'tracking-wide',
36-
'font-semibold',
37-
'uppercase',
38-
3925
'[mask-image:linear-gradient(rgba(0,0,0,1)_70%,rgba(0,0,0,0))]', // Fade out effect of fixed page items. We want the fade to start past the header, this is a good approximation.
4026
'bg-tint-base',
4127
'sidebar-filled:bg-tint-subtle',

packages/gitbook/src/components/TableOfContents/PageLinkItem.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,9 @@ export async function PageLinkItem(props: { page: RevisionPageLink; context: Git
5555
'shrink-0',
5656
'text-current',
5757
'transition-colors',
58-
'[&>path]:transition-[opacity]',
59-
'[&>path]:[opacity:0.40]',
60-
'group-hover:[&>path]:[opacity:1]'
58+
'[&>path]:transition-opacity',
59+
'[&>path]:opacity-[0.4]',
60+
'group-hover:[&>path]:opacity-11'
6161
)}
6262
/>
6363
</Link>

packages/gitbook/src/components/TableOfContents/PagesList.tsx

+30-25
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { type RevisionPage, RevisionPageType } from '@gitbook/api';
1+
import type { RevisionPage } from '@gitbook/api';
22
import type { GitBookSiteContext } from '@v2/lib/context';
33

44
import { type ClassValue, tcls } from '@/lib/tailwind';
55

6+
import assertNever from 'assert-never';
67
import { PageDocumentItem } from './PageDocumentItem';
78
import { PageGroupItem } from './PageGroupItem';
89
import { PageLinkItem } from './PageLinkItem';
@@ -16,41 +17,45 @@ export function PagesList(props: {
1617
const { rootPages, pages, context, style } = props;
1718

1819
return (
19-
<ul className={tcls('flex', 'flex-col', 'gap-y-0.5', style)}>
20+
<ul className={tcls('flex flex-col gap-y-0.5', style)}>
2021
{pages.map((page) => {
21-
if (page.type === RevisionPageType.Computed) {
22+
if (page.type === 'computed') {
2223
throw new Error(
2324
'Unexpected computed page, it should have been computed in the API'
2425
);
2526
}
2627

27-
if (page.type === RevisionPageType.Link) {
28-
return <PageLinkItem key={page.id} page={page} context={context} />;
29-
}
30-
3128
if (page.hidden) {
3229
return null;
3330
}
3431

35-
if (page.type === RevisionPageType.Group) {
36-
return (
37-
<PageGroupItem
38-
key={page.id}
39-
rootPages={rootPages}
40-
page={page}
41-
context={context}
42-
/>
43-
);
32+
switch (page.type) {
33+
case 'document':
34+
return (
35+
<PageDocumentItem
36+
key={page.id}
37+
rootPages={rootPages}
38+
page={page}
39+
context={context}
40+
/>
41+
);
42+
43+
case 'link':
44+
return <PageLinkItem key={page.id} page={page} context={context} />;
45+
46+
case 'group':
47+
return (
48+
<PageGroupItem
49+
key={page.id}
50+
rootPages={rootPages}
51+
page={page}
52+
context={context}
53+
/>
54+
);
55+
56+
default:
57+
assertNever(page);
4458
}
45-
46-
return (
47-
<PageDocumentItem
48-
key={page.id}
49-
rootPages={rootPages}
50-
page={page}
51-
context={context}
52-
/>
53-
);
5459
})}
5560
</ul>
5661
);

packages/gitbook/src/components/TableOfContents/TOCScroller.tsx

+69-53
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,96 @@
11
'use client';
22

3-
import React from 'react';
3+
import React, {
4+
useCallback,
5+
useEffect,
6+
useMemo,
7+
useRef,
8+
type ComponentPropsWithoutRef,
9+
} from 'react';
10+
import { assert } from 'ts-essentials';
411

5-
import { type ClassValue, tcls } from '@/lib/tailwind';
12+
interface TOCScrollContainerContextType {
13+
onContainerMount: (listener: (element: HTMLDivElement) => void) => () => void;
14+
}
615

7-
const TOCScrollContainerRefContext = React.createContext<React.RefObject<HTMLDivElement> | null>(
8-
null
9-
);
16+
const TOCScrollContainerContext = React.createContext<TOCScrollContainerContextType | null>(null);
1017

11-
function useTOCScrollContainerRefContext() {
12-
const ctx = React.useContext(TOCScrollContainerRefContext);
13-
if (!ctx) {
14-
throw new Error('Context `TOCScrollContainerRefContext` must be used within Provider');
15-
}
18+
function useTOCScrollContainerContext() {
19+
const ctx = React.useContext(TOCScrollContainerContext);
20+
assert(ctx);
1621
return ctx;
1722
}
1823

19-
export function TOCScrollContainer(props: {
20-
children: React.ReactNode;
21-
className?: ClassValue;
22-
style?: React.CSSProperties;
23-
}) {
24-
const { children, className, style } = props;
25-
const scrollContainerRef = React.createRef<HTMLDivElement>();
24+
/**
25+
* Table of contents scroll container.
26+
*/
27+
export function TOCScrollContainer(props: ComponentPropsWithoutRef<'div'>) {
28+
const ref = useRef<HTMLDivElement>(null);
29+
const listeners = useRef<((element: HTMLDivElement) => void)[]>([]);
30+
const onContainerMount: TOCScrollContainerContextType['onContainerMount'] = useCallback(
31+
(listener) => {
32+
if (ref.current) {
33+
listener(ref.current);
34+
return () => {};
35+
}
36+
listeners.current.push(listener);
37+
return () => {
38+
listeners.current = listeners.current.filter((l) => l !== listener);
39+
};
40+
},
41+
[]
42+
);
43+
const value: TOCScrollContainerContextType = useMemo(
44+
() => ({ onContainerMount }),
45+
[onContainerMount]
46+
);
47+
useEffect(() => {
48+
const element = ref.current;
49+
if (!element) {
50+
return;
51+
}
52+
listeners.current.forEach((listener) => listener(element));
53+
return () => {
54+
listeners.current = [];
55+
};
56+
}, []);
2657

2758
return (
28-
<TOCScrollContainerRefContext.Provider value={scrollContainerRef}>
29-
<div
30-
ref={scrollContainerRef}
31-
data-testid="toc-scroll-container"
32-
className={tcls(className)}
33-
style={style}
34-
>
35-
{children}
36-
</div>
37-
</TOCScrollContainerRefContext.Provider>
59+
<TOCScrollContainerContext.Provider value={value}>
60+
<div ref={ref} data-testid="toc-scroll-container" {...props} />
61+
</TOCScrollContainerContext.Provider>
3862
);
3963
}
4064

4165
// Offset to scroll the table of contents item by.
4266
const TOC_ITEM_OFFSET = 100;
4367

4468
/**
45-
* Scrolls the table of contents container to the page item when it becomes active
69+
* Scrolls the table of contents container to the page item when it's initially active.
4670
*/
4771
export function useScrollToActiveTOCItem(props: {
72+
anchorRef: React.RefObject<HTMLAnchorElement>;
4873
isActive: boolean;
49-
linkRef: React.RefObject<HTMLAnchorElement>;
5074
}) {
51-
const { isActive, linkRef } = props;
52-
const scrollContainerRef = useTOCScrollContainerRefContext();
53-
const isScrolled = React.useRef(false);
54-
React.useLayoutEffect(() => {
55-
if (!isActive) {
56-
isScrolled.current = false;
57-
return;
58-
}
59-
if (isScrolled.current) {
60-
return;
61-
}
62-
const tocItem = linkRef.current;
63-
const tocContainer = scrollContainerRef.current;
64-
if (!tocItem || !tocContainer || !isOutOfView(tocItem, tocContainer)) {
65-
return;
75+
const { isActive, anchorRef } = props;
76+
const isInitialActiveRef = useRef(isActive);
77+
const { onContainerMount } = useTOCScrollContainerContext();
78+
useEffect(() => {
79+
const anchor = anchorRef.current;
80+
if (isInitialActiveRef.current && anchor) {
81+
return onContainerMount((container) => {
82+
if (isOutOfView(anchor, container)) {
83+
container.scrollTo({ top: anchor.offsetTop - TOC_ITEM_OFFSET });
84+
}
85+
});
6686
}
67-
tocContainer?.scrollTo({
68-
top: tocItem.offsetTop - TOC_ITEM_OFFSET,
69-
});
70-
isScrolled.current = true;
71-
}, [isActive, linkRef, scrollContainerRef]);
87+
}, [onContainerMount, anchorRef]);
7288
}
7389

74-
function isOutOfView(tocItem: HTMLElement, tocContainer: HTMLElement) {
75-
const tocItemTop = tocItem.offsetTop;
76-
const containerTop = tocContainer.scrollTop;
77-
const containerBottom = containerTop + tocContainer.clientHeight;
90+
function isOutOfView(element: HTMLElement, container: HTMLElement) {
91+
const tocItemTop = element.offsetTop;
92+
const containerTop = container.scrollTop;
93+
const containerBottom = containerTop + container.clientHeight;
7894
return (
7995
tocItemTop < containerTop + TOC_ITEM_OFFSET ||
8096
tocItemTop > containerBottom - TOC_ITEM_OFFSET

0 commit comments

Comments
 (0)