diff --git a/e2e/react-router/basic-scroll-restoration/src/main.tsx b/e2e/react-router/basic-scroll-restoration/src/main.tsx index 8bc4c54c1c5..8478a83c0cb 100644 --- a/e2e/react-router/basic-scroll-restoration/src/main.tsx +++ b/e2e/react-router/basic-scroll-restoration/src/main.tsx @@ -20,22 +20,39 @@ const rootRoute = createRootRoute({ function RootComponent() { return ( <> -
- - Home - {' '} - - About - - - About (No Reset) - - - By-Element - + +
+
+ + Home + {' '} + + About + + + About (No Reset) + + + By-Element + +
+ +
- - ) } @@ -268,6 +285,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..9b3490860ee 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,107 @@ 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 storageKey = 'tsr-scroll-restoration-v1_3' + const targetScrollPosition = 1000 + + await page.goto('/') + await expect(page.locator('#greeting')).toContainText('Welcome Home!') + + await page.evaluate( + (scrollPos: number) => window.scrollTo(0, scrollPos), + targetScrollPosition, + ) + + await page.waitForFunction( + ([key, path, expectedY]) => { + const cache = sessionStorage.getItem(key) + if (!cache) return false + const parsed = JSON.parse(cache) + return parsed[path]?.window?.scrollY === expectedY + }, + [storageKey, '/', targetScrollPosition] as const, + ) + + const scrollBeforeNav = await page.evaluate(() => window.scrollY) + expect(scrollBeforeNav).toBe(targetScrollPosition) + + await page.getByRole('link', { name: 'About (No Reset)' }).click() + await expect(page.locator('#greeting')).toContainText('Hello from About!') + + await page.waitForFunction( + ([key, path, expectedY]) => { + const cache = sessionStorage.getItem(key) + if (!cache) return false + const parsed = JSON.parse(cache) + return parsed[path]?.window?.scrollY === expectedY + }, + [storageKey, '/about', targetScrollPosition] as const, + ) + + await page.goto('/foo') + await expect(page.getByTestId('foo-route-component')).toBeVisible() + + await page.goBack() + await expect(page.locator('#greeting')).toContainText('Hello from About!') + + await page.waitForFunction( + (expectedY) => window.scrollY === expectedY, + targetScrollPosition, + ) + + const restoredScrollPosition = await page.evaluate(() => window.scrollY) + expect(restoredScrollPosition).toBe(targetScrollPosition) +}) + +test('resetScroll=false preserves scrollToTopSelectors element scroll position, extension of #6595', async ({ + page, +}) => { + const storageKey = 'tsr-scroll-restoration-v1_3' + const sidebarScrollPosition = 500 + + await page.goto('/') + await expect(page.locator('#greeting')).toContainText('Welcome Home!') + + await page.evaluate((scrollPos: number) => { + const sidebar = document.querySelector('#sidebar') + if (sidebar) sidebar.scrollTo(0, scrollPos) + }, sidebarScrollPosition) + + const sidebarScrollBeforeNav = await page.evaluate( + () => document.querySelector('#sidebar')?.scrollTop, + ) + expect(sidebarScrollBeforeNav).toBe(sidebarScrollPosition) + + await page.getByRole('link', { name: 'About (No Reset)' }).click() + await expect(page.locator('#greeting')).toContainText('Hello from About!') + + await page.waitForFunction( + ([key, path, expectedY]) => { + const cache = sessionStorage.getItem(key) + if (!cache) return false + const parsed = JSON.parse(cache) + return parsed[path]?.['#sidebar']?.scrollY === expectedY + }, + [storageKey, '/about', sidebarScrollPosition] as const, + ) + + await page.goto('/foo') + await expect(page.getByTestId('foo-route-component')).toBeVisible() + + await page.goBack() + await expect(page.locator('#greeting')).toContainText('Hello from About!') + + await page.waitForFunction( + (expectedY) => document.querySelector('#sidebar')?.scrollTop === expectedY, + sidebarScrollPosition, + ) + + const restoredSidebarScroll = await page.evaluate( + () => document.querySelector('#sidebar')?.scrollTop, + ) + expect(restoredSidebarScroll).toBe(sidebarScrollPosition) +}) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 6520f307994..369d8063758 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -30,7 +30,12 @@ import { } from './path' import { createLRUCache } from './lru-cache' import { isNotFound } from './not-found' -import { setupScrollRestoration } from './scroll-restoration' +import { + defaultGetScrollRestorationKey, + getCssSelector, + scrollRestorationCache, + setupScrollRestoration, +} from './scroll-restoration' import { defaultParseSearch, defaultStringifySearch } from './searchParams' import { rootRouteId } from './root' import { isRedirect, redirect } from './redirect' @@ -41,6 +46,7 @@ import { executeRewriteOutput, rewriteBasepath, } from './rewrite' +import type { ScrollRestorationByElement } from './scroll-restoration' import type { LRUCache } from './lru-cache' import type { ProcessRouteTreeResult, @@ -2127,6 +2133,42 @@ export class RouterCore< this.shouldViewTransition = viewTransition + if ( + next.resetScroll === false && + this.isScrollRestoring && + scrollRestorationCache + ) { + const getKey = + this.options.getScrollRestorationKey || defaultGetScrollRestorationKey + const toKey = getKey(next as unknown as ParsedLocation) + scrollRestorationCache.set((state) => { + const keyEntry = (state[toKey] ||= {} as ScrollRestorationByElement) + keyEntry['window'] = { + scrollX: window.scrollX || 0, + scrollY: window.scrollY || 0, + } + if (this.options.scrollToTopSelectors) { + for (const selector of this.options.scrollToTopSelectors) { + const element = + typeof selector === 'function' + ? selector() + : document.querySelector(selector) + if (element) { + const elementSelector = + typeof selector === 'string' + ? selector + : getCssSelector(element) + keyEntry[elementSelector] = { + scrollX: element.scrollLeft || 0, + scrollY: element.scrollTop || 0, + } + } + } + } + return state + }) + } + this.history[next.replace ? 'replace' : 'push']( nextHistory.publicHref, nextHistory.state,