From f37b645dea933149cbd77f5a49c9526edb2693d6 Mon Sep 17 00:00:00 2001 From: TomVHPresto Date: Mon, 9 Feb 2026 21:18:09 +0000 Subject: [PATCH 1/4] Restore scroll when navigating back from a resetScroll=false navigation without having scrolled --- .../basic-scroll-restoration/src/main.tsx | 50 +++++++++++----- .../tests/app.spec.ts | 58 +++++++++++++++++++ .../router-core/src/scroll-restoration.ts | 49 +++++++++++++++- 3 files changed, 142 insertions(+), 15 deletions(-) diff --git a/e2e/react-router/basic-scroll-restoration/src/main.tsx b/e2e/react-router/basic-scroll-restoration/src/main.tsx index 8bc4c54c1c5..8035867e456 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 + +
+ +
- ) @@ -268,6 +289,7 @@ const router = createRouter({ 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..f765748c52f 100644 --- a/packages/router-core/src/scroll-restoration.ts +++ b/packages/router-core/src/scroll-restoration.ts @@ -110,6 +110,15 @@ export function getCssSelector(el: any): string { return `${path.reverse().join(' > ')}`.toLowerCase() } +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 +311,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 +351,44 @@ 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] = newState; + } + + return state + }); router.resetNextScroll = true return } From f8ca01b15ea737af36c1ba94d1d8ac1136ef1f93 Mon Sep 17 00:00:00 2001 From: TomVHPresto Date: Mon, 9 Feb 2026 22:20:00 +0000 Subject: [PATCH 2/4] Change basic-scroll-restoration test to use default scroll restoration key --- e2e/react-router/basic-scroll-restoration/src/main.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/e2e/react-router/basic-scroll-restoration/src/main.tsx b/e2e/react-router/basic-scroll-restoration/src/main.tsx index 8035867e456..62bd1c18553 100644 --- a/e2e/react-router/basic-scroll-restoration/src/main.tsx +++ b/e2e/react-router/basic-scroll-restoration/src/main.tsx @@ -288,7 +288,6 @@ const router = createRouter({ routeTree, defaultPreload: 'intent', scrollRestoration: true, - getScrollRestorationKey: (location) => location.pathname, scrollToTopSelectors: ['#sidebar'], }) From 190b462cc621426e2325fd2717f4915f1ea3a334 Mon Sep 17 00:00:00 2001 From: TomVHPresto Date: Tue, 10 Feb 2026 20:41:43 +0000 Subject: [PATCH 3/4] Preserve existing scroll cache for element selector, adds comment to findMatchingSelector --- packages/router-core/src/scroll-restoration.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/router-core/src/scroll-restoration.ts b/packages/router-core/src/scroll-restoration.ts index f765748c52f..521293f6822 100644 --- a/packages/router-core/src/scroll-restoration.ts +++ b/packages/router-core/src/scroll-restoration.ts @@ -110,6 +110,10 @@ 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)) { @@ -311,7 +315,7 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) { if (attrId) { elementSelector = `[data-scroll-restoration-id="${attrId}"]` } else { - elementSelector = findMatchingSelector(event.target as Element, router.options.scrollToTopSelectors ?? []) + elementSelector = findMatchingSelector(event.target as Element, router.options.scrollToTopSelectors ?? []) ?? getCssSelector(event.target) } } @@ -384,7 +388,8 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) { } } - state[cacheKey] = newState; + state[cacheKey] ||= {} as ScrollRestorationByElement + state[cacheKey] = { ...state[cacheKey], ...newState }; } return state From d516121865bb0926b991a1d806f0e1a6f7b91ac2 Mon Sep 17 00:00:00 2001 From: TomVHPresto Date: Tue, 10 Feb 2026 20:42:48 +0000 Subject: [PATCH 4/4] Remove trailing space from comment --- packages/router-core/src/scroll-restoration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/router-core/src/scroll-restoration.ts b/packages/router-core/src/scroll-restoration.ts index 521293f6822..599530184ed 100644 --- a/packages/router-core/src/scroll-restoration.ts +++ b/packages/router-core/src/scroll-restoration.ts @@ -110,7 +110,7 @@ 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 */