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)