Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: make scroll position in sidebar stable between client side navigation #4296

Merged
merged 11 commits into from
Mar 3, 2025
Merged
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)