From d725a17cebf587d3264c123a0928e974518ab089 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sun, 3 May 2026 23:14:13 +0200 Subject: [PATCH] fix: Preserve existing nested scroll positions across SPA navigations that create new restoration keys. --- .changeset/soft-badgers-sneeze.md | 5 + .../scroll-restoration/src/routeTree.gen.ts | 86 ++++++++ .../scroll-restoration/src/router.tsx | 1 + .../src/routes/(tests)/nested-scroll-away.tsx | 14 ++ .../(tests)/nested-scroll-carry-over-a.tsx | 49 +++++ .../(tests)/nested-scroll-carry-over-b.tsx | 38 ++++ .../routes/(tests)/nested-scroll-search.tsx | 47 +++++ .../scroll-restoration/src/routes/index.tsx | 14 ++ .../tests/nested-scroll-carry-over.spec.ts | 196 ++++++++++++++++++ .../router-core/src/scroll-restoration.ts | 146 ++++++++----- 10 files changed, 548 insertions(+), 48 deletions(-) create mode 100644 .changeset/soft-badgers-sneeze.md create mode 100644 e2e/react-start/scroll-restoration/src/routes/(tests)/nested-scroll-away.tsx create mode 100644 e2e/react-start/scroll-restoration/src/routes/(tests)/nested-scroll-carry-over-a.tsx create mode 100644 e2e/react-start/scroll-restoration/src/routes/(tests)/nested-scroll-carry-over-b.tsx create mode 100644 e2e/react-start/scroll-restoration/src/routes/(tests)/nested-scroll-search.tsx create mode 100644 e2e/react-start/scroll-restoration/tests/nested-scroll-carry-over.spec.ts diff --git a/.changeset/soft-badgers-sneeze.md b/.changeset/soft-badgers-sneeze.md new file mode 100644 index 00000000000..cb97946ee97 --- /dev/null +++ b/.changeset/soft-badgers-sneeze.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +Preserve existing nested scroll positions across SPA navigations that create new restoration keys. diff --git a/e2e/react-start/scroll-restoration/src/routeTree.gen.ts b/e2e/react-start/scroll-restoration/src/routeTree.gen.ts index 5194bbc556a..07cb84ed0bb 100644 --- a/e2e/react-start/scroll-restoration/src/routeTree.gen.ts +++ b/e2e/react-start/scroll-restoration/src/routeTree.gen.ts @@ -13,6 +13,10 @@ import { Route as IndexRouteImport } from './routes/index' import { Route as testsWithSearchRouteImport } from './routes/(tests)/with-search' import { Route as testsWithLoaderRouteImport } from './routes/(tests)/with-loader' import { Route as testsNormalPageRouteImport } from './routes/(tests)/normal-page' +import { Route as testsNestedScrollSearchRouteImport } from './routes/(tests)/nested-scroll-search' +import { Route as testsNestedScrollCarryOverBRouteImport } from './routes/(tests)/nested-scroll-carry-over-b' +import { Route as testsNestedScrollCarryOverARouteImport } from './routes/(tests)/nested-scroll-carry-over-a' +import { Route as testsNestedScrollAwayRouteImport } from './routes/(tests)/nested-scroll-away' import { Route as testsHashScrollReproRouteImport } from './routes/(tests)/hash-scroll-repro' import { Route as testsHashScrollAboutRouteImport } from './routes/(tests)/hash-scroll-about' @@ -36,6 +40,28 @@ const testsNormalPageRoute = testsNormalPageRouteImport.update({ path: '/normal-page', getParentRoute: () => rootRouteImport, } as any) +const testsNestedScrollSearchRoute = testsNestedScrollSearchRouteImport.update({ + id: '/(tests)/nested-scroll-search', + path: '/nested-scroll-search', + getParentRoute: () => rootRouteImport, +} as any) +const testsNestedScrollCarryOverBRoute = + testsNestedScrollCarryOverBRouteImport.update({ + id: '/(tests)/nested-scroll-carry-over-b', + path: '/nested-scroll-carry-over-b', + getParentRoute: () => rootRouteImport, + } as any) +const testsNestedScrollCarryOverARoute = + testsNestedScrollCarryOverARouteImport.update({ + id: '/(tests)/nested-scroll-carry-over-a', + path: '/nested-scroll-carry-over-a', + getParentRoute: () => rootRouteImport, + } as any) +const testsNestedScrollAwayRoute = testsNestedScrollAwayRouteImport.update({ + id: '/(tests)/nested-scroll-away', + path: '/nested-scroll-away', + getParentRoute: () => rootRouteImport, +} as any) const testsHashScrollReproRoute = testsHashScrollReproRouteImport.update({ id: '/(tests)/hash-scroll-repro', path: '/hash-scroll-repro', @@ -51,6 +77,10 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/hash-scroll-about': typeof testsHashScrollAboutRoute '/hash-scroll-repro': typeof testsHashScrollReproRoute + '/nested-scroll-away': typeof testsNestedScrollAwayRoute + '/nested-scroll-carry-over-a': typeof testsNestedScrollCarryOverARoute + '/nested-scroll-carry-over-b': typeof testsNestedScrollCarryOverBRoute + '/nested-scroll-search': typeof testsNestedScrollSearchRoute '/normal-page': typeof testsNormalPageRoute '/with-loader': typeof testsWithLoaderRoute '/with-search': typeof testsWithSearchRoute @@ -59,6 +89,10 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/hash-scroll-about': typeof testsHashScrollAboutRoute '/hash-scroll-repro': typeof testsHashScrollReproRoute + '/nested-scroll-away': typeof testsNestedScrollAwayRoute + '/nested-scroll-carry-over-a': typeof testsNestedScrollCarryOverARoute + '/nested-scroll-carry-over-b': typeof testsNestedScrollCarryOverBRoute + '/nested-scroll-search': typeof testsNestedScrollSearchRoute '/normal-page': typeof testsNormalPageRoute '/with-loader': typeof testsWithLoaderRoute '/with-search': typeof testsWithSearchRoute @@ -68,6 +102,10 @@ export interface FileRoutesById { '/': typeof IndexRoute '/(tests)/hash-scroll-about': typeof testsHashScrollAboutRoute '/(tests)/hash-scroll-repro': typeof testsHashScrollReproRoute + '/(tests)/nested-scroll-away': typeof testsNestedScrollAwayRoute + '/(tests)/nested-scroll-carry-over-a': typeof testsNestedScrollCarryOverARoute + '/(tests)/nested-scroll-carry-over-b': typeof testsNestedScrollCarryOverBRoute + '/(tests)/nested-scroll-search': typeof testsNestedScrollSearchRoute '/(tests)/normal-page': typeof testsNormalPageRoute '/(tests)/with-loader': typeof testsWithLoaderRoute '/(tests)/with-search': typeof testsWithSearchRoute @@ -78,6 +116,10 @@ export interface FileRouteTypes { | '/' | '/hash-scroll-about' | '/hash-scroll-repro' + | '/nested-scroll-away' + | '/nested-scroll-carry-over-a' + | '/nested-scroll-carry-over-b' + | '/nested-scroll-search' | '/normal-page' | '/with-loader' | '/with-search' @@ -86,6 +128,10 @@ export interface FileRouteTypes { | '/' | '/hash-scroll-about' | '/hash-scroll-repro' + | '/nested-scroll-away' + | '/nested-scroll-carry-over-a' + | '/nested-scroll-carry-over-b' + | '/nested-scroll-search' | '/normal-page' | '/with-loader' | '/with-search' @@ -94,6 +140,10 @@ export interface FileRouteTypes { | '/' | '/(tests)/hash-scroll-about' | '/(tests)/hash-scroll-repro' + | '/(tests)/nested-scroll-away' + | '/(tests)/nested-scroll-carry-over-a' + | '/(tests)/nested-scroll-carry-over-b' + | '/(tests)/nested-scroll-search' | '/(tests)/normal-page' | '/(tests)/with-loader' | '/(tests)/with-search' @@ -103,6 +153,10 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute testsHashScrollAboutRoute: typeof testsHashScrollAboutRoute testsHashScrollReproRoute: typeof testsHashScrollReproRoute + testsNestedScrollAwayRoute: typeof testsNestedScrollAwayRoute + testsNestedScrollCarryOverARoute: typeof testsNestedScrollCarryOverARoute + testsNestedScrollCarryOverBRoute: typeof testsNestedScrollCarryOverBRoute + testsNestedScrollSearchRoute: typeof testsNestedScrollSearchRoute testsNormalPageRoute: typeof testsNormalPageRoute testsWithLoaderRoute: typeof testsWithLoaderRoute testsWithSearchRoute: typeof testsWithSearchRoute @@ -138,6 +192,34 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof testsNormalPageRouteImport parentRoute: typeof rootRouteImport } + '/(tests)/nested-scroll-search': { + id: '/(tests)/nested-scroll-search' + path: '/nested-scroll-search' + fullPath: '/nested-scroll-search' + preLoaderRoute: typeof testsNestedScrollSearchRouteImport + parentRoute: typeof rootRouteImport + } + '/(tests)/nested-scroll-carry-over-b': { + id: '/(tests)/nested-scroll-carry-over-b' + path: '/nested-scroll-carry-over-b' + fullPath: '/nested-scroll-carry-over-b' + preLoaderRoute: typeof testsNestedScrollCarryOverBRouteImport + parentRoute: typeof rootRouteImport + } + '/(tests)/nested-scroll-carry-over-a': { + id: '/(tests)/nested-scroll-carry-over-a' + path: '/nested-scroll-carry-over-a' + fullPath: '/nested-scroll-carry-over-a' + preLoaderRoute: typeof testsNestedScrollCarryOverARouteImport + parentRoute: typeof rootRouteImport + } + '/(tests)/nested-scroll-away': { + id: '/(tests)/nested-scroll-away' + path: '/nested-scroll-away' + fullPath: '/nested-scroll-away' + preLoaderRoute: typeof testsNestedScrollAwayRouteImport + parentRoute: typeof rootRouteImport + } '/(tests)/hash-scroll-repro': { id: '/(tests)/hash-scroll-repro' path: '/hash-scroll-repro' @@ -159,6 +241,10 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, testsHashScrollAboutRoute: testsHashScrollAboutRoute, testsHashScrollReproRoute: testsHashScrollReproRoute, + testsNestedScrollAwayRoute: testsNestedScrollAwayRoute, + testsNestedScrollCarryOverARoute: testsNestedScrollCarryOverARoute, + testsNestedScrollCarryOverBRoute: testsNestedScrollCarryOverBRoute, + testsNestedScrollSearchRoute: testsNestedScrollSearchRoute, testsNormalPageRoute: testsNormalPageRoute, testsWithLoaderRoute: testsWithLoaderRoute, testsWithSearchRoute: testsWithSearchRoute, diff --git a/e2e/react-start/scroll-restoration/src/router.tsx b/e2e/react-start/scroll-restoration/src/router.tsx index 81ffc07ad3e..0d529ba0024 100644 --- a/e2e/react-start/scroll-restoration/src/router.tsx +++ b/e2e/react-start/scroll-restoration/src/router.tsx @@ -7,6 +7,7 @@ export function getRouter() { const router = createRouter({ routeTree, scrollRestoration: true, + scrollToTopSelectors: ['[data-scroll-restoration-id="carry-over-reset"]'], getScrollRestorationKey: (location) => { if (location.pathname === '/hash-scroll-repro') { return location.pathname diff --git a/e2e/react-start/scroll-restoration/src/routes/(tests)/nested-scroll-away.tsx b/e2e/react-start/scroll-restoration/src/routes/(tests)/nested-scroll-away.tsx new file mode 100644 index 00000000000..82b43f80779 --- /dev/null +++ b/e2e/react-start/scroll-restoration/src/routes/(tests)/nested-scroll-away.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/(tests)/nested-scroll-away')({ + component: Component, +}) + +function Component() { + return ( +
+

nested-scroll-away

+

This route intentionally has no nested scroll container.

+
+ ) +} diff --git a/e2e/react-start/scroll-restoration/src/routes/(tests)/nested-scroll-carry-over-a.tsx b/e2e/react-start/scroll-restoration/src/routes/(tests)/nested-scroll-carry-over-a.tsx new file mode 100644 index 00000000000..624727775c8 --- /dev/null +++ b/e2e/react-start/scroll-restoration/src/routes/(tests)/nested-scroll-carry-over-a.tsx @@ -0,0 +1,49 @@ +import { Link, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/(tests)/nested-scroll-carry-over-a')({ + component: Component, +}) + +function ScrollBox({ + restorationId, + testId, +}: { + restorationId: string + testId: string +}) { + return ( +
+ {Array.from({ length: 20 }).map((_, i) => ( +
Source row {i}
+ ))} +
+ ) +} + +function Component() { + return ( +
+

nested-scroll-carry-over-a

+ + Go to target + + + + + +
+ ) +} diff --git a/e2e/react-start/scroll-restoration/src/routes/(tests)/nested-scroll-carry-over-b.tsx b/e2e/react-start/scroll-restoration/src/routes/(tests)/nested-scroll-carry-over-b.tsx new file mode 100644 index 00000000000..69dddb2d147 --- /dev/null +++ b/e2e/react-start/scroll-restoration/src/routes/(tests)/nested-scroll-carry-over-b.tsx @@ -0,0 +1,38 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/(tests)/nested-scroll-carry-over-b')({ + component: Component, +}) + +function ScrollBox({ + restorationId, + testId, +}: { + restorationId: string + testId: string +}) { + return ( +
+ {Array.from({ length: 20 }).map((_, i) => ( +
Target row {i}
+ ))} +
+ ) +} + +function Component() { + return ( +
+

nested-scroll-carry-over-b

+ + +
+ ) +} diff --git a/e2e/react-start/scroll-restoration/src/routes/(tests)/nested-scroll-search.tsx b/e2e/react-start/scroll-restoration/src/routes/(tests)/nested-scroll-search.tsx new file mode 100644 index 00000000000..c9889386f28 --- /dev/null +++ b/e2e/react-start/scroll-restoration/src/routes/(tests)/nested-scroll-search.tsx @@ -0,0 +1,47 @@ +import { Link, createFileRoute } from '@tanstack/react-router' +import { z } from 'zod' +import { zodValidator } from '@tanstack/zod-adapter' + +export const Route = createFileRoute('/(tests)/nested-scroll-search')({ + validateSearch: zodValidator( + z.object({ + query: z.string().optional(), + }), + ), + component: Component, +}) + +function Component() { + const search = Route.useSearch() + const query = search.query ?? 'none' + + return ( +
+

nested-scroll-search

+

query: {query}

+
+ + Set query + + + Away + +
+
+ {Array.from({ length: 20 }).map((_, i) => ( +
+ Query {query} row {i} +
+ ))} +
+
+ ) +} diff --git a/e2e/react-start/scroll-restoration/src/routes/index.tsx b/e2e/react-start/scroll-restoration/src/routes/index.tsx index 9d36cca2644..ce0e57417c8 100644 --- a/e2e/react-start/scroll-restoration/src/routes/index.tsx +++ b/e2e/react-start/scroll-restoration/src/routes/index.tsx @@ -30,6 +30,20 @@ function HomeComponent() {

))} +
+

scroll restoration repro routes

+

+ /hash-scroll-repro +

+

+ + /nested-scroll-carry-over-a + +

+

+ /nested-scroll-search +

+
) } diff --git a/e2e/react-start/scroll-restoration/tests/nested-scroll-carry-over.spec.ts b/e2e/react-start/scroll-restoration/tests/nested-scroll-carry-over.spec.ts new file mode 100644 index 00000000000..b26a6e32d34 --- /dev/null +++ b/e2e/react-start/scroll-restoration/tests/nested-scroll-carry-over.spec.ts @@ -0,0 +1,196 @@ +import { expect, test } from '@playwright/test' + +const storageKey = 'tsr-scroll-restoration-v1_3' + +test('carries existing nested scroll entries to the next key', async ({ + page, +}) => { + await page.goto('/nested-scroll-carry-over-a') + await page.waitForLoadState('networkidle') + await expect( + page.getByRole('heading', { name: 'nested-scroll-carry-over-a' }), + ).toBeVisible() + + const positions = await page.evaluate(() => { + const getScroller = (testId: string) => { + const element = document.querySelector(`[data-testid="${testId}"]`) + if (!(element instanceof HTMLElement)) { + throw new Error(`Missing ${testId}`) + } + return element + } + + const preserved = getScroller('carry-over-preserved') + const reset = getScroller('carry-over-reset') + const sourceOnly = getScroller('carry-over-source-only') + + preserved.scrollTop = 80 + reset.scrollTop = 80 + sourceOnly.scrollTop = 80 + + preserved.dispatchEvent(new Event('scroll', { bubbles: true })) + reset.dispatchEvent(new Event('scroll', { bubbles: true })) + sourceOnly.dispatchEvent(new Event('scroll', { bubbles: true })) + + return { + preserved: preserved.scrollTop, + reset: reset.scrollTop, + sourceOnly: sourceOnly.scrollTop, + } + }) + + expect(positions.preserved).toBeGreaterThan(0) + expect(positions.reset).toBeGreaterThan(0) + expect(positions.sourceOnly).toBeGreaterThan(0) + + await page.getByTestId('nested-scroll-carry-over-link').click() + await expect( + page.getByRole('heading', { name: 'nested-scroll-carry-over-b' }), + ).toBeVisible() + + await expect + .poll(async () => { + return page.evaluate(() => { + const element = document.querySelector( + '[data-testid="carry-over-preserved"]', + ) + return element instanceof HTMLElement ? element.scrollTop : 0 + }) + }) + .toBe(positions.preserved) + + await expect + .poll(async () => { + return page.evaluate(() => { + const element = document.querySelector( + '[data-testid="carry-over-reset"]', + ) + return element instanceof HTMLElement ? element.scrollTop : -1 + }) + }) + .toBe(0) + + await page.reload() + await page.waitForLoadState('networkidle') + await expect( + page.getByRole('heading', { name: 'nested-scroll-carry-over-b' }), + ).toBeVisible() + + await expect + .poll(async () => { + return page.evaluate(() => { + const element = document.querySelector( + '[data-testid="carry-over-preserved"]', + ) + return element instanceof HTMLElement ? element.scrollTop : 0 + }) + }) + .toBe(positions.preserved) + + await expect + .poll(async () => { + return page.evaluate(() => { + const element = document.querySelector( + '[data-testid="carry-over-reset"]', + ) + return element instanceof HTMLElement ? element.scrollTop : -1 + }) + }) + .toBe(0) + + const targetKeyEntries = await page.evaluate((scrollStorageKey) => { + const targetKey = window.history.state.__TSR_key + const raw = window.sessionStorage.getItem(scrollStorageKey) + const state = raw ? JSON.parse(raw) : {} + const entries = state[targetKey] ?? {} + + return { + hasPreserved: Object.hasOwn( + entries, + '[data-scroll-restoration-id="carry-over-preserved"]', + ), + hasReset: Object.hasOwn( + entries, + '[data-scroll-restoration-id="carry-over-reset"]', + ), + hasSourceOnly: Object.hasOwn( + entries, + '[data-scroll-restoration-id="carry-over-source-only"]', + ), + } + }, storageKey) + + expect(targetKeyEntries).toEqual({ + hasPreserved: true, + hasReset: false, + hasSourceOnly: false, + }) +}) + +test('restores carried nested scroll after search navigation and browser back', async ({ + page, +}) => { + await page.goto('/nested-scroll-search') + await page.waitForLoadState('networkidle') + await expect( + page.getByRole('heading', { name: 'nested-scroll-search' }), + ).toBeVisible() + + const scrollTop = await page.evaluate(() => { + const element = document.querySelector( + '[data-testid="nested-scroll-search-container"]', + ) + if (!(element instanceof HTMLElement)) { + throw new Error('Missing nested scroll container') + } + + element.scrollTop = 80 + element.dispatchEvent(new Event('scroll', { bubbles: true })) + return element.scrollTop + }) + + expect(scrollTop).toBeGreaterThan(0) + + await page.getByTestId('nested-scroll-search-link').click() + await expect(page.getByTestId('nested-scroll-search-query')).toHaveText( + 'query: xyz', + ) + + await expect + .poll(async () => { + return page.evaluate(() => { + const element = document.querySelector( + '[data-testid="nested-scroll-search-container"]', + ) + return element instanceof HTMLElement ? element.scrollTop : 0 + }) + }) + .toBe(scrollTop) + + await page.getByTestId('nested-scroll-away-link').click() + await expect( + page.getByRole('heading', { name: 'nested-scroll-away' }), + ).toBeVisible() + await expect(page.getByTestId('nested-scroll-search-container')).toHaveCount( + 0, + ) + + await page.goBack() + await expect( + page.getByRole('heading', { name: 'nested-scroll-search' }), + ).toBeVisible() + await expect(page.getByTestId('nested-scroll-search-query')).toHaveText( + 'query: xyz', + ) + + await expect + .poll(async () => { + return page.evaluate(() => { + const element = document.querySelector( + '[data-testid="nested-scroll-search-container"]', + ) + return element instanceof HTMLElement ? element.scrollTop : 0 + }) + }) + .toBe(scrollTop) +}) diff --git a/packages/router-core/src/scroll-restoration.ts b/packages/router-core/src/scroll-restoration.ts index d4ec57fbdba..83966b0e9a0 100644 --- a/packages/router-core/src/scroll-restoration.ts +++ b/packages/router-core/src/scroll-restoration.ts @@ -1,9 +1,8 @@ import { isServer } from '@tanstack/router-core/isServer' -import { functionalUpdate, isPlainObject } from './utils' +import { isPlainObject } from './utils' import { historyActionKey } from './router' import type { AnyRouter, ParsedLocationWithHistoryAction } from './router' import type { ParsedLocation } from './location' -import type { NonNullableUpdater } from './utils' export type ScrollRestorationEntry = { scrollX: number; scrollY: number } @@ -13,7 +12,6 @@ type ScrollRestorationByKey = Record type ScrollRestorationCache = { readonly state: ScrollRestorationByKey - set: (updater: NonNullableUpdater) => void persist: () => void } @@ -70,9 +68,6 @@ function createScrollRestorationCache(): ScrollRestorationCache | null { get state() { return state }, - set: (updater) => { - state = functionalUpdate(updater, state) || state - }, persist, } } @@ -89,15 +84,23 @@ export const defaultGetScrollRestorationKey = (location: ParsedLocation) => { return location.state.__TSR_key! || location.href } -function getCssSelector(el: any): string { +function getScrollRestorationSelector(element: Element): string { + const attrId = element.getAttribute(scrollRestorationIdAttribute) + if (attrId) { + return `[${scrollRestorationIdAttribute}="${attrId}"]` + } + const path = [] + let el: any = element let parent: HTMLElement + while ((parent = el.parentNode)) { path.push( `${el.tagName}:nth-child(${Array.prototype.indexOf.call(parent.children, el) + 1})`, ) el = parent } + return `${path.reverse().join(' > ')}`.toLowerCase() } @@ -131,7 +134,9 @@ export function getElementScrollRestorationEntry( } return scrollRestorationCache?.state[restoreKey]?.[ - element instanceof Window ? windowScrollTarget : getCssSelector(element) + element instanceof Window + ? windowScrollTarget + : getScrollRestorationSelector(element) ] } @@ -140,13 +145,40 @@ const windowScrollTarget = 'window' const scrollRestorationIdAttribute = 'data-scroll-restoration-id' type ScrollTarget = typeof windowScrollTarget | Element +function getElement(selector: string | (() => Element | null | undefined)) { + try { + return typeof selector === 'function' + ? selector() + : document.querySelector(selector) + } catch {} + return +} + +function getScrollToTopElements( + scrollToTopSelectors: NonNullable< + AnyRouter['options']['scrollToTopSelectors'] + >, +): Array { + const elements: Array = [] + + for (const selector of scrollToTopSelectors) { + if (selector === windowScrollTarget) { + continue + } + + const element = getElement(selector) + if (element) { + elements.push(element) + } + } + + return elements +} + export function setupScrollRestoration(router: AnyRouter, force?: boolean) { // Keep hash/top scrolling active even when sessionStorage is unavailable. - const shouldScrollRestoration = - force ?? router.options.scrollRestoration ?? false - - if (shouldScrollRestoration) { + if (force ?? router.options.scrollRestoration ?? false) { router.isScrollRestoring = true } @@ -197,22 +229,11 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) { {} as ScrollRestorationByElement) for (const [target, position] of trackedScrollEntries) { - let selector: string | undefined - if (target === windowScrollTarget) { - selector = windowScrollTarget + keyEntry[windowScrollTarget] = position } else if (target.isConnected) { - const attrId = target.getAttribute(scrollRestorationIdAttribute) - selector = attrId - ? `[${scrollRestorationIdAttribute}="${attrId}"]` - : getCssSelector(target) - } - - if (!selector) { - continue + keyEntry[getScrollRestorationSelector(target)] = position } - - keyEntry[selector] = position } } @@ -234,7 +255,6 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) { // Restore destination scroll after the new route has rendered. router.subscribe('onRendered', (event) => { - const cacheKey = getKey(event.toLocation) const behavior = router.options.scrollRestorationBehavior const scrollToTopSelectors = router.options.scrollToTopSelectors trackedScrollEntries.clear() @@ -251,6 +271,9 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) { return } + const cacheKey = getKey(event.toLocation) + const fromCacheKey = event.fromLocation && getKey(event.fromLocation) + ignoreScroll = true try { @@ -264,6 +287,48 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) { hash && hashScrollIntoViewOptions && (action === 'PUSH' || action === 'REPLACE') + let scrollToTopElements: Array | undefined + + if ( + router.isScrollRestoring && + scrollRestorationCache && + fromCacheKey && + fromCacheKey !== cacheKey + ) { + const fromElementEntries = scrollRestorationCache.state[fromCacheKey] + + if (fromElementEntries) { + let toElementEntries = scrollRestorationCache.state[cacheKey] + + if (scrollToTopSelectors) { + scrollToTopElements = getScrollToTopElements(scrollToTopSelectors) + } + + for (const elementSelector in fromElementEntries) { + if (elementSelector === windowScrollTarget) { + continue + } + + const element = getElement(elementSelector) + if (!element) { + continue + } + + if (scrollToTopElements?.includes(element)) { + continue + } + + if (!toElementEntries) { + toElementEntries = scrollRestorationCache.state[cacheKey] = + {} as ScrollRestorationByElement + } + + toElementEntries[elementSelector] ??= + fromElementEntries[elementSelector]! + } + } + } + const elementEntries = router.isScrollRestoring ? scrollRestorationCache?.state[cacheKey] : undefined @@ -297,15 +362,8 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) { behavior, }) windowRestored = true - } else if (elementSelector) { - let element - - try { - element = document.querySelector(elementSelector) - } catch { - continue - } - + } else { + const element = getElement(elementSelector) if (element) { element.scrollLeft = scrollX as number element.scrollTop = scrollY as number @@ -330,15 +388,9 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) { window.scrollTo(scrollOptions) if (scrollToTopSelectors) { - for (const selector of scrollToTopSelectors) { - if (selector === windowScrollTarget) continue - const element = - typeof selector === 'function' - ? selector() - : document.querySelector(selector) - if (element) { - element.scrollTo(scrollOptions) - } + scrollToTopElements ??= getScrollToTopElements(scrollToTopSelectors) + for (const element of scrollToTopElements) { + element.scrollTo(scrollOptions) } } } @@ -348,10 +400,8 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) { } if (router.isScrollRestoring && scrollRestorationCache) { - scrollRestorationCache.set((state) => { - state[cacheKey] ||= {} as ScrollRestorationByElement - return state - }) + scrollRestorationCache.state[cacheKey] ||= + {} as ScrollRestorationByElement } }) }