Skip to content

feat(theme): add ScrollRestoration to avoid scroll flash#3160

Open
SoonIter wants to merge 11 commits into
mainfrom
syt/scroll-restoration
Open

feat(theme): add ScrollRestoration to avoid scroll flash#3160
SoonIter wants to merge 11 commits into
mainfrom
syt/scroll-restoration

Conversation

@SoonIter
Copy link
Copy Markdown
Member

@SoonIter SoonIter commented Feb 26, 2026

Summary

Due to SSG + hydration, there is a scroll flash on the first screen. We used a common community solution to implement Rspress's own ScrollRestoration

https://reactrouter.com/api/components/ScrollRestoration

  • Add ScrollRestoration component that saves and restores scroll positions on browser back/forward navigation
  • Renders an inline <script> tag for pre-hydration scroll restoration (avoids flash)
  • Uses React Router's location.key as storage key, persists to sessionStorage via pagehide
  • Skip hash-based anchor scrolling on POP navigation to avoid overriding restored position

Inspired by React Router's <ScrollRestoration> component.

Test plan

  • Navigate to a long doc page, scroll down, click a link, press browser back — scroll position should be restored
  • Navigate to a new page — should scroll to top
  • Click a hash/anchor link — should scroll to the anchor element
  • Reload the page — scroll position should be restored from sessionStorage

…ecovery

Previously, Rspress always scrolled to top on navigation, losing the user's
reading position when pressing browser back/forward. This adds a ScrollRestoration
component (inspired by React Router's ScrollRestoration) that:

- Saves scroll positions keyed by React Router's location.key
- Restores them on POP (back/forward) navigation
- Renders an inline <script> for pre-hydration restoration to avoid flash
- Persists positions to sessionStorage via pagehide for page reload support
Copilot AI review requested due to automatic review settings February 26, 2026 07:46
@SoonIter
Copy link
Copy Markdown
Member Author

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 26, 2026

Rsdoctor Bundle Diff Analysis

Found 3 projects in monorepo, 2 projects with changes.

📊 Quick Summary
Project Total Size Change
node 12.1 MB +88.7 KB (0.7%)
node_md 1.5 MB 0
web 15.9 MB +1.1 KB (0.0%)
📋 Detailed Reports (Click to expand)

📁 node

Path: website/doc_build/diff-rsdoctor/node/rsdoctor-data.json

📌 Baseline Commit: 0a4dfd6f45 | PR: #3145

Metric Current Baseline Change
📊 Total Size 12.1 MB 12.0 MB +88.7 KB (0.7%)
📄 JavaScript 0 B 0 B 0
🎨 CSS 0 B 0 B 0
🌐 HTML 12.1 MB 12.0 MB +88.7 KB (0.7%)
📁 Other Assets 0 B 0 B 0

📦 Download Diff Report: node Bundle Diff

📁 web

Path: website/doc_build/diff-rsdoctor/web/rsdoctor-data.json

📌 Baseline Commit: 0a4dfd6f45 | PR: #3145

Metric Current Baseline Change
📊 Total Size 15.9 MB 15.9 MB +1.1 KB (0.0%)
📄 JavaScript 15.6 MB 15.6 MB +1.1 KB (0.0%)
🎨 CSS 119.7 KB 119.7 KB 0
🌐 HTML 0 B 0 B 0
📁 Other Assets 166.7 KB 166.7 KB 0

📦 Download Diff Report: web Bundle Diff

Generated by Rsdoctor GitHub Action

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Feb 26, 2026

Deploying rspress-v2 with  Cloudflare Pages  Cloudflare Pages

Latest commit: 5a28af2
Status: ✅  Deploy successful!
Preview URL: https://5fd4c52b.rspress-v2.pages.dev
Branch Preview URL: https://syt-scroll-restoration.rspress-v2.pages.dev

View logs

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a theme-level scroll restoration mechanism to preserve scroll position on back/forward navigation and avoid anchor scrolling overriding restored positions.

Changes:

  • Introduces a new ScrollRestoration component that restores scroll position (including a pre-hydration inline script).
  • Updates useScrollAfterNav to skip hash scrolling on POP (back/forward) navigations.
  • Removes the old useScrollReset hook and wires ScrollRestoration into the main Layout.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
packages/core/src/theme/logic/useScrollReset.ts Removes the previous scroll-to-top-on-route-change hook.
packages/core/src/theme/logic/useScrollAfterNav.ts Skips anchor scrolling on POP navigation; adds useNavigationType.
packages/core/src/theme/logic/ScrollRestoration.tsx Adds new scroll position saving/restoring logic + inline pre-hydration script.
packages/core/src/theme/layout/Layout/index.tsx Mounts ScrollRestoration in the theme layout and removes useScrollReset().
packages/core/src/theme/index.ts Exports ScrollRestoration and stops exporting useScrollReset.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/core/src/theme/logic/ScrollRestoration.tsx Outdated
}
prevKeyRef.current = location.key;

const hash = decodeURIComponent(window.location.hash);
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decodeURIComponent(window.location.hash) can throw if the hash contains malformed percent-encoding, which would break navigation effects. Wrap the decode in a try/catch (or use a safe decode helper) and treat failures as an empty hash.

Suggested change
const hash = decodeURIComponent(window.location.hash);
const rawHash = window.location.hash;
let hash = '';
try {
hash = decodeURIComponent(rawHash);
} catch {
hash = '';
}

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,143 @@
import { useLayoutEffect, useRef } from 'react';
import { useLocation, useNavigationType } from 'react-router-dom';
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In theme code, router hooks are typically imported from @rspress/core/runtime (which re-exports react-router-dom). Importing useLocation/useNavigationType directly from react-router-dom here is inconsistent and can make it easier to accidentally pull in a second copy in some bundler/alias setups. Consider switching these imports to come from @rspress/core/runtime for consistency.

Suggested change
import { useLocation, useNavigationType } from 'react-router-dom';
import { useLocation, useNavigationType } from '@rspress/core/runtime';

Copilot uses AI. Check for mistakes.
if (navigationType === 'POP') {
return;
}
const decodedHash = decodeURIComponent(window.location.hash);
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decodeURIComponent(window.location.hash) can throw if the hash contains malformed percent-encoding, which would break the scroll hook. Wrap the decode in a try/catch (or use a safe decode helper) and treat failures as an empty hash.

Suggested change
const decodedHash = decodeURIComponent(window.location.hash);
const hash = window.location.hash;
let decodedHash = '';
try {
decodedHash = decodeURIComponent(hash);
} catch {
decodedHash = '';
}

Copilot uses AI. Check for mistakes.
Comment on lines 1 to 4
import { useLocation } from '@rspress/core/runtime';
import { useLayoutEffect } from 'react';
import { useNavigationType } from 'react-router-dom';

Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For consistency with the rest of the theme (and to leverage the runtime’s re-export), consider importing useNavigationType from @rspress/core/runtime rather than react-router-dom directly.

Suggested change
import { useLocation } from '@rspress/core/runtime';
import { useLayoutEffect } from 'react';
import { useNavigationType } from 'react-router-dom';
import { useLocation, useNavigationType } from '@rspress/core/runtime';
import { useLayoutEffect } from 'react';

Copilot uses AI. Check for mistakes.
@SoonIter SoonIter changed the title feat(theme): add ScrollRestoration for back/forward navigation feat(theme): add ScrollRestoration to avoid scroll flash Feb 26, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +142 to +147
const handleHashChange = () => {
const hash = decodeURIComponent(window.location.hash);
if (hash.length > 0) {
scrollToHashTarget(hash);
}
};
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hashchange is handled unconditionally and will scroll to the anchor whenever the hash changes. When users navigate back/forward to a URL that includes a hash, browsers typically fire hashchange during the POP, which can override the POP-restored scroll position (the effect intentionally returns early for POP). Consider suppressing hashchange-driven scrolling during/after POP restoration (e.g., via a ref/flag set during POP restores, or by checking whether a saved position exists for the current key before scrolling to the hash).

Copilot uses AI. Check for mistakes.
Comment on lines +175 to +215
// 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') {
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, navigationType]);
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds fairly intricate scroll-restoration behavior (including an inline pre-hydration script and different handling for POP vs PUSH/REPLACE) but there’s no automated test coverage asserting the expected navigation scenarios. Since the repo already has Playwright e2e tests, it would be valuable to add an e2e test that covers back/forward restoration, normal navigation scroll-to-top, hash anchor scrolling, and reload restoration to prevent regressions.

Copilot uses AI. Check for mistakes.
Comment on lines +98 to +101
if (!window.history.state || !window.history.state.key) {
var key = Math.random().toString(32).slice(2);
window.history.replaceState({ key: key }, "");
}
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the inline pre-hydration script, history.replaceState({ key }, "") overwrites the existing history.state object. React Router’s BrowserRouter/history uses additional state fields (e.g., idx, usr) to manage navigation; clobbering them can break back/forward behavior or internal routing invariants. Preserve/merge the existing history.state when injecting a key (and keep the current URL) instead of replacing it with a minimal object.

Copilot uses AI. Check for mistakes.
Comment on lines +103 to +110
var positions = JSON.parse(sessionStorage.getItem('${STORAGE_KEY}') || '{}');
var y = positions[window.history.state.key];

var hash = window.location.hash;
if (hash && hash.length > 1) {
window.history.scrollRestoration = 'manual';
var target = document.getElementById(decodeURIComponent(hash.slice(1)));
if (target) {
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inline script always overrides a saved scroll position with the hash target position whenever location.hash is present (y is reassigned inside the hash block). This contradicts the runtime logic that prefers saved positions on POP/back-forward, and can reintroduce a scroll “flash” (pre-hydration jumps to the anchor, then post-hydration restores to the saved Y). Only apply hash-based scrolling when there is no saved position for the current history key (or otherwise align the inline script’s precedence rules with the hydrated behavior).

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants