11"use client"
22
3- import React , { useState , useEffect , memo , useRef , useCallback } from 'react' ;
3+ import React , { useState , useEffect , useLayoutEffect , memo , useRef , useCallback } from 'react' ;
44import Link from 'next/link' ;
55import { usePathname , useRouter } from 'next/navigation' ;
66import { cn } from '@/lib/utils' ;
@@ -18,37 +18,49 @@ const SidebarActiveSync = ({
1818 currentPathProp ?: string ;
1919} ) => {
2020 const pathname = usePathname ( ) ;
21+ const prevActiveLinksRef = useRef < Set < Element > > ( new Set ( ) ) ;
2122
22- useEffect ( ( ) => {
23+ useLayoutEffect ( ( ) => {
2324 const navElement = navRef . current ;
2425 if ( ! navElement ) return ;
2526
2627 const currentPathValue = currentPathProp ?? pathname ;
2728 pathnameRef . current = currentPathValue ;
2829
29- // Update active states via DOM manipulation (no React state updates)
30- navElement . querySelectorAll ( '[data-nav-href]' ) . forEach ( ( link ) => {
30+ // Build next active set first, then only mutate changed links to avoid blink.
31+ const allLinks = Array . from ( navElement . querySelectorAll ( '[data-nav-href]' ) ) ;
32+ const nextActiveLinks = new Set < Element > ( ) ;
33+
34+ allLinks . forEach ( ( link ) => {
35+ const href = link . getAttribute ( 'data-nav-href' ) ;
36+ if ( ! href ) return ;
37+ if ( href === currentPathValue ) {
38+ nextActiveLinks . add ( link ) ;
39+ return ;
40+ }
41+ if ( href === '/docs' && currentPathValue !== '/docs' ) return ;
42+ if ( currentPathValue . startsWith ( href + '/' ) ) {
43+ nextActiveLinks . add ( link ) ;
44+ }
45+ } ) ;
46+
47+ const prevActiveLinks = prevActiveLinksRef . current ;
48+
49+ // Deactivate only links that are no longer active.
50+ prevActiveLinks . forEach ( ( link ) => {
51+ if ( nextActiveLinks . has ( link ) ) return ;
3152 link . classList . remove ( 'bg-blue-50' , 'text-blue-600' , 'font-medium' ) ;
3253 link . classList . add ( 'text-gray-700' , 'hover:bg-gray-100' , 'hover:text-gray-900' ) ;
3354 } ) ;
3455
35- // Add active class to current active items (exact match)
36- const activeLinks = navElement . querySelectorAll ( `[data-nav-href=" ${ currentPathValue } "]` ) ;
37- activeLinks . forEach ( ( link ) => {
56+ // Activate only newly active links.
57+ nextActiveLinks . forEach ( ( link ) => {
58+ if ( prevActiveLinks . has ( link ) ) return ;
3859 link . classList . add ( 'bg-blue-50' , 'text-blue-600' , 'font-medium' ) ;
3960 link . classList . remove ( 'text-gray-700' , 'hover:bg-gray-100' , 'hover:text-gray-900' ) ;
4061 } ) ;
4162
42- // Also handle sub-path matching
43- navElement . querySelectorAll ( '[data-nav-href]' ) . forEach ( ( link ) => {
44- const href = link . getAttribute ( 'data-nav-href' ) ;
45- if ( ! href || href === currentPathValue ) return ;
46- if ( href === '/docs' && currentPathValue !== '/docs' ) return ;
47- if ( currentPathValue . startsWith ( href + '/' ) ) {
48- link . classList . add ( 'bg-blue-50' , 'text-blue-600' , 'font-medium' ) ;
49- link . classList . remove ( 'text-gray-700' , 'hover:bg-gray-100' , 'hover:text-gray-900' ) ;
50- }
51- } ) ;
63+ prevActiveLinksRef . current = nextActiveLinks ;
5264 } , [ pathname , currentPathProp , navRef , pathnameRef ] ) ;
5365
5466 return null ;
@@ -134,9 +146,9 @@ const DocSidebarComponent: React.FC<DocSidebarProps> = ({
134146 router . prefetch ( href ) ;
135147 } , [ router ] ) ;
136148
137- // After mount: restore expanded state from localStorage and ensure current route is visible.
138- // This will cause at most ONE re-render after hydration (acceptable) and prevents hydration mismatch .
139- useEffect ( ( ) => {
149+ // Restore expanded state before paint to avoid visible collapse->expand flicker
150+ // when this component remounts during route transitions .
151+ useLayoutEffect ( ( ) => {
140152 if ( typeof window === 'undefined' ) return ;
141153
142154 const setsEqual = ( a : Set < string > , b : Set < string > ) => {
@@ -303,10 +315,13 @@ const DocSidebarComponent: React.FC<DocSidebarProps> = ({
303315 const isProject = pathSegments . length === 3 && pathSegments [ 0 ] === 'docs' && pathSegments [ 1 ] === 'projects' ;
304316 // Documents under projects have 4+ segments: /docs/projects/{projectId}/{docId}
305317 const isProjectDocument = pathSegments . length >= 4 && pathSegments [ 0 ] === 'docs' && pathSegments [ 1 ] === 'projects' ;
318+ const itemKey = isCollapsibleHeader
319+ ? `header-${ level } -${ item . label } `
320+ : `${ item . href } -${ level } ` ;
306321
307322 return (
308323 < div
309- key = { item . label }
324+ key = { itemKey }
310325 className = { cn (
311326 level > 0 && level === 1 && ! isProject && 'ml-1' ,
312327 level > 0 && level === 1 && isProject && 'ml-4' ,
0 commit comments