From b18d5ba270cfcb32f49d8193f5500f91b2167016 Mon Sep 17 00:00:00 2001
From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com>
Date: Mon, 13 Jul 2026 19:44:03 +0200
Subject: [PATCH] fix(router-core): handle window and element scroll
restoration independently
Window and element scroll targets are now handled independently. Restoring one target no longer suppresses resets for other uncached configured targets, and a restored element is no longer reset when the window has no cached position.
Hash navigation no longer resets elements configured through `scrollToTopSelectors` and retains precedence over stale window positions through destination invalidations.
Scroll positions are sampled when leaving a route, preserving live changes made after the most recent scroll event. This also prevents client hydration from undoing nested positions restored by the SSR script.
Fixes #7687.
---
.changeset/fix-independent-scroll-targets.md | 13 +
.../scroll-restoration/src/routeTree.gen.ts | 71 +++++
.../scroll-restoration/src/router.tsx | 21 +-
.../src/routes/(tests)/hash-scroll-about.tsx | 16 +-
.../src/routes/(tests)/hash-scroll-repro.tsx | 46 ++++
.../src/routes/(tests)/issue-7687.detail.tsx | 16 ++
.../src/routes/(tests)/issue-7687.index.tsx | 46 ++++
.../src/routes/(tests)/issue-7687.tsx | 49 ++++
.../src/routes/(tests)/ssr-scroll-key.tsx | 17 ++
.../tests/hash-scroll-repro.spec.ts | 119 +++++++--
.../tests/issue-7687.spec.ts | 242 +++++++++++++++++
.../tests/ssr-scroll-key.spec.ts | 74 ++++++
packages/router-core/src/router.ts | 12 +-
.../router-core/src/scroll-restoration.ts | 96 +++----
.../tests/scroll-restoration.test.ts | 249 +++++++++++++++++-
15 files changed, 997 insertions(+), 90 deletions(-)
create mode 100644 .changeset/fix-independent-scroll-targets.md
create mode 100644 e2e/react-start/scroll-restoration/src/routes/(tests)/issue-7687.detail.tsx
create mode 100644 e2e/react-start/scroll-restoration/src/routes/(tests)/issue-7687.index.tsx
create mode 100644 e2e/react-start/scroll-restoration/src/routes/(tests)/issue-7687.tsx
create mode 100644 e2e/react-start/scroll-restoration/tests/issue-7687.spec.ts
diff --git a/.changeset/fix-independent-scroll-targets.md b/.changeset/fix-independent-scroll-targets.md
new file mode 100644
index 0000000000..d47294ee3e
--- /dev/null
+++ b/.changeset/fix-independent-scroll-targets.md
@@ -0,0 +1,13 @@
+---
+'@tanstack/router-core': patch
+---
+
+fix(router-core): handle window and element scroll restoration independently
+
+Window and element scroll targets are now handled independently. Restoring one target no longer suppresses resets for other uncached configured targets, and a restored element is no longer reset when the window has no cached position.
+
+Hash navigation no longer resets elements configured through `scrollToTopSelectors` and retains precedence over stale window positions through destination invalidations.
+
+Scroll positions are sampled when leaving a route, preserving live changes made after the most recent scroll event. This also prevents client hydration from undoing nested positions restored by the SSR script.
+
+Fixes #7687.
diff --git a/e2e/react-start/scroll-restoration/src/routeTree.gen.ts b/e2e/react-start/scroll-restoration/src/routeTree.gen.ts
index 1df27a0424..7acedcb1d7 100644
--- a/e2e/react-start/scroll-restoration/src/routeTree.gen.ts
+++ b/e2e/react-start/scroll-restoration/src/routeTree.gen.ts
@@ -21,8 +21,11 @@ import { Route as testsNestedScrollSearchRouteImport } from './routes/(tests)/ne
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 testsIssue7687RouteImport } from './routes/(tests)/issue-7687'
import { Route as testsHashScrollReproRouteImport } from './routes/(tests)/hash-scroll-repro'
import { Route as testsHashScrollAboutRouteImport } from './routes/(tests)/hash-scroll-about'
+import { Route as testsIssue7687IndexRouteImport } from './routes/(tests)/issue-7687.index'
+import { Route as testsIssue7687DetailRouteImport } from './routes/(tests)/issue-7687.detail'
const IndexRoute = IndexRouteImport.update({
id: '/',
@@ -86,6 +89,11 @@ const testsNestedScrollAwayRoute = testsNestedScrollAwayRouteImport.update({
path: '/nested-scroll-away',
getParentRoute: () => rootRouteImport,
} as any)
+const testsIssue7687Route = testsIssue7687RouteImport.update({
+ id: '/(tests)/issue-7687',
+ path: '/issue-7687',
+ getParentRoute: () => rootRouteImport,
+} as any)
const testsHashScrollReproRoute = testsHashScrollReproRouteImport.update({
id: '/(tests)/hash-scroll-repro',
path: '/hash-scroll-repro',
@@ -96,11 +104,22 @@ const testsHashScrollAboutRoute = testsHashScrollAboutRouteImport.update({
path: '/hash-scroll-about',
getParentRoute: () => rootRouteImport,
} as any)
+const testsIssue7687IndexRoute = testsIssue7687IndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => testsIssue7687Route,
+} as any)
+const testsIssue7687DetailRoute = testsIssue7687DetailRouteImport.update({
+ id: '/detail',
+ path: '/detail',
+ getParentRoute: () => testsIssue7687Route,
+} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/hash-scroll-about': typeof testsHashScrollAboutRoute
'/hash-scroll-repro': typeof testsHashScrollReproRoute
+ '/issue-7687': typeof testsIssue7687RouteWithChildren
'/nested-scroll-away': typeof testsNestedScrollAwayRoute
'/nested-scroll-carry-over-a': typeof testsNestedScrollCarryOverARoute
'/nested-scroll-carry-over-b': typeof testsNestedScrollCarryOverBRoute
@@ -112,6 +131,8 @@ export interface FileRoutesByFullPath {
'/ssr-scroll-key': typeof testsSsrScrollKeyRoute
'/with-loader': typeof testsWithLoaderRoute
'/with-search': typeof testsWithSearchRoute
+ '/issue-7687/detail': typeof testsIssue7687DetailRoute
+ '/issue-7687/': typeof testsIssue7687IndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ -128,12 +149,15 @@ export interface FileRoutesByTo {
'/ssr-scroll-key': typeof testsSsrScrollKeyRoute
'/with-loader': typeof testsWithLoaderRoute
'/with-search': typeof testsWithSearchRoute
+ '/issue-7687/detail': typeof testsIssue7687DetailRoute
+ '/issue-7687': typeof testsIssue7687IndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/(tests)/hash-scroll-about': typeof testsHashScrollAboutRoute
'/(tests)/hash-scroll-repro': typeof testsHashScrollReproRoute
+ '/(tests)/issue-7687': typeof testsIssue7687RouteWithChildren
'/(tests)/nested-scroll-away': typeof testsNestedScrollAwayRoute
'/(tests)/nested-scroll-carry-over-a': typeof testsNestedScrollCarryOverARoute
'/(tests)/nested-scroll-carry-over-b': typeof testsNestedScrollCarryOverBRoute
@@ -145,6 +169,8 @@ export interface FileRoutesById {
'/(tests)/ssr-scroll-key': typeof testsSsrScrollKeyRoute
'/(tests)/with-loader': typeof testsWithLoaderRoute
'/(tests)/with-search': typeof testsWithSearchRoute
+ '/(tests)/issue-7687/detail': typeof testsIssue7687DetailRoute
+ '/(tests)/issue-7687/': typeof testsIssue7687IndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -152,6 +178,7 @@ export interface FileRouteTypes {
| '/'
| '/hash-scroll-about'
| '/hash-scroll-repro'
+ | '/issue-7687'
| '/nested-scroll-away'
| '/nested-scroll-carry-over-a'
| '/nested-scroll-carry-over-b'
@@ -163,6 +190,8 @@ export interface FileRouteTypes {
| '/ssr-scroll-key'
| '/with-loader'
| '/with-search'
+ | '/issue-7687/detail'
+ | '/issue-7687/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -179,11 +208,14 @@ export interface FileRouteTypes {
| '/ssr-scroll-key'
| '/with-loader'
| '/with-search'
+ | '/issue-7687/detail'
+ | '/issue-7687'
id:
| '__root__'
| '/'
| '/(tests)/hash-scroll-about'
| '/(tests)/hash-scroll-repro'
+ | '/(tests)/issue-7687'
| '/(tests)/nested-scroll-away'
| '/(tests)/nested-scroll-carry-over-a'
| '/(tests)/nested-scroll-carry-over-b'
@@ -195,12 +227,15 @@ export interface FileRouteTypes {
| '/(tests)/ssr-scroll-key'
| '/(tests)/with-loader'
| '/(tests)/with-search'
+ | '/(tests)/issue-7687/detail'
+ | '/(tests)/issue-7687/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
testsHashScrollAboutRoute: typeof testsHashScrollAboutRoute
testsHashScrollReproRoute: typeof testsHashScrollReproRoute
+ testsIssue7687Route: typeof testsIssue7687RouteWithChildren
testsNestedScrollAwayRoute: typeof testsNestedScrollAwayRoute
testsNestedScrollCarryOverARoute: typeof testsNestedScrollCarryOverARoute
testsNestedScrollCarryOverBRoute: typeof testsNestedScrollCarryOverBRoute
@@ -300,6 +335,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof testsNestedScrollAwayRouteImport
parentRoute: typeof rootRouteImport
}
+ '/(tests)/issue-7687': {
+ id: '/(tests)/issue-7687'
+ path: '/issue-7687'
+ fullPath: '/issue-7687'
+ preLoaderRoute: typeof testsIssue7687RouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/(tests)/hash-scroll-repro': {
id: '/(tests)/hash-scroll-repro'
path: '/hash-scroll-repro'
@@ -314,13 +356,42 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof testsHashScrollAboutRouteImport
parentRoute: typeof rootRouteImport
}
+ '/(tests)/issue-7687/': {
+ id: '/(tests)/issue-7687/'
+ path: '/'
+ fullPath: '/issue-7687/'
+ preLoaderRoute: typeof testsIssue7687IndexRouteImport
+ parentRoute: typeof testsIssue7687Route
+ }
+ '/(tests)/issue-7687/detail': {
+ id: '/(tests)/issue-7687/detail'
+ path: '/detail'
+ fullPath: '/issue-7687/detail'
+ preLoaderRoute: typeof testsIssue7687DetailRouteImport
+ parentRoute: typeof testsIssue7687Route
+ }
}
}
+interface testsIssue7687RouteChildren {
+ testsIssue7687DetailRoute: typeof testsIssue7687DetailRoute
+ testsIssue7687IndexRoute: typeof testsIssue7687IndexRoute
+}
+
+const testsIssue7687RouteChildren: testsIssue7687RouteChildren = {
+ testsIssue7687DetailRoute: testsIssue7687DetailRoute,
+ testsIssue7687IndexRoute: testsIssue7687IndexRoute,
+}
+
+const testsIssue7687RouteWithChildren = testsIssue7687Route._addFileChildren(
+ testsIssue7687RouteChildren,
+)
+
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
testsHashScrollAboutRoute: testsHashScrollAboutRoute,
testsHashScrollReproRoute: testsHashScrollReproRoute,
+ testsIssue7687Route: testsIssue7687RouteWithChildren,
testsNestedScrollAwayRoute: testsNestedScrollAwayRoute,
testsNestedScrollCarryOverARoute: testsNestedScrollCarryOverARoute,
testsNestedScrollCarryOverBRoute: testsNestedScrollCarryOverBRoute,
diff --git a/e2e/react-start/scroll-restoration/src/router.tsx b/e2e/react-start/scroll-restoration/src/router.tsx
index eb81f5bfa9..27d68369db 100644
--- a/e2e/react-start/scroll-restoration/src/router.tsx
+++ b/e2e/react-start/scroll-restoration/src/router.tsx
@@ -7,13 +7,32 @@ export function getRouter() {
const router = createRouter({
routeTree,
scrollRestoration: true,
- scrollToTopSelectors: ['[data-scroll-restoration-id="carry-over-reset"]'],
+ scrollToTopSelectors: [
+ '[data-scroll-restoration-id="carry-over-reset"]',
+ '#hash-scroll-nested',
+ '#hash-scroll-reset-target',
+ '#issue-7687-reset-probe',
+ '#issue-7687-scroller',
+ () =>
+ typeof document === 'undefined'
+ ? undefined
+ : document.getElementById('issue-7687-stale-selector'),
+ '#ssr-scroll-key-nested',
+ ],
getScrollRestorationKey: (location) => {
if (location.pathname === '/ssr-scroll-key') {
return 'ssr-scroll-key'
}
if (location.pathname === '/hash-scroll-repro') {
+ const scrollKey =
+ 'scrollKey' in location.search ? location.search.scrollKey : undefined
+ return typeof scrollKey === 'string'
+ ? `${location.pathname}:${scrollKey}`
+ : location.pathname
+ }
+
+ if (location.pathname.startsWith('/issue-7687')) {
return location.pathname
}
diff --git a/e2e/react-start/scroll-restoration/src/routes/(tests)/hash-scroll-about.tsx b/e2e/react-start/scroll-restoration/src/routes/(tests)/hash-scroll-about.tsx
index 2c2748e6a5..1ee24f4b1f 100644
--- a/e2e/react-start/scroll-restoration/src/routes/(tests)/hash-scroll-about.tsx
+++ b/e2e/react-start/scroll-restoration/src/routes/(tests)/hash-scroll-about.tsx
@@ -1,5 +1,5 @@
import * as React from 'react'
-import { createFileRoute } from '@tanstack/react-router'
+import { Link, createFileRoute } from '@tanstack/react-router'
import { sleep } from '~/utils/posts'
export const Route = createFileRoute('/(tests)/hash-scroll-about')({
@@ -17,6 +17,20 @@ function Component() {
This route exists so hover preloading can reproduce the hash scroll bug.
+
+ Return to #one
+
+
+ Return to the destination key
+
)
}
diff --git a/e2e/react-start/scroll-restoration/src/routes/(tests)/hash-scroll-repro.tsx b/e2e/react-start/scroll-restoration/src/routes/(tests)/hash-scroll-repro.tsx
index 1017b63bc3..c2c681247a 100644
--- a/e2e/react-start/scroll-restoration/src/routes/(tests)/hash-scroll-repro.tsx
+++ b/e2e/react-start/scroll-restoration/src/routes/(tests)/hash-scroll-repro.tsx
@@ -1,8 +1,13 @@
import * as React from 'react'
import { Link, createFileRoute, useRouter } from '@tanstack/react-router'
+import { z } from 'zod'
import { sleep } from '~/utils/posts'
export const Route = createFileRoute('/(tests)/hash-scroll-repro')({
+ validateSearch: z.object({
+ scrollKey: z.string().optional(),
+ invalidateOnMount: z.boolean().optional(),
+ }),
loader: async () => {
await sleep(50)
return null
@@ -14,7 +19,20 @@ const sectionIds = ['one', 'two', 'three', 'four', 'five'] as const
function Component() {
const router = useRouter()
+ const { invalidateOnMount } = Route.useSearch()
const [invalidateCount, setInvalidateCount] = React.useState(0)
+ const invalidatedOnMount = React.useRef(false)
+
+ React.useLayoutEffect(() => {
+ if (!invalidateOnMount || invalidatedOnMount.current) {
+ return
+ }
+
+ invalidatedOnMount.current = true
+ void router.invalidate().then(() => {
+ setInvalidateCount((count) => count + 1)
+ })
+ }, [invalidateOnMount, router])
return (
@@ -61,6 +79,24 @@ function Component() {
>
#four no reset
+
+ #one different key
+
+
+ #one and invalidate in layout effect
+
@@ -74,6 +110,16 @@ function Component() {
))}
+
+
{sectionIds.map((sectionId) => (
+ issue-7687-detail
+ {Array.from({ length: 40 }).map((_, i) => (
+ Detail row {i}
+ ))}
+
+ )
+}
diff --git a/e2e/react-start/scroll-restoration/src/routes/(tests)/issue-7687.index.tsx b/e2e/react-start/scroll-restoration/src/routes/(tests)/issue-7687.index.tsx
new file mode 100644
index 0000000000..82450e43f3
--- /dev/null
+++ b/e2e/react-start/scroll-restoration/src/routes/(tests)/issue-7687.index.tsx
@@ -0,0 +1,46 @@
+import * as React from 'react'
+import { createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/(tests)/issue-7687/')({
+ component: Component,
+})
+
+const visitKey = 'issue-7687-list-visits'
+
+function ResetProbe() {
+ const ref = React.useRef(null)
+
+ React.useLayoutEffect(() => {
+ const visits = Number(window.sessionStorage.getItem(visitKey) ?? 0)
+ window.sessionStorage.setItem(visitKey, String(visits + 1))
+
+ if (visits > 0 && ref.current) {
+ ref.current.scrollTop = 80
+ }
+ }, [])
+
+ return (
+
+ {Array.from({ length: 20 }).map((_, i) => (
+
Reset probe row {i}
+ ))}
+
+ )
+}
+
+function Component() {
+ return (
+
+
issue-7687-list
+
+ {Array.from({ length: 40 }).map((_, i) => (
+
List row {i}
+ ))}
+
+ )
+}
diff --git a/e2e/react-start/scroll-restoration/src/routes/(tests)/issue-7687.tsx b/e2e/react-start/scroll-restoration/src/routes/(tests)/issue-7687.tsx
new file mode 100644
index 0000000000..f7091cd829
--- /dev/null
+++ b/e2e/react-start/scroll-restoration/src/routes/(tests)/issue-7687.tsx
@@ -0,0 +1,49 @@
+import { Link, Outlet, createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/(tests)/issue-7687')({
+ component: Component,
+})
+
+function Component() {
+ return (
+
+
+
+
+
+
+
+
+ {Array.from({ length: 20 }).map((_, i) => (
+
Stale selector row {i}
+ ))}
+
+
+
+
+ )
+}
diff --git a/e2e/react-start/scroll-restoration/src/routes/(tests)/ssr-scroll-key.tsx b/e2e/react-start/scroll-restoration/src/routes/(tests)/ssr-scroll-key.tsx
index e224ed9137..dd7375660b 100644
--- a/e2e/react-start/scroll-restoration/src/routes/(tests)/ssr-scroll-key.tsx
+++ b/e2e/react-start/scroll-restoration/src/routes/(tests)/ssr-scroll-key.tsx
@@ -7,9 +7,26 @@ export const Route = createFileRoute('/(tests)/ssr-scroll-key')({
})
function Component() {
+ const [hydrated, setHydrated] = React.useState(false)
+
+ React.useEffect(() => {
+ setHydrated(true)
+ }, [])
+
return (
ssr-scroll-key
+ {hydrated ?
: null}
+
)
diff --git a/e2e/react-start/scroll-restoration/tests/hash-scroll-repro.spec.ts b/e2e/react-start/scroll-restoration/tests/hash-scroll-repro.spec.ts
index d203ea8c26..7a6aa72161 100644
--- a/e2e/react-start/scroll-restoration/tests/hash-scroll-repro.spec.ts
+++ b/e2e/react-start/scroll-restoration/tests/hash-scroll-repro.spec.ts
@@ -103,43 +103,59 @@ test('hash navigation wins over stale same-tab scroll restoration entries', asyn
expect(scrollYAfterHashNavigation).toBeLessThan(staleScrollY)
})
-test('hash navigation still runs when only nested scroll entries restore', async ({
+test('hash navigation wins when the destination invalidates in a layout effect', async ({
page,
}) => {
await goToRepro(page)
+ const staleScrollY = await scrollUpFromHashTarget(page)
- const nestedScrollTop = await page.evaluate(() => {
- const nested = document.querySelector('[data-testid="hash-scroll-nested"]')
- if (!(nested instanceof HTMLElement)) {
- throw new Error('Missing nested scroller')
- }
+ await page.reload()
+ await page.waitForLoadState('networkidle')
+ await expect(
+ page.getByTestId('hash-scroll-repro-invalidate-count'),
+ ).toHaveText('Invalidate count: 0')
+
+ await page.getByTestId('hash-scroll-layout-invalidate-link').click()
+ await expect(page).toHaveURL(/invalidateOnMount=true#one$/)
+ await expect(
+ page.getByTestId('hash-scroll-repro-invalidate-count'),
+ ).toHaveText('Invalidate count: 1')
+
+ await expect(page.getByTestId('hash-scroll-section-one')).toBeInViewport()
+ await expect(
+ page.getByTestId('hash-scroll-section-five'),
+ ).not.toBeInViewport()
- nested.scrollTop = 80
- window.dispatchEvent(new PageTransitionEvent('pagehide'))
- return nested.scrollTop
+ const scrollYAfterHashNavigation = await page.evaluate(() => window.scrollY)
+ expect(scrollYAfterHashNavigation).toBeLessThan(staleScrollY)
+})
+
+test('hash navigation still runs when a configured nested target restores', async ({
+ page,
+}) => {
+ await goToRepro(page)
+
+ const nested = page.getByTestId('hash-scroll-nested')
+ const cachedScrollTop = await nested.evaluate(async (element) => {
+ element.scrollTop = 80
+ element.dispatchEvent(new Event('scroll', { bubbles: true }))
+ await new Promise((resolve) => requestAnimationFrame(resolve))
+ return element.scrollTop
})
- expect(nestedScrollTop).toBeGreaterThan(0)
+ expect(cachedScrollTop).toBeGreaterThan(0)
- await page.reload()
- await page.waitForLoadState('networkidle')
+ await page.getByTestId('hash-scroll-about-link').click()
await expect(
- page.getByTestId('hash-scroll-repro-invalidate-count'),
+ page.getByRole('heading', { name: 'hash-scroll-about' }),
).toBeVisible()
- await page.getByTestId('hash-scroll-section-one-link').click()
+ await page.getByTestId('hash-scroll-restore-link').click()
await expect(page.getByTestId('hash-scroll-section-one')).toBeInViewport()
await expect
- .poll(async () => {
- return page.evaluate(() => {
- const nested = document.querySelector(
- '[data-testid="hash-scroll-nested"]',
- )
- return nested instanceof HTMLElement ? nested.scrollTop : 0
- })
- })
- .toBe(nestedScrollTop)
+ .poll(async () => nested.evaluate((element) => element.scrollTop))
+ .toBe(cachedScrollTop)
})
test('hash navigation scrolls when resetScroll is false', async ({ page }) => {
@@ -157,3 +173,60 @@ test('hash navigation scrolls when resetScroll is false', async ({ page }) => {
.poll(async () => page.evaluate(() => window.scrollY))
.toBeGreaterThan(scrollYBeforeHashNavigation)
})
+
+test('hash navigation does not reset configured nested targets', async ({
+ page,
+}) => {
+ await goToRepro(page)
+
+ const resetTarget = page.getByTestId('hash-scroll-reset-target')
+ const scrollTop = await resetTarget.evaluate((element) => {
+ element.scrollTop = 80
+ return element.scrollTop
+ })
+ expect(scrollTop).toBeGreaterThan(0)
+
+ await page.getByTestId('hash-scroll-section-one-link').click()
+ await expect(page).toHaveURL(/#one$/)
+ await expect(page.getByTestId('hash-scroll-section-one')).toBeInViewport()
+ await expect
+ .poll(async () => resetTarget.evaluate((el) => el.scrollTop))
+ .toBe(scrollTop)
+})
+
+test('hash navigation does not carry configured targets into a different key', async ({
+ page,
+}) => {
+ await goToRepro(page)
+
+ const resetTarget = page.getByTestId('hash-scroll-reset-target')
+ const sourceScrollTop = await resetTarget.evaluate(async (element) => {
+ element.scrollTop = 80
+ element.dispatchEvent(new Event('scroll', { bubbles: true }))
+ await new Promise((resolve) => requestAnimationFrame(resolve))
+ return element.scrollTop
+ })
+ expect(sourceScrollTop).toBeGreaterThan(0)
+
+ await page.getByTestId('hash-scroll-different-key-link').click()
+ await expect(page).toHaveURL(/scrollKey=destination#one$/)
+ await expect(page.getByTestId('hash-scroll-section-one')).toBeInViewport()
+ await expect
+ .poll(async () => resetTarget.evaluate((element) => element.scrollTop))
+ .toBe(sourceScrollTop)
+
+ await page.getByTestId('hash-scroll-about-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'hash-scroll-about' }),
+ ).toBeVisible()
+
+ await page.getByTestId('hash-scroll-destination-link').click()
+ await expect(page).toHaveURL(/scrollKey=destination$/)
+ await expect
+ .poll(async () =>
+ page
+ .getByTestId('hash-scroll-reset-target')
+ .evaluate((element) => element.scrollTop),
+ )
+ .toBe(0)
+})
diff --git a/e2e/react-start/scroll-restoration/tests/issue-7687.spec.ts b/e2e/react-start/scroll-restoration/tests/issue-7687.spec.ts
new file mode 100644
index 0000000000..85a5bd5d43
--- /dev/null
+++ b/e2e/react-start/scroll-restoration/tests/issue-7687.spec.ts
@@ -0,0 +1,242 @@
+import { expect, test } from '@playwright/test'
+import type { Page } from '@playwright/test'
+
+async function goToList(page: Page) {
+ await page.goto('/issue-7687')
+ await page.waitForLoadState('networkidle')
+ await expect(
+ page.getByRole('heading', { name: 'issue-7687-list' }),
+ ).toBeVisible()
+}
+
+test('restores a configured nested target independently from the window', async ({
+ page,
+}) => {
+ await goToList(page)
+
+ const scroller = page.getByTestId('issue-7687-scroller')
+ const scrollTop = await scroller.evaluate((element) => {
+ element.scrollTop = 400
+ element.dispatchEvent(new Event('scroll', { bubbles: true }))
+ return element.scrollTop
+ })
+
+ expect(scrollTop).toBeGreaterThan(0)
+ expect(await page.evaluate(() => window.scrollY)).toBe(0)
+
+ await page.getByTestId('issue-7687-detail-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'issue-7687-detail' }),
+ ).toBeVisible()
+ await expect.poll(async () => scroller.evaluate((el) => el.scrollTop)).toBe(0)
+
+ const outgoingWindowScrollY = await page.evaluate(() => {
+ window.scrollTo(0, 600)
+ window.dispatchEvent(new Event('scroll'))
+ return window.scrollY
+ })
+ expect(outgoingWindowScrollY).toBeGreaterThan(0)
+
+ await page.getByTestId('issue-7687-list-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'issue-7687-list' }),
+ ).toBeVisible()
+
+ await expect
+ .poll(async () => scroller.evaluate((el) => el.scrollTop))
+ .toBe(scrollTop)
+ await expect.poll(async () => page.evaluate(() => window.scrollY)).toBe(0)
+ await expect
+ .poll(async () =>
+ page.getByTestId('issue-7687-reset-probe').evaluate((el) => el.scrollTop),
+ )
+ .toBe(0)
+})
+
+test('resets an uncached configured target when the window restores', async ({
+ page,
+}) => {
+ await goToList(page)
+
+ const windowScrollY = await page.evaluate(() => {
+ window.scrollTo(0, 600)
+ window.dispatchEvent(new Event('scroll'))
+ return window.scrollY
+ })
+ expect(windowScrollY).toBeGreaterThan(0)
+
+ await page.getByTestId('issue-7687-detail-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'issue-7687-detail' }),
+ ).toBeVisible()
+ await expect.poll(async () => page.evaluate(() => window.scrollY)).toBe(0)
+
+ await page.getByTestId('issue-7687-list-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'issue-7687-list' }),
+ ).toBeVisible()
+
+ await expect
+ .poll(async () => page.evaluate(() => window.scrollY))
+ .toBe(windowScrollY)
+ await expect
+ .poll(async () =>
+ page.getByTestId('issue-7687-reset-probe').evaluate((el) => el.scrollTop),
+ )
+ .toBe(0)
+})
+
+test('restores and resets nested targets independently on browser back', async ({
+ page,
+}) => {
+ await goToList(page)
+
+ const cachedScrollTop = await page
+ .getByTestId('issue-7687-scroller')
+ .evaluate(async (element) => {
+ element.scrollTop = 400
+ element.dispatchEvent(new Event('scroll', { bubbles: true }))
+ await new Promise((resolve) => requestAnimationFrame(resolve))
+ return element.scrollTop
+ })
+ expect(cachedScrollTop).toBeGreaterThan(0)
+
+ await page.getByTestId('issue-7687-detail-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'issue-7687-detail' }),
+ ).toBeVisible()
+
+ await page.goBack()
+ await expect(
+ page.getByRole('heading', { name: 'issue-7687-list' }),
+ ).toBeVisible()
+
+ await expect
+ .poll(async () =>
+ page
+ .getByTestId('issue-7687-scroller')
+ .evaluate((element) => element.scrollTop),
+ )
+ .toBe(cachedScrollTop)
+ await expect
+ .poll(async () =>
+ page
+ .getByTestId('issue-7687-reset-probe')
+ .evaluate((element) => element.scrollTop),
+ )
+ .toBe(0)
+})
+
+test('resets a live configured target when its cached selector becomes stale', async ({
+ page,
+}) => {
+ await goToList(page)
+
+ const target = page.getByTestId('issue-7687-stale-selector')
+ const cachedScrollTop = await target.evaluate(async (element) => {
+ element.scrollTop = 80
+ element.dispatchEvent(new Event('scroll', { bubbles: true }))
+ await new Promise((resolve) => requestAnimationFrame(resolve))
+ return element.scrollTop
+ })
+ expect(cachedScrollTop).toBeGreaterThan(0)
+
+ await page.getByTestId('issue-7687-detail-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'issue-7687-detail' }),
+ ).toBeVisible()
+ await expect.poll(async () => target.evaluate((el) => el.scrollTop)).toBe(0)
+
+ const outgoingScrollTop = await target.evaluate(async (element) => {
+ element.dataset.scrollRestorationId = 'issue-7687-stale-current'
+ element.scrollTop = 40
+ element.dispatchEvent(new Event('scroll', { bubbles: true }))
+ await new Promise((resolve) => requestAnimationFrame(resolve))
+ return element.scrollTop
+ })
+ expect(outgoingScrollTop).toBeGreaterThan(0)
+
+ await page.getByTestId('issue-7687-list-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'issue-7687-list' }),
+ ).toBeVisible()
+
+ await expect.poll(async () => target.evaluate((el) => el.scrollTop)).toBe(0)
+})
+
+test('resetScroll false preserves window and configured nested targets', async ({
+ page,
+}) => {
+ await goToList(page)
+
+ const positions = await page.evaluate(() => {
+ const scroller = document.querySelector(
+ '[data-testid="issue-7687-scroller"]',
+ )
+ if (!(scroller instanceof HTMLElement)) {
+ throw new Error('Missing issue-7687 scroller')
+ }
+
+ scroller.scrollTop = 400
+ scroller.dispatchEvent(new Event('scroll', { bubbles: true }))
+ window.scrollTo(0, 600)
+ window.dispatchEvent(new Event('scroll'))
+
+ return { nested: scroller.scrollTop, window: window.scrollY }
+ })
+
+ expect(positions.nested).toBeGreaterThan(0)
+ expect(positions.window).toBeGreaterThan(0)
+
+ await page.getByTestId('issue-7687-detail-no-reset-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'issue-7687-detail' }),
+ ).toBeVisible()
+
+ await expect
+ .poll(async () => page.evaluate(() => window.scrollY))
+ .toBe(positions.window)
+ await expect
+ .poll(async () =>
+ page.getByTestId('issue-7687-scroller').evaluate((el) => el.scrollTop),
+ )
+ .toBe(positions.nested)
+
+ await page.getByTestId('issue-7687-list-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'issue-7687-list' }),
+ ).toBeVisible()
+
+ const changedPositions = await page.evaluate(async () => {
+ const scroller = document.querySelector(
+ '[data-testid="issue-7687-scroller"]',
+ )
+ if (!(scroller instanceof HTMLElement)) {
+ throw new Error('Missing issue-7687 scroller')
+ }
+
+ scroller.scrollTop = 200
+ scroller.dispatchEvent(new Event('scroll', { bubbles: true }))
+ window.scrollTo(0, 300)
+ window.dispatchEvent(new Event('scroll'))
+ await new Promise((resolve) => requestAnimationFrame(resolve))
+
+ return { nested: scroller.scrollTop, window: window.scrollY }
+ })
+
+ expect(changedPositions.nested).not.toBe(positions.nested)
+ expect(changedPositions.window).not.toBe(positions.window)
+
+ await page.getByTestId('issue-7687-detail-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'issue-7687-detail' }),
+ ).toBeVisible()
+ await expect
+ .poll(async () => page.evaluate(() => window.scrollY))
+ .toBe(positions.window)
+ await expect
+ .poll(async () =>
+ page.getByTestId('issue-7687-scroller').evaluate((el) => el.scrollTop),
+ )
+ .toBe(positions.nested)
+})
diff --git a/e2e/react-start/scroll-restoration/tests/ssr-scroll-key.spec.ts b/e2e/react-start/scroll-restoration/tests/ssr-scroll-key.spec.ts
index 737695a0cf..eb61a279d2 100644
--- a/e2e/react-start/scroll-restoration/tests/ssr-scroll-key.spec.ts
+++ b/e2e/react-start/scroll-restoration/tests/ssr-scroll-key.spec.ts
@@ -38,3 +38,77 @@ test('SSR scroll restoration uses a custom restoration key', async ({
.poll(async () => page.evaluate(() => window.scrollY))
.toBe(customKeyScrollY)
})
+
+test('SSR restores a configured nested target without client JavaScript', async ({
+ page,
+}) => {
+ const nestedScrollTop = 80
+
+ await page.addInitScript(
+ ({ nestedScrollTop, storageKey }) => {
+ window.sessionStorage.setItem(
+ storageKey,
+ JSON.stringify({
+ 'ssr-scroll-key': {
+ '[data-scroll-restoration-id="ssr-scroll-key-nested"]': {
+ scrollX: 0,
+ scrollY: nestedScrollTop,
+ },
+ },
+ }),
+ )
+ },
+ { nestedScrollTop, storageKey },
+ )
+ await page.route(/\/assets\/.*\.js$/, (route) => route.abort())
+
+ await page.goto('/ssr-scroll-key', { waitUntil: 'domcontentloaded' })
+ await expect(
+ page.getByRole('heading', { name: 'ssr-scroll-key' }),
+ ).toBeVisible()
+ await expect(page.getByTestId('ssr-scroll-key-hydrated')).toHaveCount(0)
+ await expect
+ .poll(async () =>
+ page
+ .getByTestId('ssr-scroll-key-nested')
+ .evaluate((element) => element.scrollTop),
+ )
+ .toBe(nestedScrollTop)
+})
+
+test('hydration does not reset a configured nested target restored by SSR', async ({
+ page,
+}) => {
+ const nestedScrollTop = 80
+
+ await page.addInitScript(
+ ({ nestedScrollTop, storageKey }) => {
+ window.sessionStorage.setItem(
+ storageKey,
+ JSON.stringify({
+ 'ssr-scroll-key': {
+ '[data-scroll-restoration-id="ssr-scroll-key-nested"]': {
+ scrollX: 0,
+ scrollY: nestedScrollTop,
+ },
+ },
+ }),
+ )
+ },
+ { nestedScrollTop, storageKey },
+ )
+
+ await page.goto('/ssr-scroll-key')
+ await page.waitForLoadState('networkidle')
+ await expect(
+ page.getByRole('heading', { name: 'ssr-scroll-key' }),
+ ).toBeVisible()
+ await expect(page.getByTestId('ssr-scroll-key-hydrated')).toBeAttached()
+ await expect
+ .poll(async () =>
+ page
+ .getByTestId('ssr-scroll-key-nested')
+ .evaluate((element) => element.scrollTop),
+ )
+ .toBe(nestedScrollTop)
+})
diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts
index 2197dab737..df9251a739 100644
--- a/packages/router-core/src/router.ts
+++ b/packages/router-core/src/router.ts
@@ -904,11 +904,6 @@ export function getLocationChangeInfo(
return { fromLocation, toLocation, pathChanged, hrefChanged, hashChanged }
}
-export const locationHistoryActions = new WeakMap<
- ParsedLocation,
- HistoryAction
->()
-
type LightweightRouteMatchResult = {
matchedRoutes: ReadonlyArray
fullPath: string
@@ -978,6 +973,8 @@ export class RouterCore<
)}`
_scroll: {
next: boolean
+ // True until the current PUSH/REPLACE renders, so its hash owns window scroll.
+ hash?: boolean
restoring?: boolean
restoration?: boolean
reset?: boolean
@@ -2475,9 +2472,8 @@ export class RouterCore<
try {
this.beforeLoad()
if (historyAction) {
- locationHistoryActions.set(this.latestLocation, historyAction)
- } else {
- locationHistoryActions.delete(this.latestLocation)
+ this._scroll.hash =
+ historyAction === 'PUSH' || historyAction === 'REPLACE'
}
const next = this.latestLocation
const prevLocation = this.stores.resolvedLocation.get()
diff --git a/packages/router-core/src/scroll-restoration.ts b/packages/router-core/src/scroll-restoration.ts
index 6287a6fce0..5e4ae0d3ee 100644
--- a/packages/router-core/src/scroll-restoration.ts
+++ b/packages/router-core/src/scroll-restoration.ts
@@ -1,5 +1,4 @@
import { isServer } from '@tanstack/router-core/isServer'
-import { locationHistoryActions } from './router'
import type { AnyRouter } from './router'
import type { ParsedLocation } from './location'
@@ -133,7 +132,6 @@ export function getElementScrollRestorationEntry(
let ignoreScroll = false
const windowScrollTarget = 'window'
-type ScrollTarget = typeof windowScrollTarget | Element
function getElement(selector: string | (() => Element | null | undefined)) {
try {
@@ -148,8 +146,8 @@ function getScrollToTopElements(
scrollToTopSelectors: NonNullable<
AnyRouter['options']['scrollToTopSelectors']
>,
-): Array {
- const elements: Array = []
+) {
+ const elements = new Set()
for (const selector of scrollToTopSelectors) {
if (selector === windowScrollTarget) {
@@ -158,7 +156,7 @@ function getScrollToTopElements(
const element = getElement(selector)
if (element) {
- elements.push(element)
+ elements.add(element)
}
}
@@ -180,46 +178,21 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) {
const getKey =
router.options.getScrollRestorationKey || defaultGetScrollRestorationKey
- const trackedScrollEntries = new Map()
- const setTrackedScrollEntry = (
- target: ScrollTarget,
- scrollX: number,
- scrollY: number,
- ) => {
- const entry =
- trackedScrollEntries.get(target) || ({} as ScrollRestorationEntry)
- entry.scrollX = scrollX
- entry.scrollY = scrollY
- trackedScrollEntries.set(target, entry)
- }
-
- const onScroll = (event: Event) => {
- if (ignoreScroll || !scroll.restoring) {
- return
- }
-
- if (event.target === document) {
- setTrackedScrollEntry(windowScrollTarget, scrollX, scrollY)
- } else {
- const target = event.target as Element
- setTrackedScrollEntry(target, target.scrollLeft, target.scrollTop)
- }
- }
+ const trackedScrollTargets = new Set()
// Snapshot the current page's tracked scroll targets before navigation or unload.
const snapshotCurrentScrollTargets = (restoreKey: string) => {
- if (!scroll.restoring) {
- return
- }
-
const keyEntry = (scrollRestorationCache[restoreKey] ||=
{} as ScrollRestorationByElement)
- for (const [target, position] of trackedScrollEntries) {
- if (target === windowScrollTarget) {
- keyEntry[windowScrollTarget] = position
- } else if (target.isConnected) {
- keyEntry[getScrollRestorationSelector(target)] = position
+ for (const target of trackedScrollTargets) {
+ if (target === document) {
+ keyEntry[windowScrollTarget] = { scrollX, scrollY }
+ } else if ((target as Element).isConnected) {
+ keyEntry[getScrollRestorationSelector(target as Element)] = {
+ scrollX: (target as Element).scrollLeft,
+ scrollY: (target as Element).scrollTop,
+ }
}
}
}
@@ -230,12 +203,21 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) {
history.scrollRestoration = 'manual'
- document.addEventListener('scroll', onScroll, true)
+ document.addEventListener(
+ 'scroll',
+ (event) => {
+ if (ignoreScroll) {
+ return
+ }
+ trackedScrollTargets.add(event.target as Document | Element)
+ },
+ true,
+ )
router.subscribe('onBeforeLoad', (event) => {
if (event.fromLocation) {
snapshotCurrentScrollTargets(getKey(event.fromLocation))
}
- trackedScrollEntries.clear()
+ trackedScrollTargets.clear()
})
addEventListener('pagehide', () => {
snapshotCurrentScrollTargets(
@@ -258,12 +240,11 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) {
const behavior = router.options.scrollRestorationBehavior
const scrollToTopSelectors = router.options.scrollToTopSelectors
const shouldResetScroll = scroll.next
- let scrollToTopElements: Array | undefined
- trackedScrollEntries.clear()
-
- if (!shouldResetScroll) {
- scroll.next = true
- }
+ const hashNavigation = scroll.hash
+ let scrollToTopElements: Set | undefined
+ trackedScrollTargets.clear()
+ scroll.next = true
+ scroll.hash = false
if (
typeof router.options.scrollRestoration === 'function' &&
@@ -295,7 +276,7 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) {
if (shouldResetScroll && scrollToTopSelectors) {
scrollToTopElements ??=
getScrollToTopElements(scrollToTopSelectors)
- if (scrollToTopElements.includes(element)) {
+ if (scrollToTopElements.has(element)) {
continue
}
}
@@ -321,11 +302,12 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) {
let windowRestored = false
if (shouldResetScroll) {
- const action = locationHistoryActions.get(event.toLocation)
+ if (!hash && scrollToTopSelectors) {
+ scrollToTopElements ??= getScrollToTopElements(scrollToTopSelectors)
+ }
+
const skipWindowRestore =
- hash &&
- hashScrollIntoViewOptions &&
- (action === 'PUSH' || action === 'REPLACE')
+ hash && hashScrollIntoViewOptions && hashNavigation
const elementEntries = scroll.restoring
? scrollRestorationCache[cacheKey]
@@ -351,21 +333,23 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) {
if (element) {
element.scrollLeft = scrollX
element.scrollTop = scrollY
+ scrollToTopElements?.delete(element)
}
}
}
}
- if (!windowRestored && !hash) {
+ if (!hash) {
const scrollOptions = {
top: 0,
left: 0,
behavior,
}
- scrollTo(scrollOptions)
- if (scrollToTopSelectors) {
- scrollToTopElements ??= getScrollToTopElements(scrollToTopSelectors)
+ if (!windowRestored) {
+ scrollTo(scrollOptions)
+ }
+ if (scrollToTopElements) {
for (const element of scrollToTopElements) {
element.scrollTo(scrollOptions)
}
diff --git a/packages/router-core/tests/scroll-restoration.test.ts b/packages/router-core/tests/scroll-restoration.test.ts
index 188d13bb68..c02a8f8783 100644
--- a/packages/router-core/tests/scroll-restoration.test.ts
+++ b/packages/router-core/tests/scroll-restoration.test.ts
@@ -2,8 +2,15 @@ import { createMemoryHistory } from '@tanstack/history'
import { afterEach, describe, expect, test, vi } from 'vitest'
import { BaseRootRoute, BaseRoute } from '../src'
import { createTestRouter } from './routerTestUtils'
+import type { ParsedLocation } from '../src'
-function createRouter(options: { scrollRestoration?: boolean } = {}) {
+function createRouter(
+ options: {
+ scrollRestoration?: boolean
+ scrollToTopSelectors?: Array Element | null | undefined)>
+ getScrollRestorationKey?: (location: ParsedLocation) => string
+ } = {},
+) {
const rootRoute = new BaseRootRoute({})
const indexRoute = new BaseRoute({
getParentRoute: () => rootRoute,
@@ -18,9 +25,39 @@ function createRouter(options: { scrollRestoration?: boolean } = {}) {
}
afterEach(() => {
+ document.body.replaceChildren()
+ window.sessionStorage.clear()
vi.restoreAllMocks()
+ vi.unstubAllGlobals()
})
+function getLocation(
+ router: ReturnType,
+ pathname: string,
+) {
+ return {
+ ...router.latestLocation,
+ href: pathname,
+ pathname,
+ }
+}
+
+function emitNavigation(
+ router: ReturnType,
+ type: 'onBeforeLoad' | 'onRendered',
+ fromLocation: ParsedLocation,
+ toLocation: ParsedLocation,
+) {
+ router.emit({
+ type,
+ fromLocation,
+ toLocation,
+ pathChanged: true,
+ hrefChanged: true,
+ hashChanged: false,
+ })
+}
+
describe('setupScrollRestoration', () => {
test('sets up scroll restoration when scrollRestoration is true', () => {
const windowAddEventListener = vi.spyOn(window, 'addEventListener')
@@ -46,6 +83,63 @@ describe('setupScrollRestoration', () => {
window.history.scrollRestoration = previousScrollRestoration
})
+ test('snapshots the live position when it changed after the latest scroll event', () => {
+ const element = document.createElement('div')
+ element.id = 'unit-live-snapshot-element'
+ document.body.append(element)
+ vi.stubGlobal('scrollTo', vi.fn())
+
+ const router = createRouter({
+ scrollRestoration: true,
+ getScrollRestorationKey: (location) => location.pathname,
+ })
+ const source = getLocation(router, '/unit-live-snapshot-source')
+ const destination = getLocation(router, '/unit-live-snapshot-destination')
+
+ element.scrollTop = 80
+ element.dispatchEvent(new Event('scroll', { bubbles: true }))
+ element.scrollTop = 120
+
+ emitNavigation(router, 'onBeforeLoad', source, destination)
+ element.scrollTop = 0
+ emitNavigation(router, 'onRendered', source, destination)
+
+ expect(element.scrollTop).toBe(120)
+ })
+
+ test('snapshots the live window position when it changed after the latest scroll event', () => {
+ const windowScrollTo = vi.fn()
+ vi.stubGlobal('scrollTo', windowScrollTo)
+ vi.stubGlobal('scrollX', 0)
+ vi.stubGlobal('scrollY', 80)
+
+ const router = createRouter({
+ scrollRestoration: true,
+ getScrollRestorationKey: (location) => location.pathname,
+ })
+ const source = getLocation(router, '/unit-live-window-snapshot-source')
+ const destination = getLocation(
+ router,
+ '/unit-live-window-snapshot-destination',
+ )
+
+ document.dispatchEvent(new Event('scroll'))
+ vi.stubGlobal('scrollY', 120)
+
+ emitNavigation(router, 'onBeforeLoad', source, destination)
+ emitNavigation(router, 'onRendered', source, destination)
+ windowScrollTo.mockClear()
+
+ emitNavigation(router, 'onBeforeLoad', destination, source)
+ emitNavigation(router, 'onRendered', destination, source)
+
+ expect(windowScrollTo).toHaveBeenCalledWith({
+ top: 120,
+ left: 0,
+ behavior: undefined,
+ })
+ })
+
test.each([
['omitted', undefined],
['false', false],
@@ -81,4 +175,157 @@ describe('setupScrollRestoration', () => {
window.history.scrollRestoration = previousScrollRestoration
},
)
+
+ test('restores a configured element independently from the window', () => {
+ const element = document.createElement('div')
+ element.id = 'unit-restored-element'
+ element.dataset.scrollRestorationId = 'unit-restored-element'
+ document.body.append(element)
+
+ const elementScrollTo = vi.fn((options: ScrollToOptions) => {
+ element.scrollTop = options.top ?? element.scrollTop
+ })
+ element.scrollTo = elementScrollTo as typeof element.scrollTo
+ const windowScrollTo = vi.fn()
+ vi.stubGlobal('scrollTo', windowScrollTo)
+
+ const router = createRouter({
+ scrollRestoration: true,
+ scrollToTopSelectors: ['#unit-restored-element'],
+ getScrollRestorationKey: (location) => location.pathname,
+ })
+ const source = getLocation(router, '/unit-element-source')
+ const destination = getLocation(router, '/unit-element-destination')
+
+ element.scrollTop = 80
+ element.dispatchEvent(new Event('scroll', { bubbles: true }))
+
+ emitNavigation(router, 'onBeforeLoad', source, destination)
+ emitNavigation(router, 'onRendered', source, destination)
+ expect(element.scrollTop).toBe(0)
+
+ elementScrollTo.mockClear()
+ windowScrollTo.mockClear()
+ vi.stubGlobal('scrollX', 0)
+ vi.stubGlobal('scrollY', 120)
+ document.dispatchEvent(new Event('scroll'))
+ emitNavigation(router, 'onBeforeLoad', destination, source)
+ emitNavigation(router, 'onRendered', destination, source)
+
+ expect(element.scrollTop).toBe(80)
+ expect(elementScrollTo).not.toHaveBeenCalled()
+ expect(windowScrollTo).toHaveBeenCalledWith({
+ top: 0,
+ left: 0,
+ behavior: undefined,
+ })
+ })
+
+ test('resets an uncached configured element when the window restores', () => {
+ const element = document.createElement('div')
+ element.id = 'unit-reset-element'
+ document.body.append(element)
+
+ const elementScrollTo = vi.fn((options: ScrollToOptions) => {
+ element.scrollTop = options.top ?? element.scrollTop
+ })
+ element.scrollTo = elementScrollTo as typeof element.scrollTo
+
+ const windowScrollTo = vi.fn()
+ vi.stubGlobal('scrollX', 0)
+ vi.stubGlobal('scrollY', 120)
+ vi.stubGlobal('scrollTo', windowScrollTo)
+
+ const router = createRouter({
+ scrollRestoration: true,
+ scrollToTopSelectors: ['#unit-reset-element'],
+ getScrollRestorationKey: (location) => location.pathname,
+ })
+ const source = getLocation(router, '/unit-window-source')
+ const destination = getLocation(router, '/unit-window-destination')
+
+ document.dispatchEvent(new Event('scroll'))
+ emitNavigation(router, 'onBeforeLoad', source, destination)
+ emitNavigation(router, 'onRendered', source, destination)
+
+ elementScrollTo.mockClear()
+ windowScrollTo.mockClear()
+ element.scrollTop = 80
+
+ emitNavigation(router, 'onBeforeLoad', destination, source)
+ emitNavigation(router, 'onRendered', destination, source)
+
+ expect(windowScrollTo).toHaveBeenCalledWith({
+ top: 120,
+ left: 0,
+ behavior: undefined,
+ })
+ expect(element.scrollTop).toBe(0)
+ expect(elementScrollTo).toHaveBeenCalledWith({
+ top: 0,
+ left: 0,
+ behavior: undefined,
+ })
+ })
+
+ test('resets a configured element when its cached selector becomes stale', () => {
+ const element = document.createElement('div')
+ element.id = 'unit-stale-selector-element'
+ element.dataset.scrollRestorationId = 'unit-stale-selector-source'
+ document.body.append(element)
+
+ const elementScrollTo = vi.fn((options: ScrollToOptions) => {
+ element.scrollTop = options.top ?? element.scrollTop
+ })
+ element.scrollTo = elementScrollTo as typeof element.scrollTo
+ vi.stubGlobal('scrollTo', vi.fn())
+
+ const router = createRouter({
+ scrollRestoration: true,
+ scrollToTopSelectors: [() => element],
+ getScrollRestorationKey: (location) => location.pathname,
+ })
+ const source = getLocation(router, '/unit-stale-selector-source')
+ const destination = getLocation(router, '/unit-stale-selector-destination')
+
+ element.scrollTop = 80
+ element.dispatchEvent(new Event('scroll', { bubbles: true }))
+ emitNavigation(router, 'onBeforeLoad', source, destination)
+ emitNavigation(router, 'onRendered', source, destination)
+ expect(element.scrollTop).toBe(0)
+
+ element.dataset.scrollRestorationId = 'unit-stale-selector-current'
+ element.scrollTop = 40
+ elementScrollTo.mockClear()
+ emitNavigation(router, 'onBeforeLoad', destination, source)
+ emitNavigation(router, 'onRendered', destination, source)
+
+ expect(element.scrollTop).toBe(0)
+ expect(elementScrollTo).toHaveBeenCalledWith({
+ top: 0,
+ left: 0,
+ behavior: undefined,
+ })
+ })
+
+ test('does not resolve configured selectors for hash navigation without source entries', () => {
+ const element = document.createElement('div')
+ document.body.append(element)
+ const getElement = vi.fn(() => element)
+
+ const router = createRouter({
+ scrollRestoration: true,
+ scrollToTopSelectors: [getElement],
+ getScrollRestorationKey: (location) => location.pathname,
+ })
+ const source = getLocation(router, '/unit-hash-source')
+ const destination = {
+ ...getLocation(router, '/unit-hash-destination'),
+ hash: 'section',
+ }
+
+ emitNavigation(router, 'onRendered', source, destination)
+
+ expect(getElement).not.toHaveBeenCalled()
+ })
})