Skip to content

Commit b42b0b3

Browse files
Add navigation loader and rename context provider (#3642)
Co-authored-by: Nicolas Dorseuil <[email protected]> Co-authored-by: Zeno Kapitein <[email protected]>
1 parent 2fc2127 commit b42b0b3

File tree

6 files changed

+112
-18
lines changed

6 files changed

+112
-18
lines changed

packages/gitbook/src/components/RootLayout/RootLayoutClientContexts.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type React from 'react';
55
import { TranslateContext } from '@/intl/client';
66
import type { TranslationLanguage } from '@/intl/translations';
77
import { TooltipProvider } from '@radix-ui/react-tooltip';
8-
import { HashProvider } from '../hooks';
8+
import { NavigationStatusProvider } from '../hooks';
99
import { LoadingStateProvider } from '../primitives/LoadingStateProvider';
1010

1111
/**
@@ -20,9 +20,9 @@ export function RootLayoutClientContexts(props: {
2020
return (
2121
<TranslateContext.Provider value={language}>
2222
<TooltipProvider delayDuration={200}>
23-
<HashProvider>
23+
<NavigationStatusProvider>
2424
<LoadingStateProvider>{children}</LoadingStateProvider>
25-
</HashProvider>
25+
</NavigationStatusProvider>
2626
</TooltipProvider>
2727
</TranslateContext.Provider>
2828
);

packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { InsightsProvider, VisitorSessionProvider } from '../Insights';
2525
import { SearchContainer } from '../Search';
2626
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
2727
import { CurrentContentProvider } from '../hooks';
28+
import { NavigationLoader } from '../primitives/NavigationLoader';
2829
import { SpaceLayoutContextProvider } from './SpaceLayoutContext';
2930

3031
type SpaceLayoutProps = {
@@ -125,6 +126,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
125126
<SpaceLayoutServerContext {...props}>
126127
<Announcement context={context} />
127128
<Header withTopHeader={withTopHeader} withVariants={withVariants} context={context} />
129+
<NavigationLoader />
128130
{customization.ai?.mode === CustomizationAIMode.Assistant ? (
129131
<AIChat trademark={customization.trademark.enabled} />
130132
) : null}
Lines changed: 71 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
11
'use client';
2+
3+
import { usePathname } from 'next/navigation';
24
import React from 'react';
35

4-
export const HashContext = React.createContext<{
6+
export const NavigationStatusContext = React.createContext<{
57
hash: string | null;
68
/**
7-
* Updates the hash value from the URL provided here.
8-
* It will then be used by the `useHash` hook.
9+
* Updates the navigation state from the URL provided here.
910
* URL can be relative or absolute.
1011
*/
11-
updateHashFromUrl: (href: string) => void;
12+
onNavigationClick: (href: string) => void;
13+
/**
14+
* Indicates if a link has been clicked recently.
15+
* Becomes true after a click and resets to false when pathname changes.
16+
* It is debounced to avoid flickering on fast navigations.
17+
* Debounce time is 400ms (= doherty threshold for responsiveness).
18+
*/
19+
isNavigating: boolean;
1220
}>({
1321
hash: null,
14-
updateHashFromUrl: () => {},
22+
onNavigationClick: () => {},
23+
isNavigating: false,
1524
});
1625

1726
function getHash(): string | null {
@@ -21,20 +30,62 @@ function getHash(): string | null {
2130
return window.location.hash.slice(1);
2231
}
2332

24-
export const HashProvider: React.FC<React.PropsWithChildren<{}>> = ({ children }) => {
33+
export const NavigationStatusProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
2534
const [hash, setHash] = React.useState<string | null>(getHash);
26-
const updateHashFromUrl = React.useCallback((href: string) => {
35+
const [isNavigating, setIsNavigating] = React.useState(false);
36+
const timeoutRef = React.useRef<number | null>(null);
37+
const pathname = usePathname();
38+
const pathnameRef = React.useRef(pathname);
39+
40+
// Reset isNavigating when pathname changes
41+
React.useEffect(() => {
42+
if (pathnameRef.current !== pathname) {
43+
setIsNavigating(false);
44+
if (timeoutRef.current) {
45+
clearTimeout(timeoutRef.current);
46+
timeoutRef.current = null;
47+
}
48+
pathnameRef.current = pathname;
49+
}
50+
}, [pathname]);
51+
52+
// Cleanup timeout on unmount
53+
React.useEffect(() => {
54+
return () => {
55+
if (timeoutRef.current) {
56+
clearTimeout(timeoutRef.current);
57+
}
58+
};
59+
}, []);
60+
61+
const onNavigationClick = React.useCallback((href: string) => {
2762
const url = new URL(
2863
href,
2964
typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
3065
);
3166
setHash(url.hash.slice(1));
67+
68+
if (timeoutRef.current) {
69+
clearTimeout(timeoutRef.current);
70+
}
71+
if (pathnameRef.current !== url.pathname) {
72+
timeoutRef.current = window.setTimeout(() => {
73+
setIsNavigating(true);
74+
timeoutRef.current = null;
75+
return;
76+
}, 400); // 400ms timeout - doherty threshold for responsiveness
77+
}
3278
}, []);
79+
3380
const memoizedValue = React.useMemo(
34-
() => ({ hash, updateHashFromUrl }),
35-
[hash, updateHashFromUrl]
81+
() => ({ hash, onNavigationClick, isNavigating }),
82+
[hash, onNavigationClick, isNavigating]
83+
);
84+
return (
85+
<NavigationStatusContext.Provider value={memoizedValue}>
86+
{children}
87+
</NavigationStatusContext.Provider>
3688
);
37-
return <HashContext.Provider value={memoizedValue}>{children}</HashContext.Provider>;
3889
};
3990

4091
/**
@@ -45,8 +96,16 @@ export const HashProvider: React.FC<React.PropsWithChildren<{}>> = ({ children }
4596
* Since we have a single Link component that handles all links, we can use a context to share the hash.
4697
*/
4798
export function useHash() {
48-
// const params = useParams();
49-
const { hash } = React.useContext(HashContext);
99+
const { hash } = React.useContext(NavigationStatusContext);
50100

51101
return hash;
52102
}
103+
104+
/**
105+
* Hook to get the current navigation state.
106+
* @returns True if a navigation has been triggered recently. False otherwise, it also resets to false when the navigation is complete.
107+
*/
108+
export function useIsNavigating() {
109+
const { isNavigating: hasBeenClicked } = React.useContext(NavigationStatusContext);
110+
return hasBeenClicked;
111+
}

packages/gitbook/src/components/primitives/Link.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import React from 'react';
66
import { tcls } from '@/lib/tailwind';
77
import { SiteExternalLinksTarget } from '@gitbook/api';
88
import { type TrackEventInput, useTrackEvent } from '../Insights';
9-
import { HashContext } from '../hooks';
9+
import { NavigationStatusContext } from '../hooks';
1010
import { isExternalLink } from '../utils/link';
1111
import { type DesignTokenName, useClassnames } from './StyleProvider';
1212

@@ -72,7 +72,7 @@ export const Link = React.forwardRef(function Link(
7272
) {
7373
const { href, prefetch, children, insights, classNames, className, ...domProps } = props;
7474
const { externalLinksTarget } = React.useContext(LinkSettingsContext);
75-
const { updateHashFromUrl } = React.useContext(HashContext);
75+
const { onNavigationClick } = React.useContext(NavigationStatusContext);
7676
const trackEvent = useTrackEvent();
7777
const forwardedClassNames = useClassnames(classNames || []);
7878
const isExternal = isExternalLink(href);
@@ -81,7 +81,7 @@ export const Link = React.forwardRef(function Link(
8181
const onClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
8282
const isExternalWithOrigin = isExternalLink(href, window.location.origin);
8383
if (!isExternal) {
84-
updateHashFromUrl(href);
84+
onNavigationClick(href);
8585
}
8686

8787
if (insights) {
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
'use client';
2+
import { tcls } from '@/lib/tailwind';
3+
import { useIsNavigating } from '../hooks';
4+
5+
export const NavigationLoader = () => {
6+
const isNavigating = useIsNavigating();
7+
8+
return (
9+
<div
10+
className={tcls(
11+
'pointer-events-none fixed inset-x-0 top-0 z-50 h-0.5 overflow-hidden',
12+
isNavigating ? 'block' : 'hidden animate-fade-out-slow'
13+
)}
14+
>
15+
<div className={tcls('h-full w-full origin-left animate-crawl bg-primary-solid')} />
16+
</div>
17+
);
18+
};

packages/gitbook/tailwind.config.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,7 @@ const config: Config = {
329329
exitToRight: 'exitToRight 250ms cubic-bezier(0.83, 0, 0.17, 1) both',
330330

331331
heightIn: 'heightIn 200ms ease both',
332+
crawl: 'crawl 2s ease-in-out infinite',
332333
},
333334
keyframes: {
334335
bounceSmall: {
@@ -498,6 +499,20 @@ const config: Config = {
498499
from: { height: '0' },
499500
to: { height: 'max-content' },
500501
},
502+
crawl: {
503+
'0%': {
504+
scale: '0 1',
505+
translate: '0 0',
506+
},
507+
'40%': {
508+
scale: '1 1',
509+
translate: '100% 0',
510+
},
511+
'100%': {
512+
scale: '0 1',
513+
translate: '100% 0',
514+
},
515+
},
501516
},
502517
boxShadow: {
503518
thinbottom: '0px 1px 0px rgba(0, 0, 0, 0.05)',

0 commit comments

Comments
 (0)