diff --git a/packages/core/src/theme/components/DocContent/index.tsx b/packages/core/src/theme/components/DocContent/index.tsx
index 3c8650f26b..1a6bad840c 100644
--- a/packages/core/src/theme/components/DocContent/index.tsx
+++ b/packages/core/src/theme/components/DocContent/index.tsx
@@ -1,11 +1,6 @@
import { MDXProvider } from '@mdx-js/react';
import { Content, usePage, useSite } from '@rspress/core/runtime';
-import {
- Callout,
- FallbackHeading,
- getCustomMDXComponent,
- useScrollAfterNav,
-} from '@theme';
+import { Callout, FallbackHeading, getCustomMDXComponent } from '@theme';
import './doc.scss';
function FallbackTitle() {
@@ -38,8 +33,6 @@ export function DocContent({
*/
afterDocContent?: React.ReactNode;
}) {
- useScrollAfterNav();
-
const mdxComponents = {
...getCustomMDXComponent(),
...components,
diff --git a/packages/core/src/theme/index.ts b/packages/core/src/theme/index.ts
index 06551af20d..543adcbfa0 100644
--- a/packages/core/src/theme/index.ts
+++ b/packages/core/src/theme/index.ts
@@ -105,11 +105,10 @@ export { Layout, type LayoutProps } from './layout/Layout/index';
export { NotFoundLayout } from './layout/NotFountLayout/index';
// logic
export { mergeRefs } from './logic/mergeRefs';
+export { ScrollRestoration } from './logic/ScrollRestoration';
export { useFullTextSearch } from './logic/useFullTextSearch';
export { usePrevNextPage } from './logic/usePrevNextPage';
export { useRedirect4FirstVisit } from './logic/useRedirect4FirstVisit';
-export { useScrollAfterNav } from './logic/useScrollAfterNav';
-export { useScrollReset } from './logic/useScrollReset';
export { useSetup } from './logic/useSetup';
export { useStorageValue } from './logic/useStorageValue';
export { useThemeState } from './logic/useThemeState';
diff --git a/packages/core/src/theme/layout/Layout/index.tsx b/packages/core/src/theme/layout/Layout/index.tsx
index 56158fda3f..0293679e5f 100644
--- a/packages/core/src/theme/layout/Layout/index.tsx
+++ b/packages/core/src/theme/layout/Layout/index.tsx
@@ -14,8 +14,8 @@ import {
type DocLayoutProps,
Nav,
type NavProps,
+ ScrollRestoration,
useRedirect4FirstVisit,
- useScrollReset,
useSetup,
} from '@theme';
import { Head, useHead } from '@unhead/react';
@@ -203,7 +203,6 @@ export function Layout(props: LayoutProps) {
}
useSetup();
- useScrollReset();
useRedirect4FirstVisit();
const {
@@ -236,6 +235,7 @@ export function Layout(props: LayoutProps) {
{getContentLayout()}
{bottom}
+
>
);
}
diff --git a/packages/core/src/theme/logic/ScrollRestoration.tsx b/packages/core/src/theme/logic/ScrollRestoration.tsx
new file mode 100644
index 0000000000..9707e3c5f8
--- /dev/null
+++ b/packages/core/src/theme/logic/ScrollRestoration.tsx
@@ -0,0 +1,196 @@
+import { useLocation, useNavigationType } from '@rspress/core/runtime';
+import { useLayoutEffect, useRef } from 'react';
+
+const STORAGE_KEY = 'rspress-scroll-positions';
+const MAX_SCROLL_ENTRIES = 100;
+
+// Module-level state for scroll positions
+const savedScrollPositions: Record =
+ typeof window === 'undefined'
+ ? {}
+ : JSON.parse(sessionStorage.getItem(STORAGE_KEY) || '{}');
+
+/**
+ * Parse CSS length value to number (in pixels).
+ * Supports: px
+ */
+function parseCSSLength(value: string): number {
+ if (!value || value === 'auto' || value === 'none') {
+ return 0;
+ }
+
+ const numValue = Number.parseFloat(value);
+ if (Number.isNaN(numValue)) {
+ return 0;
+ }
+
+ return numValue;
+}
+
+/**
+ * Scroll to a hash target element, respecting scroll-padding-top.
+ */
+function scrollToHashTarget(hash: string): boolean {
+ const target = document.getElementById(hash.slice(1));
+ if (!target) {
+ return false;
+ }
+
+ const scrollPaddingTop = parseCSSLength(
+ window
+ .getComputedStyle(document.documentElement)
+ .getPropertyValue('scroll-padding-top'),
+ );
+
+ const offsetTop = Math.round(
+ window.scrollY + target.getBoundingClientRect().top - scrollPaddingTop,
+ );
+
+ window.scrollTo({ left: 0, top: offsetTop });
+ return true;
+}
+
+/**
+ * Get the scroll restoration key from location.
+ * Uses location.key by default, which provides unique keys for each navigation.
+ */
+function getScrollRestorationKey(location: { key: string }): string {
+ return location.key;
+}
+
+/**
+ * Persist scroll positions to sessionStorage.
+ * Prunes oldest entries if exceeding MAX_SCROLL_ENTRIES.
+ */
+function persistSavedPositions(): void {
+ try {
+ const keys = Object.keys(savedScrollPositions);
+ if (keys.length > MAX_SCROLL_ENTRIES) {
+ // Remove oldest entries (first inserted keys)
+ const excess = keys.length - MAX_SCROLL_ENTRIES;
+ for (let i = 0; i < excess; i++) {
+ delete savedScrollPositions[keys[i]];
+ }
+ }
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify(savedScrollPositions));
+ } catch {
+ // Ignore errors
+ }
+}
+
+// The inline script that runs before React hydration.
+// It reads the saved scroll position from sessionStorage and restores it immediately,
+// preventing a flash of wrong scroll position.
+// Also handles hash anchor scrolling since scrollRestoration is 'manual'.
+const inlineScript = `(function(){
+ try {
+ if (!window.history.state || !window.history.state.key) {
+ var key = Math.random().toString(32).slice(2);
+ window.history.replaceState({ key: key }, "");
+ }
+
+ var positions = JSON.parse(sessionStorage.getItem('${STORAGE_KEY}') || '{}');
+ var y = positions[window.history.state.key];
+
+ if (typeof y === 'number') {
+ window.history.scrollRestoration = 'manual';
+ window.scrollTo(0, y);
+ }
+ } catch(e) {}
+})()`;
+
+/**
+ * Hook for scroll restoration logic.
+ * Follows React Router's implementation closely.
+ */
+function useScrollRestoration() {
+ const location = useLocation();
+ const navigationType = useNavigationType();
+ const prevKeyRef = useRef(undefined);
+
+ // Save positions on pagehide for tab close / page refresh scenarios.
+ // Reads key from history.state directly so this effect only runs once.
+ useLayoutEffect(() => {
+ const onPageHide = () => {
+ const state = window.history.state;
+ const key = state?.key;
+ if (key) {
+ savedScrollPositions[key] = window.scrollY;
+ persistSavedPositions();
+ }
+ // Let browser handle scroll restoration on page refresh
+ window.history.scrollRestoration = 'auto';
+ };
+
+ window.addEventListener('pagehide', onPageHide);
+ return () => {
+ window.removeEventListener('pagehide', onPageHide);
+ };
+ }, []);
+
+ // Handle scroll on navigation
+ useLayoutEffect(() => {
+ const prevKey = prevKeyRef.current;
+ const currentKey = getScrollRestorationKey(location);
+ prevKeyRef.current = currentKey;
+
+ // Save the previous page's scroll position before handling the new page.
+ // This is essential for SPA navigation where pagehide does not fire.
+ if (prevKey) {
+ savedScrollPositions[prevKey] = window.scrollY;
+ persistSavedPositions();
+ }
+
+ // For POP navigation (back/forward), restore saved position
+ if (navigationType === 'POP') {
+ // console.log(`[ScrollRestoration] POP navigation detected. Restoring scroll position for key: ${currentKey}`);
+ // const savedY = savedScrollPositions[currentKey];
+
+ // if (typeof savedY === 'number') {
+ // // Use requestAnimationFrame to ensure DOM is ready
+ // requestAnimationFrame(() => {
+ // window.scrollTo(0, savedY);
+ // });
+ // }
+ // If no saved position, let browser handle it naturally
+ return;
+ }
+
+ // For PUSH/REPLACE navigation
+ const hash = decodeURIComponent(window.location.hash);
+
+ if (hash.length > 0) {
+ // Try to scroll to hash target
+ // Use requestAnimationFrame to ensure target element is rendered
+ requestAnimationFrame(() => {
+ scrollToHashTarget(hash);
+ });
+ } else {
+ // Scroll to top for new navigation
+ window.scrollTo(0, 0);
+ }
+ }, [location.search, location.hash, location.pathname, navigationType]);
+}
+
+/**
+ * Scroll restoration component inspired by React Router's ScrollRestoration.
+ *
+ * This component:
+ * 1. Renders an inline script that restores scroll position before React hydration
+ * 2. Sets up scroll position saving on pagehide
+ * 3. Handles scroll restoration on navigation
+ *
+ * @see https://reactrouter.com/api/components/ScrollRestoration
+ * @private
+ * @unstable
+ */
+export function ScrollRestoration() {
+ useScrollRestoration();
+
+ return (
+
+ );
+}
diff --git a/packages/core/src/theme/logic/useScrollAfterNav.ts b/packages/core/src/theme/logic/useScrollAfterNav.ts
deleted file mode 100644
index 0d2e8ff271..0000000000
--- a/packages/core/src/theme/logic/useScrollAfterNav.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import { useLocation } from '@rspress/core/runtime';
-import { useLayoutEffect } from 'react';
-
-/**
- * Parse CSS length value to number (in pixels)
- * Supports: px
- */
-function parseCSSLength(value: string): number {
- if (!value || value === 'auto' || value === 'none') {
- return 0;
- }
-
- const numValue = Number.parseFloat(value);
- if (Number.isNaN(numValue)) {
- return 0;
- }
-
- // For px or plain number, just return the numeric value
- return numValue;
-}
-
-function getTargetTop(element: HTMLElement) {
- // get scroll-padding-top from html
- const scrollPaddingTop = parseCSSLength(
- window
- .getComputedStyle(document.documentElement)
- .getPropertyValue('scroll-padding-top'),
- );
-
- const targetTop =
- window.scrollY + element.getBoundingClientRect().top - scrollPaddingTop;
-
- return Math.round(targetTop);
-}
-
-function scrollToTarget(target: HTMLElement) {
- const offsetTop = getTargetTop(target);
-
- window.scrollTo({
- left: 0,
- top: offsetTop,
- });
-}
-
-/**
- * @private
- * @unstable
- */
-export function useScrollAfterNav() {
- const location = useLocation();
-
- useLayoutEffect(() => {
- if (typeof window === 'undefined') {
- return;
- }
- const decodedHash = decodeURIComponent(window.location.hash);
- if (decodedHash.length > 0) {
- const target = document.getElementById(decodedHash.slice(1));
- if (target) {
- scrollToTarget(target);
- }
- }
- }, [location, location.pathname]);
-}
diff --git a/packages/core/src/theme/logic/useScrollReset.ts b/packages/core/src/theme/logic/useScrollReset.ts
deleted file mode 100644
index 071acfd84e..0000000000
--- a/packages/core/src/theme/logic/useScrollReset.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { useLayoutEffect } from 'react';
-import { useLocation } from 'react-router-dom';
-
-/**
- * @private
- * @unstable
- */
-export function useScrollReset() {
- const { pathname } = useLocation();
-
- useLayoutEffect(() => {
- const decodedHash = decodeURIComponent(window.location.hash);
- if (decodedHash.length === 0) {
- window.scrollTo(0, 0);
- }
- }, [pathname]);
- return;
-}