Compare commits

...

2 Commits

Author SHA1 Message Date
Claude b8e7ec1967 changeset 2026-07-16 00:25:39 +00:00
Claude c7f0daa4d3 Fix TOC groups losing expand/active state during client-side navigation
The sidebar table of contents lives in the site layout, whose client subtree is
remounted on client-side navigation (already documented in useClearRouterCache,
Next.js #67542). PageGroupItem and ToggleableLinkItem kept their expand/collapse
state in component-local useState/useRef, so every navigation reset it: top-level
groups snapped back open, nested groups re-derived from the freshly-mounting active
path, and the descendants' enter animation replayed each time - which read as the
sidebar flickering / losing its expanded context.

Move the groups' open/collapsed state into a module-level store keyed by page id so
it survives the navigation remount (mirroring the existing module-level workaround in
useClearRouterCache and the zustand store in useCurrentPage). Until the visitor
toggles a group it follows the active page; once toggled, their choice is remembered
for the session. Also set AnimatePresence initial={false} so an already-open group
does not replay its expand animation on mount.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DaBLy53zu9DTce9sZhaARf
2026-07-16 00:25:36 +00:00
5 changed files with 76 additions and 36 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix the table-of-contents feeling unstable: sidebar groups no longer expand/collapse or lose their expanded context when navigating between pages.
@@ -15,6 +15,7 @@ export function PageDocumentItem(props: { page: ClientTOCPageDocument }) {
return (
<li className="page-document-item flex flex-col [.page-group-item+&]:mt-4">
<ToggleableLinkItem
id={page.id}
href={page.href ?? '#'}
pathnames={page.pathnames}
insights={{
@@ -9,12 +9,13 @@ import { ToggleChevron } from '../primitives';
import { PagesList } from './PagesList';
import { TOCPageIcon } from './TOCPageIcon';
import { ToCButtonItemStyles } from './styles';
import { useTOCGroupState } from './useTOCGroupState';
export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boolean }) {
const { page, isFirst } = props;
const descendants = page.descendants ?? [];
const hasDescendants = descendants.length > 0;
const [isOpen, setIsOpen] = React.useState(true);
const [isOpen, setIsOpen] = useTOCGroupState(page.id, true);
const { sentinelRef, isSticking } = useIsSticking();
const handleToggle = () => {
@@ -22,7 +23,7 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
return;
}
setIsOpen((prev) => !prev);
setIsOpen(!isOpen);
};
return (
@@ -1,14 +1,16 @@
'use client';
import { AnimatePresence, motion } from 'motion/react';
import React, { useRef } from 'react';
import type React from 'react';
import { useCurrentPagePath } from '../hooks';
import { Button, Link, type LinkInsightsProps, type LinkProps, ToggleChevron } from '../primitives';
import { useTOCGroupState } from './useTOCGroupState';
/**
* Client component for a page document to toggle its children and be marked as active.
*/
export function ToggleableLinkItem(
props: {
id: string;
href: string;
pathnames: string[];
children: React.ReactNode;
@@ -17,30 +19,15 @@ export function ToggleableLinkItem(
tag?: React.ReactNode;
} & LinkInsightsProps
) {
const { href, children, descendants, pathnames, insights, icon, tag } = props;
const { id, href, children, descendants, pathnames, insights, icon, tag } = props;
const currentPagePath = useCurrentPagePath();
const isActive = pathnames.some((pathname) => pathname === currentPagePath);
// Auto-expand to reveal the active page; the store keeps this open across navigations and
// remembers the visitor's own toggles instead of re-deriving (and flickering) on every remount.
const defaultIsOpen =
isActive || pathnames.some((pathname) => currentPagePath.startsWith(`${pathname}/`));
const [isOpen, setIsOpen] = React.useState(defaultIsOpen);
const hasBeenToggled = useRef(false);
// Update the visibility of the children if one of the descendants becomes active.
React.useEffect(() => {
if (defaultIsOpen && !hasBeenToggled.current) {
setIsOpen(defaultIsOpen);
}
}, [defaultIsOpen]);
const handleToggle = (newState: boolean | ((prev: boolean) => boolean)) => {
hasBeenToggled.current = true;
if (typeof newState === 'function') {
setIsOpen(newState);
} else {
setIsOpen(newState);
}
};
const [isOpen, setIsOpen] = useTOCGroupState(id, defaultIsOpen);
if (!descendants) {
return (
@@ -59,14 +46,18 @@ export function ToggleableLinkItem(
}
return (
<DescendantsRenderer descendants={descendants} isOpen={isOpen} setIsOpen={handleToggle}>
<DescendantsRenderer
descendants={descendants}
isOpen={isOpen}
onToggle={() => setIsOpen(!isOpen)}
>
{({ descendants, toggler }) => (
<>
<LinkItem
href={href}
insights={insights}
isActive={isActive}
onActiveClick={() => handleToggle(!isOpen)}
onActiveClick={() => setIsOpen(!isOpen)}
>
{icon}
{tag ? (
@@ -126,24 +117,16 @@ function LinkItem(
function DescendantsRenderer(props: {
descendants: React.ReactNode;
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
onToggle: () => void;
children: (renderProps: {
descendants: React.ReactNode;
toggler: React.ReactNode;
}) => React.ReactNode;
}) {
const { descendants, isOpen, setIsOpen } = props;
const { descendants, isOpen, onToggle } = props;
return props.children({
toggler: (
<Toggler
isLinkActive={isOpen}
isOpen={isOpen}
onToggle={() => {
setIsOpen((prev) => !prev);
}}
/>
),
toggler: <Toggler isLinkActive={isOpen} isOpen={isOpen} onToggle={onToggle} />,
descendants: <Descendants isVisible={isOpen}>{descendants}</Descendants>,
});
}
@@ -193,8 +176,11 @@ function Descendants(props: {
children: React.ReactNode;
}) {
const { isVisible, children } = props;
// `initial={false}` renders an already-open group without replaying the expand animation on
// mount: the layout remounts on navigation, and animating every time looked like the sidebar
// flickering. Visitor-initiated toggles still animate since they happen after mount.
return (
<AnimatePresence>
<AnimatePresence initial={false}>
{isVisible ? (
<motion.div
initial={hide}
@@ -0,0 +1,47 @@
'use client';
import { useCallback } from 'react';
import * as zustand from 'zustand';
type TOCGroupState = {
isOpen: boolean;
/** Whether the visitor toggled the group themselves, as opposed to it following the active page. */
userToggled: boolean;
};
// The table of contents lives in the site layout, whose client subtree is remounted on
// client-side navigation (see useClearRouterCache and https://github.com/vercel/next.js/issues/67542).
// Keeping each group's open/closed state in component-local `useState`/`useRef` therefore resets it on
// every navigation, making the sidebar expand/collapse or lose its expanded context as visitors move
// between pages. We keep the state in a module-level store so it survives those remounts.
const useStore = zustand.create<{
groups: Record<string, TOCGroupState>;
setOpen: (id: string, isOpen: boolean) => void;
}>((set) => ({
groups: {},
setOpen: (id, isOpen) =>
set((state) => ({
groups: { ...state.groups, [id]: { isOpen, userToggled: true } },
})),
}));
/**
* Persisted open/collapsed state for a collapsible table-of-contents group.
*
* Until the visitor toggles the group, it follows `defaultOpen` (e.g. the group containing the
* active page). Once toggled, their choice is remembered across navigations for the session, so
* navigating between pages no longer resets the sidebar.
*/
export function useTOCGroupState(
id: string,
defaultOpen: boolean
): [boolean, (isOpen: boolean) => void] {
const stored = useStore((state) => state.groups[id]);
const setOpen = useStore((state) => state.setOpen);
const isOpen = stored?.userToggled ? stored.isOpen : defaultOpen;
const setIsOpen = useCallback((next: boolean) => setOpen(id, next), [id, setOpen]);
return [isOpen, setIsOpen];
}