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
}
})
}