diff --git a/.changeset/silver-numbers-call.md b/.changeset/silver-numbers-call.md
new file mode 100644
index 0000000000..50f2ce4148
--- /dev/null
+++ b/.changeset/silver-numbers-call.md
@@ -0,0 +1,5 @@
+---
+"nextra-theme-docs": patch
+---
+
+fix: make scroll position in sidebar stable between client-side navigation
diff --git a/docs/app/docs/file-conventions/content-directory/page.mdx b/docs/app/docs/file-conventions/content-directory/page.mdx
index 3f6281f104..2ef8d8eb1e 100644
--- a/docs/app/docs/file-conventions/content-directory/page.mdx
+++ b/docs/app/docs/file-conventions/content-directory/page.mdx
@@ -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
diff --git a/examples/docs/src/app/docs/[[...mdxPath]]/page.jsx b/examples/docs/src/app/docs/[[...mdxPath]]/page.jsx
index 499dd4f546..7a19336854 100644
--- a/examples/docs/src/app/docs/[[...mdxPath]]/page.jsx
+++ b/examples/docs/src/app/docs/[[...mdxPath]]/page.jsx
@@ -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')
@@ -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
diff --git a/examples/swr-site/app/[lang]/[[...mdxPath]]/page.tsx b/examples/swr-site/app/[lang]/[[...mdxPath]]/page.tsx
index 5c530036c5..ed6569b52a 100644
--- a/examples/swr-site/app/[lang]/[[...mdxPath]]/page.tsx
+++ b/examples/swr-site/app/[lang]/[[...mdxPath]]/page.tsx
@@ -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')
@@ -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
diff --git a/examples/swr-site/app/[lang]/remote/graphql-yoga/[[...slug]]/page.tsx b/examples/swr-site/app/[lang]/remote/graphql-yoga/[[...slug]]/page.tsx
index 7f84990814..bffd83cd9b 100644
--- a/examples/swr-site/app/[lang]/remote/graphql-yoga/[[...slug]]/page.tsx
+++ b/examples/swr-site/app/[lang]/remote/graphql-yoga/[[...slug]]/page.tsx
@@ -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'
@@ -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,
diff --git a/packages/nextra-theme-docs/src/components/sidebar.tsx b/packages/nextra-theme-docs/src/components/sidebar.tsx
index 15fbbccff5..02eebf58a1 100644
--- a/packages/nextra-theme-docs/src/components/sidebar.tsx
+++ b/packages/nextra-theme-docs/src/components/sidebar.tsx
@@ -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 {
@@ -18,7 +23,7 @@ import {
useFocusedRoute,
useMenu,
useThemeConfig,
- useToc
+ useTOC
} from '../stores'
import { LocaleSwitch } from './locale-switch'
import { ThemeSwitch } from './theme-switch'
@@ -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()
@@ -301,14 +306,15 @@ export const MobileNav: FC = () => {
const sidebarRef = useRef(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])
@@ -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(null)
+ const sidebarRef = useRef(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 `` 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
@@ -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 's inner.clientWidth on `layout: "raw"` will be 0 and element will not have width on initial loading */}
{(!hideSidebar || !isExpanded) && (
diff --git a/packages/nextra-theme-docs/src/components/toc.tsx b/packages/nextra-theme-docs/src/components/toc.tsx
index 29040b81a2..ea3d6af437 100644
--- a/packages/nextra-theme-docs/src/components/toc.tsx
+++ b/packages/nextra-theme-docs/src/components/toc.tsx
@@ -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
}
@@ -23,11 +21,11 @@ const linkClassName = cn(
'x:contrast-more:text-gray-700 x:contrast-more:dark:text-gray-100'
)
-export const TOC: FC = ({ toc, filePath, pageTitle }) => {
+export const TOC: FC = ({ filePath, pageTitle }) => {
const activeSlug = useActiveAnchor()
const tocRef = useRef(null)
const themeConfig = useThemeConfig()
-
+ const toc = useTOC()
const hasMetaInfo =
themeConfig.feedback.content ||
themeConfig.editLink ||
diff --git a/packages/nextra-theme-docs/src/mdx-components/index.tsx b/packages/nextra-theme-docs/src/mdx-components/index.tsx
index 679c87ead5..90552df7e5 100644
--- a/packages/nextra-theme-docs/src/mdx-components/index.tsx
+++ b/packages/nextra-theme-docs/src/mdx-components/index.tsx
@@ -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'
@@ -94,22 +95,19 @@ const DEFAULT_COMPONENTS = getNextraMDXComponents({
// Attach user-defined props to wrapper container, e.g. `data-pagefind-filter`
{...props}
>
-
-
-
-
-
- {children}
-
-
+
+
+
+
+
+ {children}
+
+
+
)
}
diff --git a/packages/nextra-theme-docs/src/mdx-components/wrapper.client.tsx b/packages/nextra-theme-docs/src/mdx-components/wrapper.client.tsx
index 737ce06fc6..bb7d1ca157 100644
--- a/packages/nextra-theme-docs/src/mdx-components/wrapper.client.tsx
+++ b/packages/nextra-theme-docs/src/mdx-components/wrapper.client.tsx
@@ -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, 'toc'>> = ({
children,
metadata,
bottomContent
@@ -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) && (
@@ -34,11 +28,7 @@ export const ClientWrapper: MDXWrapper = ({
aria-label="table of contents"
>
{themeContext.toc && (
-
+
)}
)}
diff --git a/packages/nextra-theme-docs/src/stores/index.ts b/packages/nextra-theme-docs/src/stores/index.ts
index 5711a140f2..8de48b5f56 100644
--- a/packages/nextra-theme-docs/src/stores/index.ts
+++ b/packages/nextra-theme-docs/src/stores/index.ts
@@ -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'
diff --git a/packages/nextra-theme-docs/src/stores/toc.ts b/packages/nextra-theme-docs/src/stores/toc.ts
index a07db37599..36924d939d 100644
--- a/packages/nextra-theme-docs/src/stores/toc.ts
+++ b/packages/nextra-theme-docs/src/stores/toc.ts
@@ -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([])
-export const useToc = () => useTocStore(state => state.toc)
+export const useTOC = () => useContext(TOCContext)
-export const setToc: Dispatch = toc => {
- useTocStore.setState({ toc })
-}
+export const TOCProvider = (
+ props: ComponentProps
+) => createElement(TOCContext.Provider, props)