diff --git a/e2e/react-router/basic-scroll-restoration/src/main.tsx b/e2e/react-router/basic-scroll-restoration/src/main.tsx
index 8bc4c54c1c5..62bd1c18553 100644
--- a/e2e/react-router/basic-scroll-restoration/src/main.tsx
+++ b/e2e/react-router/basic-scroll-restoration/src/main.tsx
@@ -20,21 +20,42 @@ const rootRoute = createRootRoute({
function RootComponent() {
return (
<>
-
-
- Home
- {' '}
-
- About
-
-
- About (No Reset)
-
-
- By-Element
-
+
+
+
+
+ Home
+ {' '}
+
+ About
+
+
+ About (No Reset)
+
+
+ Bar (No Reset)
+
+
+ By-Element
+
+
+
+
-
>
)
@@ -267,7 +288,7 @@ const router = createRouter({
routeTree,
defaultPreload: 'intent',
scrollRestoration: true,
- getScrollRestorationKey: (location) => location.pathname,
+ scrollToTopSelectors: ['#sidebar'],
})
declare global {
diff --git a/e2e/react-router/basic-scroll-restoration/tests/app.spec.ts b/e2e/react-router/basic-scroll-restoration/tests/app.spec.ts
index c7360ce8a4a..7280c6dd021 100644
--- a/e2e/react-router/basic-scroll-restoration/tests/app.spec.ts
+++ b/e2e/react-router/basic-scroll-restoration/tests/app.spec.ts
@@ -115,3 +115,61 @@ test('scroll to top when not scrolled, regression test for #4782', async ({
const restoredScrollPosition = await page.evaluate(() => window.scrollY)
expect(restoredScrollPosition).toBe(0)
})
+
+test('resetScroll=false saves scroll position for back navigation without scroll event, regression test for #6595', async ({
+ page,
+}) => {
+ const targetScrollPosition = 1500
+ const elementTargetScrollPosition = 600
+
+ await page.goto('/')
+ await page.waitForURL('/')
+ await expect(page.locator('#greeting')).toContainText('Welcome Home!');
+
+ await page.evaluate(
+ (scrollPos: number) => window.scrollTo(0, scrollPos),
+ targetScrollPosition,
+ );
+ await page.evaluate(
+ (scrollPos: number) => document.querySelector('#sidebar')?.scrollTo(0, scrollPos),
+ elementTargetScrollPosition,
+ );
+ await page.waitForTimeout(1000);
+
+ await checkScrollPositions();
+
+ async function checkScrollPositions() {
+ const scrollPosition = await page.evaluate(() => window.scrollY)
+ expect(scrollPosition).toBe(targetScrollPosition);
+
+ const sidebarScrollPosition = await page.evaluate(() => document.querySelector('#sidebar')?.scrollTop)
+ expect(sidebarScrollPosition).toBe(elementTargetScrollPosition);
+ }
+ await page.getByRole('link', { name: 'About (No Reset)', exact: true }).click()
+ await page.waitForURL('/about');
+ await expect(page.locator('#greeting')).toContainText('Hello from About!')
+
+ await checkScrollPositions();
+
+ await page.getByRole('link', { name: 'Bar (No Reset)', exact: true }).click()
+ await page.waitForURL('/bar');
+ await expect(page.locator('#greeting')).toContainText('Hello from Bar!')
+
+ await checkScrollPositions();
+
+ await page.goBack();
+ await page.waitForTimeout(1000)
+
+ await page.waitForURL('/about');
+ await expect(page.locator('#greeting')).toContainText('Hello from About!')
+
+ await checkScrollPositions();
+
+ await page.goBack();
+ await page.waitForTimeout(1000)
+
+ await page.waitForURL('/');
+ await expect(page.locator('#greeting')).toContainText('Welcome Home!');
+
+ await checkScrollPositions();
+});
\ No newline at end of file
diff --git a/packages/router-core/src/scroll-restoration.ts b/packages/router-core/src/scroll-restoration.ts
index ec1925c137e..599530184ed 100644
--- a/packages/router-core/src/scroll-restoration.ts
+++ b/packages/router-core/src/scroll-restoration.ts
@@ -110,6 +110,19 @@ export function getCssSelector(el: any): string {
return `${path.reverse().join(' > ')}`.toLowerCase()
}
+/**
+ * Finds the first matching string selector for a given element from a list of selectors.
+ * Ignores function selectors, see https://github.com/TanStack/router/pull/6632
+ */
+function findMatchingSelector(el: Element, selectors: Array
Element | null | undefined)>): string | undefined {
+ for (const selector of selectors) {
+ if (typeof selector === 'string' && el.matches(selector)) {
+ return selector;
+ }
+ }
+ return undefined;
+}
+
let ignoreScroll = false
// NOTE: This function must remain pure and not use any outside variables
@@ -302,7 +315,8 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) {
if (attrId) {
elementSelector = `[data-scroll-restoration-id="${attrId}"]`
} else {
- elementSelector = getCssSelector(event.target)
+ elementSelector = findMatchingSelector(event.target as Element, router.options.scrollToTopSelectors ?? [])
+ ?? getCssSelector(event.target)
}
}
@@ -341,7 +355,45 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) {
// If the user doesn't want to restore the scroll position,
// we don't need to do anything.
+ //
+ // Remember the current scroll position of window and scroll to top selectors,
+ // in order to restore them if the user goes back without scrolling.
if (!router.resetNextScroll) {
+ scrollRestorationCache.set((state) => {
+ if (event.fromLocation) {
+ const fromKey = getKey(event.fromLocation);
+ const newState = {} as ScrollRestorationByElement;
+
+ const windowState = state[fromKey]?.['window'];
+
+ if (windowState) {
+ newState['window'] = {
+ scrollX: windowState.scrollX,
+ scrollY: windowState.scrollY,
+ };
+ }
+
+ if (router.options.scrollToTopSelectors) {
+ for (const selector of router.options.scrollToTopSelectors) {
+ if (typeof selector === 'string') {
+ const oldElement = state[fromKey]?.[selector];
+
+ if (oldElement) {
+ newState[selector] = {
+ scrollX: oldElement.scrollX,
+ scrollY: oldElement.scrollY,
+ }
+ }
+ }
+ }
+ }
+
+ state[cacheKey] ||= {} as ScrollRestorationByElement
+ state[cacheKey] = { ...state[cacheKey], ...newState };
+ }
+
+ return state
+ });
router.resetNextScroll = true
return
}