Core & UI: Implement selective re-render for TOC

This commit is contained in:
Fuma Nama
2026-04-14 22:54:08 +08:00
parent 9a200c8de6
commit c731a92bba
10 changed files with 367 additions and 226 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'fumadocs-ui': patch
'@fumadocs/base-ui': patch
'fumadocs-core': patch
---
Implement selective re-render for TOC
+74 -57
View File
@@ -135,18 +135,7 @@ export function TOCItems({ ref, className, thumbBox = true, ...props }: TOCItems
return (
<>
{svg && (
<div
className="absolute top-0 inset-s-0"
style={{
width: svg.width,
height: svg.height,
}}
>
<ThumbTrack computed={svg} />
{thumbBox && <ThumbBox computed={svg} />}
</div>
)}
{svg && <ThumbTrack computed={svg} thumbBox={thumbBox} />}
<div
ref={mergeRefs(containerRef, ref)}
className={cn('flex flex-col', className)}
@@ -172,59 +161,87 @@ interface ThumbBoxInfo {
isUp: boolean;
}
function ThumbTrack({ computed }: { computed: ComputedSVG }) {
const items = Primitive.useItems();
const startIdx = items.findIndex((item) => item.active);
if (startIdx === -1) return;
const endIdx = items.findLastIndex((item) => item.active);
const top = `${computed.positions[startIdx][0]}px`;
const bottom = `${computed.positions[endIdx][1]}px`;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={`0 0 ${computed.width} ${computed.height}`}
className="absolute transition-[clip-path]"
style={{
width: computed.width,
height: computed.height,
clipPath: `polygon(0 ${top}, 100% ${top}, 100% ${bottom}, 0 ${bottom})`,
}}
>
{computed.content}
</svg>
);
}
function ThumbBox({ computed }: { computed: ComputedSVG }) {
const items = Primitive.useItems();
function ThumbTrack({ computed, thumbBox }: { computed: ComputedSVG; thumbBox: boolean }) {
const svgRef = useRef<SVGSVGElement>(null);
const boxRef = useRef<HTMLDivElement>(null);
const previousRef = useRef<ThumbBoxInfo>(null);
const startIdx = items.findIndex((item) => item.active);
if (startIdx === -1) return;
const endIdx = items.findLastIndex((item) => item.active);
const tocInfo = Primitive.useTOC();
const onUpdate = useCallback(
(items: Primitive.TOCItemInfo[]) => {
const svg = svgRef.current;
if (!svg) return;
let isUp = false;
if (previousRef.current) {
const prev = previousRef.current;
isUp =
prev.startIdx > startIdx ||
prev.endIdx > endIdx ||
(prev.startIdx === startIdx && prev.endIdx === endIdx && prev.isUp);
}
const startIdx = items.findIndex((item) => item.active);
if (startIdx === -1) return;
previousRef.current = { startIdx, endIdx, isUp };
const endIdx = items.findLastIndex((item) => item.active);
svg.style.setProperty('--track-top', `${computed.positions[startIdx][0]}px`);
svg.style.setProperty('--track-bottom', `${computed.positions[endIdx][1]}px`);
const box = boxRef.current;
if (box) {
let isUp = false;
if (previousRef.current) {
const prev = previousRef.current;
isUp =
prev.startIdx > startIdx ||
prev.endIdx > endIdx ||
(prev.startIdx === startIdx && prev.endIdx === endIdx && prev.isUp);
}
previousRef.current = { startIdx, endIdx, isUp };
box.style.setProperty(
'--offset-distance',
isUp
? `${computed.itemLineLengths[startIdx][0]}px`
: `${computed.itemLineLengths[endIdx][1]}px`,
);
box.style.setProperty(
'--opacity',
items[isUp ? startIdx : endIdx].original._step !== undefined ? '0' : '1',
);
}
},
[computed],
);
Primitive.useTOCListener(onUpdate);
useEffect(() => {
onUpdate(tocInfo.get());
}, [onUpdate, tocInfo]);
return (
<div
className="absolute size-1 bg-fd-primary rounded-full transition-[offset-distance]"
className="absolute top-0 inset-s-0"
style={{
offsetPath: `path("${computed.d}")`,
offsetDistance: isUp
? computed.itemLineLengths[startIdx][0]
: computed.itemLineLengths[endIdx][1],
scale: items[isUp ? startIdx : endIdx].original._step !== undefined ? '0' : '1',
width: computed.width,
height: computed.height,
}}
/>
>
<svg
ref={svgRef}
xmlns="http://www.w3.org/2000/svg"
viewBox={`0 0 ${computed.width} ${computed.height}`}
className="absolute transition-[clip-path]"
style={{
width: computed.width,
height: computed.height,
clipPath: `polygon(0 var(--track-top,0), 100% var(--track-top,0), 100% var(--track-bottom,0), 0 var(--track-bottom,0))`,
}}
>
{computed.content}
</svg>
{thumbBox && (
<div
ref={boxRef}
className="absolute size-1 bg-fd-primary rounded-full [offset-distance:var(--offset-distance,0)] opacity-(--opacity,0) transition-[opacity,offset-distance]"
style={{
offsetPath: `path("${computed.d}")`,
}}
/>
)}
</div>
);
}
@@ -67,18 +67,34 @@ export function TOCItems({ ref, className, ...props }: TOCItemsProps) {
}
function TocThumb({ computed }: { computed: ComputedData }) {
const items = Primitive.useItems();
const startIdx = items.findIndex((item) => item.active);
if (startIdx === -1) return;
const endIdx = items.findLastIndex((item) => item.active);
const top = `${computed.positions[startIdx][0]}px`;
const bottom = `${computed.positions[endIdx][1]}px`;
const ref = useRef<HTMLDivElement>(null);
const tocInfo = Primitive.useTOC();
const onUpdate = useCallback(
(items: Primitive.TOCItemInfo[]) => {
const element = ref.current;
if (!element) return;
const startIdx = items.findIndex((item) => item.active);
if (startIdx === -1) return;
const endIdx = items.findLastIndex((item) => item.active);
element.style.setProperty('--track-top', `${computed.positions[startIdx][0]}px`);
element.style.setProperty('--track-bottom', `${computed.positions[endIdx][1]}px`);
},
[computed],
);
Primitive.useTOCListener(onUpdate);
useEffect(() => {
onUpdate(tocInfo.get());
}, [onUpdate, tocInfo]);
return (
<div
ref={ref}
className="absolute inset-y-0 inset-s-0 bg-fd-primary w-px transition-[clip-path]"
style={{
clipPath: `polygon(0 ${top}, 100% ${top}, 100% ${bottom}, 0 ${bottom})`,
clipPath: `polygon(0 var(--track-top,0), 100% var(--track-top,0), 100% var(--track-bottom,0), 0 var(--track-bottom,0))`,
}}
/>
);
+150 -81
View File
@@ -6,12 +6,14 @@ import {
type RefObject,
use,
useEffect,
useEffectEvent,
useMemo,
useRef,
useState,
} from 'react';
import scrollIntoView from 'scroll-into-view-if-needed';
import { mergeRefs } from '@/utils/merge-refs';
import { isEqualShallow } from './utils/is-equal';
export interface TOCItemType {
title: ReactNode;
@@ -33,7 +35,7 @@ export interface TOCItemInfo {
original: TOCItemType;
}
const ItemsContext = createContext<TOCItemInfo[] | null>(null);
const ObserverContext = createContext<Observer | null>(null);
const ScrollContext = createContext<RefObject<HTMLElement | null> | null>(null);
export interface AnchorProviderProps {
@@ -63,7 +65,6 @@ export function ScrollProvider({ containerRef, children }: ScrollProviderProps)
export function AnchorProvider({ toc, single = false, children }: AnchorProviderProps) {
const observer = useMemo(() => new Observer(), []);
const [items, setItems] = useState<TOCItemInfo[]>(observer.items);
observer.single = single;
useEffect(() => {
@@ -72,17 +73,13 @@ export function AnchorProvider({ toc, single = false, children }: AnchorProvider
useEffect(() => {
observer.watch({
rootMargin: '0px',
threshold: 0.98,
threshold: 0.9,
});
observer.onChange = () => setItems(observer.items);
return () => {
observer.unwatch();
};
return () => observer.unwatch();
}, [observer]);
return <ItemsContext value={items}>{children}</ItemsContext>;
return <ObserverContext value={observer}>{children}</ObserverContext>;
}
export interface TOCItemProps extends ComponentProps<'a'> {
@@ -91,48 +88,106 @@ export interface TOCItemProps extends ComponentProps<'a'> {
export function TOCItem({ ref, onActiveChange = () => null, ...props }: TOCItemProps) {
const id = props.href ? getItemId(props.href) : null;
const items = useItems();
const itemData = id ? items.find((item) => item.id === id) : null;
const containerRef = use(ScrollContext);
const isInitialMountRef = useRef(true);
const anchorRef = useRef<HTMLAnchorElement>(null);
const [active, setActive] = useState(() => itemData != null && itemData.active);
const observer = useObserver();
const [active, setActive] = useState(() =>
observer.items.some((item) => item.id === id && item.active),
);
if (itemData && itemData.active !== active) {
setActive(itemData.active);
onActiveChange(itemData.active);
}
const shouldScroll =
itemData?.active && items.every((item) => !item.active || item.t <= itemData.t);
useEffect(() => {
function autoScroll(items: TOCItemInfo[], instant = false) {
const anchor = anchorRef.current;
const container = containerRef?.current;
if (!id || !anchor || !container) return;
if (shouldScroll && container && anchor) {
let lastActive: TOCItemInfo | undefined;
for (const item of items) {
if (!item.active) continue;
if (!lastActive || lastActive.t < item.t) {
lastActive = item;
}
}
if (lastActive?.id === id) {
scrollIntoView(anchor, {
behavior: isInitialMountRef.current ? 'instant' : 'smooth',
behavior: instant ? 'instant' : 'smooth',
block: 'center',
inline: 'center',
scrollMode: 'always',
boundary: container,
});
}
}
isInitialMountRef.current = false;
}, [shouldScroll, containerRef]);
useTOCListener((items) => {
const itemData = id ? items.find((item) => item.id === id) : null;
if (itemData && itemData.active !== active) {
setActive(itemData.active);
onActiveChange(itemData.active);
autoScroll(items);
}
});
useEffect(() => {
autoScroll(observer.items, true);
// oxlint-disable-next-line eslint-plugin-react-hooks/exhaustive-deps -- initial only
}, [observer]);
return <a ref={mergeRefs(anchorRef, ref)} data-active={active} {...props} />;
}
function useObserver() {
const observer = use(ObserverContext);
if (!observer) throw new Error(`Component must be used under the <AnchorProvider /> component.`);
return observer;
}
/** @returns static info object, useful for custom rendering logic */
export function useTOC() {
const observer = useObserver();
return useMemo(
() => ({
get() {
return observer.items;
},
listen: observer.listen.bind(observer),
unlisten: observer.unlisten.bind(observer),
}),
[observer],
);
}
export function useTOCListener(listener: ChangeListener) {
const observer = useObserver();
const callback = useEffectEvent(listener);
useEffect(() => {
observer.listen(callback);
return () => observer.unlisten(callback);
}, [observer]);
}
export function useTOCSelector<T>(
select: (items: TOCItemInfo[]) => T,
isEqual: (a: T, b: T) => boolean = isEqualShallow,
) {
const observer = useObserver();
const [value, setValue] = useState<T>(() => select(observer.items));
useTOCListener((items) => {
const next = select(items);
if (!isEqual(value, next)) setValue(next);
});
return value;
}
/**
* The estimated active heading ID
*/
export function useActiveAnchor(): string | undefined {
const items = useItems();
return useMemo(() => {
return useTOCSelector((items) => {
let out: TOCItemInfo | undefined;
for (const item of items) {
if (!item.active) continue;
@@ -143,27 +198,24 @@ export function useActiveAnchor(): string | undefined {
}
return out?.id;
}, [items]);
});
}
/**
* The id of visible anchors
*/
export function useActiveAnchors(): string[] {
const items = useItems();
return useMemo(() => {
return useTOCSelector((items) => {
const out: string[] = [];
for (const item of items) {
if (item.active) out.push(item.id);
}
return out;
}, [items]);
});
}
export function useItems() {
const ctx = use(ItemsContext);
if (!ctx) throw new Error(`Component must be used under the <AnchorProvider /> component.`);
return ctx;
export function useItems(): TOCItemInfo[] {
return useTOCSelector((items) => items);
}
function getItemId(url: string) {
@@ -171,17 +223,67 @@ function getItemId(url: string) {
return null;
}
type ChangeListener = (items: TOCItemInfo[]) => void;
class Observer {
items: TOCItemInfo[] = [];
single = false;
private observer: IntersectionObserver | null = null;
onChange?: () => void;
private listeners = new Set<ChangeListener>();
listen(listener: ChangeListener) {
this.listeners.add(listener);
}
unlisten(listener: ChangeListener) {
this.listeners.delete(listener);
}
setItems(newItems: TOCItemType[]) {
const observer = this.observer;
if (observer) {
for (const item of this.items) {
const element = document.getElementById(item.id);
if (!element) continue;
observer.unobserve(element);
}
}
const next: TOCItemInfo[] = [];
for (const item of newItems) {
const id = getItemId(item.url);
if (!id) continue;
next.push({
id,
active: false,
fallback: false,
t: 0,
original: item,
});
}
this.update(next);
this.observeItems();
}
watch(options?: IntersectionObserverInit) {
if (this.observer) return;
this.observer = new IntersectionObserver(this.callback.bind(this), options);
this.observeItems();
}
unwatch() {
this.observer?.disconnect();
this.observer = null;
}
private callback(entries: IntersectionObserverEntry[]) {
if (entries.length === 0) return;
let hasActive = false;
this.items = this.items.map((item) => {
const updated = this.items.map((item) => {
const entry = entries.find((entry) => entry.target.id === item.id);
let active = entry ? entry.isIntersecting : item.active && !item.fallback;
if (this.single && hasActive) active = false;
@@ -204,8 +306,8 @@ class Observer {
let min = Number.MAX_VALUE;
let fallbackIdx = -1;
for (let i = 0; i < this.items.length; i++) {
const element = document.getElementById(this.items[i].id);
for (let i = 0; i < updated.length; i++) {
const element = document.getElementById(updated[i].id);
if (!element) continue;
const d = Math.abs(viewTop - element.getBoundingClientRect().top);
@@ -216,8 +318,8 @@ class Observer {
}
if (fallbackIdx !== -1) {
this.items[fallbackIdx] = {
...this.items[fallbackIdx],
updated[fallbackIdx] = {
...updated[fallbackIdx],
active: true,
fallback: true,
t: Date.now(),
@@ -225,43 +327,10 @@ class Observer {
}
}
this.onChange?.();
this.update(updated);
}
setItems(newItems: TOCItemType[]) {
const observer = this.observer;
if (observer) {
for (const item of this.items) {
const element = document.getElementById(item.id);
if (!element) continue;
observer.unobserve(element);
}
}
this.items = [];
for (const item of newItems) {
const id = getItemId(item.url);
if (!id) continue;
this.items.push({
id,
active: false,
fallback: false,
t: 0,
original: item,
});
}
this.watchItems();
}
watch(options?: IntersectionObserverInit) {
if (this.observer) return;
this.observer = new IntersectionObserver(this.callback.bind(this), options);
this.watchItems();
}
private watchItems() {
private observeItems() {
if (!this.observer) return;
for (const item of this.items) {
const element = document.getElementById(item.id);
@@ -270,8 +339,8 @@ class Observer {
}
}
unwatch() {
this.observer?.disconnect();
this.observer = null;
private update(next: TOCItemInfo[]) {
this.items = next;
for (const listener of this.listeners) listener(next);
}
}
+9
View File
@@ -0,0 +1,9 @@
export function isEqualShallow(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (Array.isArray(a) && Array.isArray(b)) {
return b.length === a.length && a.every((v, i) => isEqualShallow(v, b[i]));
}
return false;
}
+2 -9
View File
@@ -1,12 +1,5 @@
import { useState } from 'react';
function isDifferent(a: unknown, b: unknown): boolean {
if (Array.isArray(a) && Array.isArray(b)) {
return b.length !== a.length || a.some((v, i) => isDifferent(v, b[i]));
}
return a !== b;
}
import { isEqualShallow } from './is-equal';
/**
* @param value - state to watch
@@ -16,7 +9,7 @@ function isDifferent(a: unknown, b: unknown): boolean {
export function useOnChange<T>(
value: T,
onChange: (current: T, previous: T) => void,
isUpdated: (prev: T, current: T) => boolean = isDifferent,
isUpdated: (prev: T, current: T) => boolean = (a, b) => !isEqualShallow(a, b),
): void {
const [prev, setPrev] = useState<T>(value);
+1 -1
View File
@@ -11,7 +11,7 @@ async function compileConfig(core: Core) {
entryPoints: [{ in: configPath, out: 'source.config' }],
bundle: true,
outdir: outDir,
target: 'node20',
target: 'node22',
write: true,
platform: 'node',
format: 'esm',
+4 -7
View File
@@ -1,3 +1,4 @@
import { loadConfig } from '@/config/load-from-file';
import type { Core } from '@/core';
import fs from 'node:fs/promises';
@@ -43,13 +44,9 @@ export function createStandaloneConfigLoader({
if (!prev || hash !== prev.hash) {
prev = {
hash,
init: (async () => {
const { loadConfig } = await import('../config/load-from-file');
await core.init({
config: loadConfig(core, buildConfig),
});
})(),
init: core.init({
config: loadConfig(core, buildConfig),
}),
};
}
+74 -57
View File
@@ -135,18 +135,7 @@ export function TOCItems({ ref, className, thumbBox = true, ...props }: TOCItems
return (
<>
{svg && (
<div
className="absolute top-0 inset-s-0"
style={{
width: svg.width,
height: svg.height,
}}
>
<ThumbTrack computed={svg} />
{thumbBox && <ThumbBox computed={svg} />}
</div>
)}
{svg && <ThumbTrack computed={svg} thumbBox={thumbBox} />}
<div
ref={mergeRefs(containerRef, ref)}
className={cn('flex flex-col', className)}
@@ -172,59 +161,87 @@ interface ThumbBoxInfo {
isUp: boolean;
}
function ThumbTrack({ computed }: { computed: ComputedSVG }) {
const items = Primitive.useItems();
const startIdx = items.findIndex((item) => item.active);
if (startIdx === -1) return;
const endIdx = items.findLastIndex((item) => item.active);
const top = `${computed.positions[startIdx][0]}px`;
const bottom = `${computed.positions[endIdx][1]}px`;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={`0 0 ${computed.width} ${computed.height}`}
className="absolute transition-[clip-path]"
style={{
width: computed.width,
height: computed.height,
clipPath: `polygon(0 ${top}, 100% ${top}, 100% ${bottom}, 0 ${bottom})`,
}}
>
{computed.content}
</svg>
);
}
function ThumbBox({ computed }: { computed: ComputedSVG }) {
const items = Primitive.useItems();
function ThumbTrack({ computed, thumbBox }: { computed: ComputedSVG; thumbBox: boolean }) {
const svgRef = useRef<SVGSVGElement>(null);
const boxRef = useRef<HTMLDivElement>(null);
const previousRef = useRef<ThumbBoxInfo>(null);
const startIdx = items.findIndex((item) => item.active);
if (startIdx === -1) return;
const endIdx = items.findLastIndex((item) => item.active);
const tocInfo = Primitive.useTOC();
const onUpdate = useCallback(
(items: Primitive.TOCItemInfo[]) => {
const svg = svgRef.current;
if (!svg) return;
let isUp = false;
if (previousRef.current) {
const prev = previousRef.current;
isUp =
prev.startIdx > startIdx ||
prev.endIdx > endIdx ||
(prev.startIdx === startIdx && prev.endIdx === endIdx && prev.isUp);
}
const startIdx = items.findIndex((item) => item.active);
if (startIdx === -1) return;
previousRef.current = { startIdx, endIdx, isUp };
const endIdx = items.findLastIndex((item) => item.active);
svg.style.setProperty('--track-top', `${computed.positions[startIdx][0]}px`);
svg.style.setProperty('--track-bottom', `${computed.positions[endIdx][1]}px`);
const box = boxRef.current;
if (box) {
let isUp = false;
if (previousRef.current) {
const prev = previousRef.current;
isUp =
prev.startIdx > startIdx ||
prev.endIdx > endIdx ||
(prev.startIdx === startIdx && prev.endIdx === endIdx && prev.isUp);
}
previousRef.current = { startIdx, endIdx, isUp };
box.style.setProperty(
'--offset-distance',
isUp
? `${computed.itemLineLengths[startIdx][0]}px`
: `${computed.itemLineLengths[endIdx][1]}px`,
);
box.style.setProperty(
'--opacity',
items[isUp ? startIdx : endIdx].original._step !== undefined ? '0' : '1',
);
}
},
[computed],
);
Primitive.useTOCListener(onUpdate);
useEffect(() => {
onUpdate(tocInfo.get());
}, [onUpdate, tocInfo]);
return (
<div
className="absolute size-1 bg-fd-primary rounded-full transition-[offset-distance]"
className="absolute top-0 inset-s-0"
style={{
offsetPath: `path("${computed.d}")`,
offsetDistance: isUp
? computed.itemLineLengths[startIdx][0]
: computed.itemLineLengths[endIdx][1],
scale: items[isUp ? startIdx : endIdx].original._step !== undefined ? '0' : '1',
width: computed.width,
height: computed.height,
}}
/>
>
<svg
ref={svgRef}
xmlns="http://www.w3.org/2000/svg"
viewBox={`0 0 ${computed.width} ${computed.height}`}
className="absolute transition-[clip-path]"
style={{
width: computed.width,
height: computed.height,
clipPath: `polygon(0 var(--track-top,0), 100% var(--track-top,0), 100% var(--track-bottom,0), 0 var(--track-bottom,0))`,
}}
>
{computed.content}
</svg>
{thumbBox && (
<div
ref={boxRef}
className="absolute size-1 bg-fd-primary rounded-full [offset-distance:var(--offset-distance,0)] opacity-(--opacity,0) transition-[opacity,offset-distance]"
style={{
offsetPath: `path("${computed.d}")`,
}}
/>
)}
</div>
);
}
@@ -67,18 +67,34 @@ export function TOCItems({ ref, className, ...props }: TOCItemsProps) {
}
function TocThumb({ computed }: { computed: ComputedData }) {
const items = Primitive.useItems();
const startIdx = items.findIndex((item) => item.active);
if (startIdx === -1) return;
const endIdx = items.findLastIndex((item) => item.active);
const top = `${computed.positions[startIdx][0]}px`;
const bottom = `${computed.positions[endIdx][1]}px`;
const ref = useRef<HTMLDivElement>(null);
const tocInfo = Primitive.useTOC();
const onUpdate = useCallback(
(items: Primitive.TOCItemInfo[]) => {
const element = ref.current;
if (!element) return;
const startIdx = items.findIndex((item) => item.active);
if (startIdx === -1) return;
const endIdx = items.findLastIndex((item) => item.active);
element.style.setProperty('--track-top', `${computed.positions[startIdx][0]}px`);
element.style.setProperty('--track-bottom', `${computed.positions[endIdx][1]}px`);
},
[computed],
);
Primitive.useTOCListener(onUpdate);
useEffect(() => {
onUpdate(tocInfo.get());
}, [onUpdate, tocInfo]);
return (
<div
ref={ref}
className="absolute inset-y-0 inset-s-0 bg-fd-primary w-px transition-[clip-path]"
style={{
clipPath: `polygon(0 ${top}, 100% ${top}, 100% ${bottom}, 0 ${bottom})`,
clipPath: `polygon(0 var(--track-top,0), 100% var(--track-top,0), 100% var(--track-bottom,0), 0 var(--track-bottom,0))`,
}}
/>
);