feat(theme): add ScrollRestoration to avoid scroll flash#3160
feat(theme): add ScrollRestoration to avoid scroll flash#3160SoonIter wants to merge 11 commits into
Conversation
…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
Rsdoctor Bundle Diff AnalysisFound 3 projects in monorepo, 2 projects with changes. 📊 Quick Summary
📋 Detailed Reports (Click to expand)📁 nodePath:
📦 Download Diff Report: node Bundle Diff 📁 webPath:
📦 Download Diff Report: web Bundle Diff Generated by Rsdoctor GitHub Action |
Deploying rspress-v2 with
|
| 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 |
There was a problem hiding this comment.
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
ScrollRestorationcomponent that restores scroll position (including a pre-hydration inline script). - Updates
useScrollAfterNavto skip hash scrolling on POP (back/forward) navigations. - Removes the old
useScrollResethook and wiresScrollRestorationinto the mainLayout.
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.
| } | ||
| prevKeyRef.current = location.key; | ||
|
|
||
| const hash = decodeURIComponent(window.location.hash); |
There was a problem hiding this comment.
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.
| const hash = decodeURIComponent(window.location.hash); | |
| const rawHash = window.location.hash; | |
| let hash = ''; | |
| try { | |
| hash = decodeURIComponent(rawHash); | |
| } catch { | |
| hash = ''; | |
| } |
| @@ -0,0 +1,143 @@ | |||
| import { useLayoutEffect, useRef } from 'react'; | |||
| import { useLocation, useNavigationType } from 'react-router-dom'; | |||
There was a problem hiding this comment.
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.
| import { useLocation, useNavigationType } from 'react-router-dom'; | |
| import { useLocation, useNavigationType } from '@rspress/core/runtime'; |
| if (navigationType === 'POP') { | ||
| return; | ||
| } | ||
| const decodedHash = decodeURIComponent(window.location.hash); |
There was a problem hiding this comment.
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.
| const decodedHash = decodeURIComponent(window.location.hash); | |
| const hash = window.location.hash; | |
| let decodedHash = ''; | |
| try { | |
| decodedHash = decodeURIComponent(hash); | |
| } catch { | |
| decodedHash = ''; | |
| } |
| import { useLocation } from '@rspress/core/runtime'; | ||
| import { useLayoutEffect } from 'react'; | ||
| import { useNavigationType } from 'react-router-dom'; | ||
|
|
There was a problem hiding this comment.
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.
| 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'; |
Consolidate all scroll logic (restoration, scroll-to-top, hash anchor scrolling) into the single ScrollRestoration component, removing the separate useScrollAfterNav hook.
There was a problem hiding this comment.
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.
| const handleHashChange = () => { | ||
| const hash = decodeURIComponent(window.location.hash); | ||
| if (hash.length > 0) { | ||
| scrollToHashTarget(hash); | ||
| } | ||
| }; |
There was a problem hiding this comment.
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).
| // 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]); |
There was a problem hiding this comment.
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.
| if (!window.history.state || !window.history.state.key) { | ||
| var key = Math.random().toString(32).slice(2); | ||
| window.history.replaceState({ key: key }, ""); | ||
| } |
There was a problem hiding this comment.
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.
| 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) { |
There was a problem hiding this comment.
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).
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
ScrollRestorationcomponent that saves and restores scroll positions on browser back/forward navigation<script>tag for pre-hydration scroll restoration (avoids flash)location.keyas storage key, persists tosessionStorageviapagehideInspired by React Router's
<ScrollRestoration>component.Test plan