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 }