Skip to content

Commit

Permalink
fix: make scroll position in sidebar stable between client side navig…
Browse files Browse the repository at this point in the history
…ation (#4296)

* fix: sidebar is scrolled on client side navigation

* improve comment

* more

* upd

* upd

* upd

* fixes

* Update .changeset/silver-numbers-call.md

* Update packages/nextra-theme-docs/src/components/sidebar.tsx

* Update packages/nextra-theme-docs/src/components/sidebar.tsx

* oops
  • Loading branch information
dimaMachina authored Mar 3, 2025
1 parent 05a202d commit fd4e6d1
Show file tree
Hide file tree
Showing 11 changed files with 83 additions and 78 deletions.
5 changes: 5 additions & 0 deletions .changeset/silver-numbers-call.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nextra-theme-docs": patch
---

fix: make scroll position in sidebar stable between client-side navigation
5 changes: 1 addition & 4 deletions docs/app/docs/file-conventions/content-directory/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,7 @@ import { MDXRemote } from 'nextra/mdx-remote'
export async function MDXPathPage() {
const filename = '[[...mdxPath]]/page.jsx'
const rawMdx = `~~~jsx filename="${filename}" showLineNumbers
${(await fs.readFile(`../examples/docs/src/app/docs/${filename}`, 'utf8'))
.split('\n')
.slice(2, -1)
.join('\n')}
${(await fs.readFile(`../examples/docs/src/app/docs/${filename}`, 'utf8')).trimEnd()}
~~~`
const rawJs = await compileMdx(rawMdx, { defaultShowCopyCode: true })
return <MDXRemote compiledSource={rawJs} />
Expand Down
6 changes: 2 additions & 4 deletions examples/docs/src/app/docs/[[...mdxPath]]/page.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
/* eslint-disable react-hooks/rules-of-hooks -- false positive, useMDXComponents isn't react hooks */

import { generateStaticParamsFor, importPage } from 'nextra/pages'
import { useMDXComponents } from '../../../../mdx-components'
import { useMDXComponents as getMDXComponents } from '../../../../mdx-components'

export const generateStaticParams = generateStaticParamsFor('mdxPath')

Expand All @@ -11,7 +9,7 @@ export async function generateMetadata(props) {
return metadata
}

const Wrapper = useMDXComponents().wrapper
const Wrapper = getMDXComponents().wrapper

export default async function Page(props) {
const params = await props.params
Expand Down
6 changes: 2 additions & 4 deletions examples/swr-site/app/[lang]/[[...mdxPath]]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
/* eslint-disable react-hooks/rules-of-hooks -- false positive, useMDXComponents isn't react hooks */

import { generateStaticParamsFor, importPage } from 'nextra/pages'
import { useMDXComponents } from '../../../mdx-components'
import { useMDXComponents as getMDXComponents } from '../../../mdx-components'

export const generateStaticParams = generateStaticParamsFor('mdxPath')

Expand All @@ -17,7 +15,7 @@ type PageProps = Readonly<{
lang: string
}>
}>
const Wrapper = useMDXComponents().wrapper
const Wrapper = getMDXComponents().wrapper

export default async function Page(props: PageProps) {
const params = await props.params
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
/* eslint-disable react-hooks/rules-of-hooks -- false positive, useMDXComponents isn't react hooks */
import { notFound } from 'next/navigation'
import { useMDXComponents } from 'nextra-theme-docs'
import { useMDXComponents as getMDXComponents } from 'nextra-theme-docs'
import { compileMdx } from 'nextra/compile'
import { Callout, Tabs } from 'nextra/components'
import { evaluate } from 'nextra/evaluate'
Expand Down Expand Up @@ -61,7 +60,7 @@ const yogaPageMap = mergeMetaWithPageMap(yogaPage, {

export const pageMap = normalizePageMap(yogaPageMap)

const { wrapper: Wrapper, ...components } = useMDXComponents({
const { wrapper: Wrapper, ...components } = getMDXComponents({
Callout,
Tabs,
Tab: Tabs.Tab,
Expand Down
55 changes: 40 additions & 15 deletions packages/nextra-theme-docs/src/components/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import { Anchor, Button, Collapse } from 'nextra/components'
import { useFSRoute, useHash } from 'nextra/hooks'
import { ArrowRightIcon, ExpandIcon } from 'nextra/icons'
import type { Item, MenuItem, PageItem } from 'nextra/normalize-pages'
import type { FC, FocusEventHandler, MouseEventHandler } from 'react'
import type {
ComponentProps,
FC,
FocusEventHandler,
MouseEventHandler
} from 'react'
import { forwardRef, useEffect, useId, useRef, useState } from 'react'
import scrollIntoView from 'scroll-into-view-if-needed'
import {
Expand All @@ -18,7 +23,7 @@ import {
useFocusedRoute,
useMenu,
useThemeConfig,
useToc
useTOC
} from '../stores'
import { LocaleSwitch } from './locale-switch'
import { ThemeSwitch } from './theme-switch'
Expand Down Expand Up @@ -286,7 +291,7 @@ Menu.displayName = 'Menu'

export const MobileNav: FC = () => {
const { directories } = useConfig().normalizePagesResult
const toc = useToc()
const toc = useTOC()

const menu = useMenu()
const pathname = usePathname()
Expand All @@ -301,14 +306,15 @@ export const MobileNav: FC = () => {
const sidebarRef = useRef<HTMLUListElement>(null!)

useEffect(() => {
const activeElement = sidebarRef.current.querySelector('li.active')
const sidebar = sidebarRef.current
const activeLink = sidebar.querySelector('li.active')

if (activeElement && menu) {
scrollIntoView(activeElement, {
if (activeLink && menu) {
scrollIntoView(activeLink, {
block: 'center',
inline: 'center',
scrollMode: 'always',
boundary: sidebarRef.current.parentNode as HTMLElement
boundary: sidebar.parentNode as HTMLElement
})
}
}, [menu])
Expand Down Expand Up @@ -355,33 +361,50 @@ export const MobileNav: FC = () => {
)
}

export const Sidebar: FC<{ toc: Heading[] }> = ({ toc }) => {
let lastScrollPosition = 0

const handleScrollEnd: ComponentProps<'div'>['onScroll'] = event => {
lastScrollPosition = event.currentTarget.scrollTop
}

export const Sidebar: FC = () => {
const toc = useTOC()
const { normalizePagesResult, hideSidebar } = useConfig()
const themeConfig = useThemeConfig()
const [isExpanded, setIsExpanded] = useState(themeConfig.sidebar.defaultOpen)
const [showToggleAnimation, setToggleAnimation] = useState(false)
const sidebarRef = useRef<HTMLDivElement>(null)
const sidebarRef = useRef<HTMLDivElement>(null!)
const sidebarControlsId = useId()

const { docsDirectories, activeThemeContext } = normalizePagesResult
const includePlaceholder = activeThemeContext.layout === 'default'

useEffect(() => {
const activeElement = sidebarRef.current?.querySelector('li.active')
if (window.innerWidth < 768) {
return
}
const sidebar = sidebarRef.current

// Since `<Sidebar>` is placed in `useMDXComponents.wrapper` on client side navigation he will
// be remounted, this is a workaround to restore the scroll position, and will be fixed in Nextra 5
if (lastScrollPosition) {
sidebar.scrollTop = lastScrollPosition
return
}

if (activeElement && window.innerWidth > 767) {
scrollIntoView(activeElement, {
const activeLink = sidebar.querySelector('li.active')
if (activeLink) {
scrollIntoView(activeLink, {
block: 'center',
inline: 'center',
scrollMode: 'always',
boundary: sidebarRef.current!.parentNode as HTMLDivElement
boundary: sidebar.parentNode as HTMLDivElement
})
}
}, [])

const anchors =
// When the viewport size is larger than `md`, hide the anchors in
// the sidebar when `floatTOC` is enabled.
// hide the anchors in the sidebar when `floatTOC` is enabled.
themeConfig.toc.float ? [] : toc.filter(v => v.depth === 2)

const hasI18n = themeConfig.i18n.length > 0
Expand Down Expand Up @@ -412,6 +435,8 @@ export const Sidebar: FC<{ toc: Heading[] }> = ({ toc }) => {
!isExpanded && 'no-scrollbar'
)}
ref={sidebarRef}
// @ts-expect-error -- false positive https://github.com/DefinitelyTyped/DefinitelyTyped/pull/72078
onScrollEnd={handleScrollEnd} // eslint-disable-line react/no-unknown-property
>
{/* without !hideSidebar check <Collapse />'s inner.clientWidth on `layout: "raw"` will be 0 and element will not have width on initial loading */}
{(!hideSidebar || !isExpanded) && (
Expand Down
8 changes: 3 additions & 5 deletions packages/nextra-theme-docs/src/components/toc.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
'use client'

import cn from 'clsx'
import type { Heading } from 'nextra'
import { Anchor } from 'nextra/components'
import type { FC } from 'react'
import { useEffect, useRef } from 'react'
import scrollIntoView from 'scroll-into-view-if-needed'
import { useActiveAnchor, useConfig, useThemeConfig } from '../stores'
import { useActiveAnchor, useConfig, useThemeConfig, useTOC } from '../stores'
import { getGitIssueUrl, gitUrlParse } from '../utils'
import { BackToTop } from './back-to-top'

type TOCProps = {
toc: Heading[]
filePath: string
pageTitle: string
}
Expand All @@ -23,11 +21,11 @@ const linkClassName = cn(
'x:contrast-more:text-gray-700 x:contrast-more:dark:text-gray-100'
)

export const TOC: FC<TOCProps> = ({ toc, filePath, pageTitle }) => {
export const TOC: FC<TOCProps> = ({ filePath, pageTitle }) => {
const activeSlug = useActiveAnchor()
const tocRef = useRef<HTMLUListElement>(null)
const themeConfig = useThemeConfig()

const toc = useTOC()
const hasMetaInfo =
themeConfig.feedback.content ||
themeConfig.editLink ||
Expand Down
30 changes: 14 additions & 16 deletions packages/nextra-theme-docs/src/mdx-components/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type { MDXComponents } from 'nextra/mdx-components'
import { removeLinks } from 'nextra/remove-links'
import type { ComponentProps, FC } from 'react'
import { Sidebar } from '../components'
import { TOCProvider } from '../stores'
import { H1, H2, H3, H4, H5, H6 } from './heading'
import { Link } from './link'
import { ClientWrapper } from './wrapper.client'
Expand Down Expand Up @@ -94,22 +95,19 @@ const DEFAULT_COMPONENTS = getNextraMDXComponents({
// Attach user-defined props to wrapper container, e.g. `data-pagefind-filter`
{...props}
>
<Sidebar toc={toc} />

<ClientWrapper
toc={toc}
metadata={metadata}
bottomContent={bottomContent}
>
<SkipNavContent />
<main
data-pagefind-body={
(metadata as any).searchable !== false || undefined
}
>
{children}
</main>
</ClientWrapper>
<TOCProvider value={toc}>
<Sidebar />
<ClientWrapper metadata={metadata} bottomContent={bottomContent}>
<SkipNavContent />
<main
data-pagefind-body={
(metadata as any).searchable !== false || undefined
}
>
{children}
</main>
</ClientWrapper>
</TOCProvider>
</div>
)
}
Expand Down
20 changes: 5 additions & 15 deletions packages/nextra-theme-docs/src/mdx-components/wrapper.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

import cn from 'clsx'
import type { MDXWrapper } from 'nextra'
import { cloneElement, useEffect } from 'react'
import type { ComponentProps, FC } from 'react'
import { cloneElement } from 'react'
import { Breadcrumb, Pagination, TOC } from '../components'
import { setToc, useConfig, useThemeConfig } from '../stores'
import { useConfig, useThemeConfig } from '../stores'

export const ClientWrapper: MDXWrapper = ({
toc,
export const ClientWrapper: FC<Omit<ComponentProps<MDXWrapper>, 'toc'>> = ({
children,
metadata,
bottomContent
Expand All @@ -18,14 +18,8 @@ export const ClientWrapper: MDXWrapper = ({
activePath
} = useConfig().normalizePagesResult
const themeConfig = useThemeConfig()

const date = themeContext.timestamp && metadata.timestamp

// We can't update store in server component so doing it in client component
useEffect(() => {
setToc(toc)
}, [toc])

return (
<>
{(themeContext.layout === 'default' || themeContext.toc) && (
Expand All @@ -34,11 +28,7 @@ export const ClientWrapper: MDXWrapper = ({
aria-label="table of contents"
>
{themeContext.toc && (
<TOC
toc={toc}
filePath={metadata.filePath}
pageTitle={metadata.title}
/>
<TOC filePath={metadata.filePath} pageTitle={metadata.title} />
)}
</nav>
)}
Expand Down
2 changes: 1 addition & 1 deletion packages/nextra-theme-docs/src/stores/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ export { useConfig, ConfigProvider } from './config'
export { useFocusedRoute, setFocusedRoute } from './focused-route'
export { useMenu, setMenu } from './menu'
export { ThemeConfigProvider, useThemeConfig } from './theme-config'
export { useToc, setToc } from './toc'
export { useTOC, TOCProvider } from './toc'
19 changes: 8 additions & 11 deletions packages/nextra-theme-docs/src/stores/toc.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
'use no memo'
'use client'

import type { Heading } from 'nextra'
import type { Dispatch } from 'react'
import { create } from 'zustand'
import type { ComponentProps } from 'react'
import { createContext, createElement, useContext } from 'react'

const useTocStore = create<{
toc: Heading[]
}>(() => ({
toc: []
}))
const TOCContext = createContext<Heading[]>([])

export const useToc = () => useTocStore(state => state.toc)
export const useTOC = () => useContext(TOCContext)

export const setToc: Dispatch<Heading[]> = toc => {
useTocStore.setState({ toc })
}
export const TOCProvider = (
props: ComponentProps<typeof TOCContext.Provider>
) => createElement(TOCContext.Provider, props)

0 comments on commit fd4e6d1

Please sign in to comment.