Skip to content

Commit 2c3af5e

Browse files
authored
Improve scrolling with lot of highlighted blocks (#2909)
1 parent 6597f85 commit 2c3af5e

File tree

2 files changed

+88
-31
lines changed

2 files changed

+88
-31
lines changed

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

Lines changed: 39 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
import type { DocumentBlockCode } from '@gitbook/api';
44
import { useEffect, useRef, useState } from 'react';
55

6-
import { useHasBeenInViewport } from '@/components/hooks/useHasBeenInViewport';
7-
6+
import { useInViewportListener } from '@/components/hooks/useInViewportListener';
7+
import { useScrollListener } from '@/components/hooks/useScrollListener';
8+
import { useDebounceCallback, useEventCallback } from 'usehooks-ts';
89
import type { BlockProps } from '../Block';
910
import { CodeBlockRenderer } from './CodeBlockRenderer';
1011
import type { HighlightLine, RenderedInline } from './highlight';
@@ -21,46 +22,53 @@ type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> &
2122
export function ClientCodeBlock(props: ClientBlockProps) {
2223
const { block, style, inlines } = props;
2324
const blockRef = useRef<HTMLDivElement>(null);
25+
const processedRef = useRef(false);
26+
const isInViewportRef = useRef<boolean | null>(null);
2427
const [lines, setLines] = useState<HighlightLine[]>(() => plainHighlight(block, []));
2528

2629
// Preload the highlighter when the block is mounted.
2730
useEffect(() => {
2831
import('./highlight').then(({ preloadHighlight }) => preloadHighlight(block));
2932
}, [block]);
3033

31-
// Check if the block is in the viewport to start highlighting it.
32-
const hasBeenInViewport = useHasBeenInViewport(blockRef, {
33-
rootMargin: '200px',
34+
const runHighlight = useEventCallback(() => {
35+
if (processedRef.current) {
36+
return;
37+
}
38+
if (typeof window !== 'undefined') {
39+
import('./highlight').then(({ highlight }) => {
40+
highlight(block, inlines).then((lines) => {
41+
setLines(lines);
42+
processedRef.current = true;
43+
});
44+
});
45+
}
3446
});
47+
const debouncedRunHighlight = useDebounceCallback(runHighlight, 1000);
3548

36-
// Highlight the block when it's in the viewport.
37-
useEffect(() => {
38-
if (hasBeenInViewport) {
39-
let canceled = false;
40-
import('./highlight').then(({ highlight }) => {
41-
// We use requestIdleCallback to avoid blocking the main thread
42-
// when scrolling.
43-
if (typeof requestIdleCallback === 'function') {
44-
requestIdleCallback(() =>
45-
highlight(block, inlines).then((result) => {
46-
if (!canceled) {
47-
setLines(result);
48-
}
49-
})
50-
);
51-
} else {
52-
highlight(block, inlines).then((result) => {
53-
if (!canceled) {
54-
setLines(result);
55-
}
56-
});
49+
useInViewportListener(
50+
blockRef,
51+
(isInViewport, disconnect) => {
52+
// Disconnect once in viewport
53+
if (isInViewport) {
54+
disconnect();
55+
// If it's initially in viewport, we need to run the highlight
56+
if (isInViewportRef.current === null) {
57+
runHighlight();
5758
}
58-
});
59-
return () => {
60-
canceled = true;
61-
};
59+
}
60+
isInViewportRef.current = isInViewport;
61+
},
62+
{ rootMargin: '200px' }
63+
);
64+
65+
const handleScroll = useDebounceCallback(() => {
66+
if (isInViewportRef.current) {
67+
debouncedRunHighlight();
6268
}
63-
}, [hasBeenInViewport, block, inlines]);
69+
}, 80);
70+
71+
useScrollListener(handleScroll, useRef(typeof window !== 'undefined' ? window : null));
6472

6573
return <CodeBlockRenderer ref={blockRef} block={block} style={style} lines={lines} />;
6674
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
'use client';
2+
import { useEffect, useLayoutEffect, useRef } from 'react';
3+
4+
const HAS_INTERSECTION_OBSERVER = typeof IntersectionObserver !== 'undefined';
5+
6+
/**
7+
* Watch an element to know when it is in the viewport.
8+
*/
9+
export function useInViewportListener(
10+
containerRef: React.RefObject<HTMLElement>,
11+
listener: (isIntersecting: boolean, disconnect: () => void) => void,
12+
options?: Pick<IntersectionObserverInit, 'root' | 'rootMargin' | 'threshold'>
13+
) {
14+
const listenerRef = useRef(listener);
15+
useLayoutEffect(() => {
16+
listenerRef.current = listener;
17+
});
18+
const isIntersectingRef = useRef(false);
19+
useEffect(() => {
20+
// We set the element as visible if the IntersectionObserver API is not available.
21+
// we have to do it in the `useEffect` to be SSR compatible.
22+
if (!HAS_INTERSECTION_OBSERVER) {
23+
listenerRef.current(true, () => {});
24+
return;
25+
}
26+
27+
if (!containerRef.current) {
28+
return;
29+
}
30+
31+
const observer = new IntersectionObserver(
32+
([entry]) => {
33+
isIntersectingRef.current = entry.isIntersecting;
34+
listenerRef.current(entry.isIntersecting, () => {
35+
observer.disconnect();
36+
});
37+
},
38+
{
39+
root: options?.root,
40+
rootMargin: options?.rootMargin,
41+
threshold: options?.threshold,
42+
}
43+
);
44+
45+
observer.observe(containerRef.current);
46+
47+
return () => observer.disconnect();
48+
}, [containerRef, options?.root, options?.rootMargin, options?.threshold]);
49+
}

0 commit comments

Comments
 (0)