diff --git a/.changeset/scroll-restoration-fixes.md b/.changeset/scroll-restoration-fixes.md
new file mode 100644
index 0000000000..65437fa5a8
--- /dev/null
+++ b/.changeset/scroll-restoration-fixes.md
@@ -0,0 +1,5 @@
+---
+'@tanstack/router-core': patch
+---
+
+Fix hash scrolling with `resetScroll={false}`
diff --git a/e2e/react-start/scroll-restoration/src/routeTree.gen.ts b/e2e/react-start/scroll-restoration/src/routeTree.gen.ts
index 5c9174efcc..1df27a0424 100644
--- a/e2e/react-start/scroll-restoration/src/routeTree.gen.ts
+++ b/e2e/react-start/scroll-restoration/src/routeTree.gen.ts
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
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 testsSsrScrollKeyRouteImport } from './routes/(tests)/ssr-scroll-key'
import { Route as testsResetScrollFalseCRouteImport } from './routes/(tests)/reset-scroll-false-c'
import { Route as testsResetScrollFalseBRouteImport } from './routes/(tests)/reset-scroll-false-b'
import { Route as testsResetScrollFalseARouteImport } from './routes/(tests)/reset-scroll-false-a'
@@ -38,6 +39,11 @@ const testsWithLoaderRoute = testsWithLoaderRouteImport.update({
path: '/with-loader',
getParentRoute: () => rootRouteImport,
} as any)
+const testsSsrScrollKeyRoute = testsSsrScrollKeyRouteImport.update({
+ id: '/(tests)/ssr-scroll-key',
+ path: '/ssr-scroll-key',
+ getParentRoute: () => rootRouteImport,
+} as any)
const testsResetScrollFalseCRoute = testsResetScrollFalseCRouteImport.update({
id: '/(tests)/reset-scroll-false-c',
path: '/reset-scroll-false-c',
@@ -103,6 +109,7 @@ export interface FileRoutesByFullPath {
'/reset-scroll-false-a': typeof testsResetScrollFalseARoute
'/reset-scroll-false-b': typeof testsResetScrollFalseBRoute
'/reset-scroll-false-c': typeof testsResetScrollFalseCRoute
+ '/ssr-scroll-key': typeof testsSsrScrollKeyRoute
'/with-loader': typeof testsWithLoaderRoute
'/with-search': typeof testsWithSearchRoute
}
@@ -118,6 +125,7 @@ export interface FileRoutesByTo {
'/reset-scroll-false-a': typeof testsResetScrollFalseARoute
'/reset-scroll-false-b': typeof testsResetScrollFalseBRoute
'/reset-scroll-false-c': typeof testsResetScrollFalseCRoute
+ '/ssr-scroll-key': typeof testsSsrScrollKeyRoute
'/with-loader': typeof testsWithLoaderRoute
'/with-search': typeof testsWithSearchRoute
}
@@ -134,6 +142,7 @@ export interface FileRoutesById {
'/(tests)/reset-scroll-false-a': typeof testsResetScrollFalseARoute
'/(tests)/reset-scroll-false-b': typeof testsResetScrollFalseBRoute
'/(tests)/reset-scroll-false-c': typeof testsResetScrollFalseCRoute
+ '/(tests)/ssr-scroll-key': typeof testsSsrScrollKeyRoute
'/(tests)/with-loader': typeof testsWithLoaderRoute
'/(tests)/with-search': typeof testsWithSearchRoute
}
@@ -151,6 +160,7 @@ export interface FileRouteTypes {
| '/reset-scroll-false-a'
| '/reset-scroll-false-b'
| '/reset-scroll-false-c'
+ | '/ssr-scroll-key'
| '/with-loader'
| '/with-search'
fileRoutesByTo: FileRoutesByTo
@@ -166,6 +176,7 @@ export interface FileRouteTypes {
| '/reset-scroll-false-a'
| '/reset-scroll-false-b'
| '/reset-scroll-false-c'
+ | '/ssr-scroll-key'
| '/with-loader'
| '/with-search'
id:
@@ -181,6 +192,7 @@ export interface FileRouteTypes {
| '/(tests)/reset-scroll-false-a'
| '/(tests)/reset-scroll-false-b'
| '/(tests)/reset-scroll-false-c'
+ | '/(tests)/ssr-scroll-key'
| '/(tests)/with-loader'
| '/(tests)/with-search'
fileRoutesById: FileRoutesById
@@ -197,6 +209,7 @@ export interface RootRouteChildren {
testsResetScrollFalseARoute: typeof testsResetScrollFalseARoute
testsResetScrollFalseBRoute: typeof testsResetScrollFalseBRoute
testsResetScrollFalseCRoute: typeof testsResetScrollFalseCRoute
+ testsSsrScrollKeyRoute: typeof testsSsrScrollKeyRoute
testsWithLoaderRoute: typeof testsWithLoaderRoute
testsWithSearchRoute: typeof testsWithSearchRoute
}
@@ -224,6 +237,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof testsWithLoaderRouteImport
parentRoute: typeof rootRouteImport
}
+ '/(tests)/ssr-scroll-key': {
+ id: '/(tests)/ssr-scroll-key'
+ path: '/ssr-scroll-key'
+ fullPath: '/ssr-scroll-key'
+ preLoaderRoute: typeof testsSsrScrollKeyRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/(tests)/reset-scroll-false-c': {
id: '/(tests)/reset-scroll-false-c'
path: '/reset-scroll-false-c'
@@ -309,6 +329,7 @@ const rootRouteChildren: RootRouteChildren = {
testsResetScrollFalseARoute: testsResetScrollFalseARoute,
testsResetScrollFalseBRoute: testsResetScrollFalseBRoute,
testsResetScrollFalseCRoute: testsResetScrollFalseCRoute,
+ testsSsrScrollKeyRoute: testsSsrScrollKeyRoute,
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 0d529ba002..eb81f5bfa9 100644
--- a/e2e/react-start/scroll-restoration/src/router.tsx
+++ b/e2e/react-start/scroll-restoration/src/router.tsx
@@ -9,6 +9,10 @@ export function getRouter() {
scrollRestoration: true,
scrollToTopSelectors: ['[data-scroll-restoration-id="carry-over-reset"]'],
getScrollRestorationKey: (location) => {
+ if (location.pathname === '/ssr-scroll-key') {
+ return 'ssr-scroll-key'
+ }
+
if (location.pathname === '/hash-scroll-repro') {
return location.pathname
}
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 31970306bd..1017b63bc3 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
@@ -52,6 +52,15 @@ function Component() {
>
#one
+
+ #four no reset
+
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
new file mode 100644
index 0000000000..e224ed9137
--- /dev/null
+++ b/e2e/react-start/scroll-restoration/src/routes/(tests)/ssr-scroll-key.tsx
@@ -0,0 +1,16 @@
+import { createFileRoute } from '@tanstack/react-router'
+import * as React from 'react'
+import { ScrollBlock } from '../-components/scroll-block'
+
+export const Route = createFileRoute('/(tests)/ssr-scroll-key')({
+ component: Component,
+})
+
+function Component() {
+ return (
+
+
ssr-scroll-key
+
+
+ )
+}
diff --git a/e2e/react-start/scroll-restoration/tests/app.spec.ts b/e2e/react-start/scroll-restoration/tests/app.spec.ts
index 2485d04da0..2d7ee75fd3 100644
--- a/e2e/react-start/scroll-restoration/tests/app.spec.ts
+++ b/e2e/react-start/scroll-restoration/tests/app.spec.ts
@@ -8,6 +8,46 @@ test('Smoke - Renders home', async ({ page }) => {
).toBeVisible()
})
+test('restores window scroll after force reload of a navigated route', async ({
+ page,
+}) => {
+ await page.goto('/')
+ await page.getByRole('link', { name: '/reset-scroll-false-a' }).click()
+ await expect(
+ page.getByRole('heading', { name: 'reset-scroll-false-a' }),
+ ).toBeVisible()
+ await expect
+ .poll(async () =>
+ page.evaluate(() => document.documentElement.scrollHeight > innerHeight),
+ )
+ .toBe(true)
+
+ const scrollY = await page.evaluate(async () => {
+ window.scrollTo(0, 500)
+ await new Promise((resolve) => requestAnimationFrame(() => resolve(null)))
+ return window.scrollY
+ })
+
+ expect(scrollY).toBeGreaterThan(0)
+
+ await page.reload()
+ await expect(
+ page.getByRole('heading', { name: 'reset-scroll-false-a' }),
+ ).toBeVisible()
+ await expect
+ .poll(async () => page.evaluate(() => window.scrollY))
+ .toBe(scrollY)
+})
+
+test('initial hard navigation to a hash scrolls to the hash target', async ({
+ page,
+}) => {
+ await page.goto('/hash-scroll-repro#four')
+ await page.waitForLoadState('networkidle')
+ await expect(page.getByTestId('hash-scroll-section-four')).toBeInViewport()
+ await expect(page.getByTestId('hash-scroll-section-one')).not.toBeInViewport()
+})
+
// Test for scroll related stuff
;[
linkOptions({ to: '/normal-page' }),
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 65dcf7a4dc..d203ea8c26 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
@@ -141,3 +141,19 @@ test('hash navigation still runs when only nested scroll entries restore', async
})
.toBe(nestedScrollTop)
})
+
+test('hash navigation scrolls when resetScroll is false', async ({ page }) => {
+ await page.goto('/hash-scroll-repro#one')
+ await page.waitForLoadState('networkidle')
+ await expect(page.getByTestId('hash-scroll-section-one')).toBeInViewport()
+
+ const scrollYBeforeHashNavigation = await page.evaluate(() => window.scrollY)
+
+ await page.getByTestId('hash-scroll-section-four-no-reset-link').click()
+
+ await expect(page).toHaveURL(/#four$/)
+ await expect(page.getByTestId('hash-scroll-section-four')).toBeInViewport()
+ await expect
+ .poll(async () => page.evaluate(() => window.scrollY))
+ .toBeGreaterThan(scrollYBeforeHashNavigation)
+})
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
new file mode 100644
index 0000000000..39b2f3e8d2
--- /dev/null
+++ b/e2e/react-start/scroll-restoration/tests/ssr-scroll-key.spec.ts
@@ -0,0 +1,41 @@
+import { expect, test } from '@playwright/test'
+
+const storageKey = 'tsr-scroll-restoration-v1_3'
+
+test('SSR scroll restoration uses a custom restoration key', async ({
+ page,
+}) => {
+ const customKeyScrollY = 650
+ const historyKeyScrollY = 80
+
+ await page.goto('/')
+ await page.evaluate(
+ ({ customKeyScrollY, historyKeyScrollY, storageKey }) => {
+ window.sessionStorage.setItem(
+ storageKey,
+ JSON.stringify({
+ 'ssr-scroll-key': {
+ window: { scrollX: 0, scrollY: customKeyScrollY },
+ },
+ 'history-key': {
+ window: { scrollX: 0, scrollY: historyKeyScrollY },
+ },
+ }),
+ )
+ },
+ { customKeyScrollY, historyKeyScrollY, storageKey },
+ )
+
+ await page.addInitScript(() => {
+ window.history.replaceState({ __TSR_key: 'history-key' }, '')
+ })
+ 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
+ .poll(async () => page.evaluate(() => window.scrollY))
+ .toBe(customKeyScrollY)
+})
diff --git a/packages/router-core/src/scroll-restoration-inline.ts b/packages/router-core/src/scroll-restoration-inline.ts
index 1e0328a663..76ab0027b3 100644
--- a/packages/router-core/src/scroll-restoration-inline.ts
+++ b/packages/router-core/src/scroll-restoration-inline.ts
@@ -1,62 +1,43 @@
-export default function (options: { storageKey: string; key?: string }) {
+export default function (storageKey: string, key?: string) {
let byKey
try {
- byKey = JSON.parse(sessionStorage.getItem(options.storageKey) || '{}')
- } catch (error) {
- console.error(error)
+ byKey = JSON.parse(sessionStorage.getItem(storageKey) || '{}')
+ } catch {
return
}
- const resolvedKey = options.key || window.history.state?.__TSR_key
- const elementEntries = resolvedKey ? byKey[resolvedKey] : undefined
+ const elementEntries = byKey?.[key || history.state?.__TSR_key]
let windowRestored = false
- if (elementEntries && typeof elementEntries === 'object') {
- for (const elementSelector in elementEntries) {
- const entry = elementEntries[elementSelector]
-
- if (!entry || typeof entry !== 'object') {
- continue
- }
-
- const scrollX = entry.scrollX
- const scrollY = entry.scrollY
-
- if (!Number.isFinite(scrollX) || !Number.isFinite(scrollY)) {
- continue
- }
+ for (const elementSelector in elementEntries) {
+ const entry = elementEntries[elementSelector]
+ const scrollX = entry?.scrollX
+ const scrollY = entry?.scrollY
+ if (Number.isFinite(scrollX) && Number.isFinite(scrollY)) {
if (elementSelector === 'window') {
- window.scrollTo({
- top: scrollY,
- left: scrollX,
- })
+ scrollTo(scrollX, scrollY)
windowRestored = true
} else if (elementSelector) {
- let element
-
try {
- element = document.querySelector(elementSelector)
- } catch {
- continue
- }
-
- if (element) {
- element.scrollLeft = scrollX
- element.scrollTop = scrollY
- }
+ const element = document.querySelector(elementSelector)
+ if (element) {
+ element.scrollLeft = scrollX
+ element.scrollTop = scrollY
+ }
+ } catch {}
}
}
}
if (windowRestored) return
- const hash = window.location.hash.split('#', 2)[1]
+ const hash = location.hash.slice(1)
if (hash) {
const hashScrollIntoViewOptions =
- window.history.state?.__hashScrollIntoViewOptions ?? true
+ history.state?.__hashScrollIntoViewOptions ?? true
if (hashScrollIntoViewOptions) {
const el = document.getElementById(hash)
@@ -68,5 +49,5 @@ export default function (options: { storageKey: string; key?: string }) {
return
}
- window.scrollTo({ top: 0, left: 0 })
+ scrollTo(0, 0)
}
diff --git a/packages/router-core/src/scroll-restoration-script/server.ts b/packages/router-core/src/scroll-restoration-script/server.ts
index d112f144b9..5c5ce5e734 100644
--- a/packages/router-core/src/scroll-restoration-script/server.ts
+++ b/packages/router-core/src/scroll-restoration-script/server.ts
@@ -6,25 +6,16 @@ import {
import { escapeHtml } from '../utils'
import type { AnyRouter } from '../router'
-type InlineScrollRestorationScriptOptions = {
- storageKey: string
- key?: string
-}
-
const defaultInlineScrollRestorationScript = `(${minifiedScrollRestorationScript})(${escapeHtml(
- JSON.stringify({
- storageKey,
- } satisfies InlineScrollRestorationScriptOptions),
+ JSON.stringify(storageKey),
)})`
-function getScrollRestorationScript(
- options: InlineScrollRestorationScriptOptions,
-) {
- if (options.storageKey === storageKey && options.key === undefined) {
+function getScrollRestorationScript(key?: string) {
+ if (key === undefined) {
return defaultInlineScrollRestorationScript
}
- return `(${minifiedScrollRestorationScript})(${escapeHtml(JSON.stringify(options))})`
+ return `(${minifiedScrollRestorationScript})(${escapeHtml(JSON.stringify(storageKey))},${escapeHtml(JSON.stringify(key))})`
}
export function getScrollRestorationScriptForRouter(router: AnyRouter) {
@@ -48,8 +39,5 @@ export function getScrollRestorationScriptForRouter(router: AnyRouter) {
return defaultInlineScrollRestorationScript
}
- return getScrollRestorationScript({
- storageKey,
- key: userKey,
- })
+ return getScrollRestorationScript(userKey)
}
diff --git a/packages/router-core/src/scroll-restoration.ts b/packages/router-core/src/scroll-restoration.ts
index d27ad28529..f3c67802d5 100644
--- a/packages/router-core/src/scroll-restoration.ts
+++ b/packages/router-core/src/scroll-restoration.ts
@@ -302,60 +302,51 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) {
}
}
- if (!shouldResetScroll) {
- return
- }
-
ignoreScroll = true
try {
const hash = event.toLocation.hash
const hashScrollIntoViewOptions =
event.toLocation.state.__hashScrollIntoViewOptions ?? true
- const action = locationHistoryActions.get(event.toLocation)
- const skipWindowRestore =
- hash &&
- hashScrollIntoViewOptions &&
- (action === 'PUSH' || action === 'REPLACE')
-
- const elementEntries = router.isScrollRestoring
- ? scrollRestorationCache[cacheKey]
- : undefined
let windowRestored = false
- if (elementEntries) {
- for (const elementSelector in elementEntries) {
- const { scrollX, scrollY } = elementEntries[elementSelector]!
+ if (shouldResetScroll) {
+ const action = locationHistoryActions.get(event.toLocation)
+ const skipWindowRestore =
+ hash &&
+ hashScrollIntoViewOptions &&
+ (action === 'PUSH' || action === 'REPLACE')
- if (elementSelector === windowScrollTarget) {
- if (skipWindowRestore) {
- continue
- }
+ const elementEntries = router.isScrollRestoring
+ ? scrollRestorationCache[cacheKey]
+ : undefined
- scrollTo({
- top: scrollY,
- left: scrollX,
- behavior,
- })
- windowRestored = true
- } else {
- const element = getElement(elementSelector)
- if (element) {
- element.scrollLeft = scrollX
- element.scrollTop = scrollY
+ if (elementEntries) {
+ for (const elementSelector in elementEntries) {
+ const { scrollX, scrollY } = elementEntries[elementSelector]!
+
+ if (elementSelector === windowScrollTarget) {
+ if (skipWindowRestore) {
+ continue
+ }
+
+ scrollTo({
+ top: scrollY,
+ left: scrollX,
+ behavior,
+ })
+ windowRestored = true
+ } else {
+ const element = getElement(elementSelector)
+ if (element) {
+ element.scrollLeft = scrollX
+ element.scrollTop = scrollY
+ }
}
}
}
- }
- if (!windowRestored) {
- if (hash) {
- if (hashScrollIntoViewOptions) {
- document
- .getElementById(hash)
- ?.scrollIntoView(hashScrollIntoViewOptions)
- }
- } else {
+ if (!windowRestored && !hash) {
const scrollOptions = {
top: 0,
left: 0,
@@ -371,6 +362,10 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) {
}
}
}
+
+ if (!windowRestored && hash && hashScrollIntoViewOptions) {
+ document.getElementById(hash)?.scrollIntoView(hashScrollIntoViewOptions)
+ }
} finally {
ignoreScroll = false
}
diff --git a/packages/router-core/tests/scroll-restoration-script.test.ts b/packages/router-core/tests/scroll-restoration-script.test.ts
new file mode 100644
index 0000000000..ed1f151562
--- /dev/null
+++ b/packages/router-core/tests/scroll-restoration-script.test.ts
@@ -0,0 +1,155 @@
+import { createMemoryHistory } from '@tanstack/history'
+import { afterEach, describe, expect, test, vi } from 'vitest'
+import { getScrollRestorationScriptForRouter } from '../src/scroll-restoration-script/server'
+import { BaseRootRoute, BaseRoute, storageKey } from '../src'
+import { createTestRouter } from './routerTestUtils'
+
+function createScrollRestorationRouter(getScrollRestorationKey?: () => string) {
+ const rootRoute = new BaseRootRoute({})
+ const indexRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+
+ return createTestRouter({
+ routeTree: rootRoute.addChildren([indexRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ isServer: true,
+ scrollRestoration: true,
+ getScrollRestorationKey,
+ })
+}
+
+function runScrollRestorationScript(script: string | null) {
+ expect(script).toBeTruthy()
+ new Function(script!)()
+}
+
+afterEach(() => {
+ window.sessionStorage.clear()
+ window.history.replaceState({}, '', '/')
+ vi.unstubAllGlobals()
+})
+
+describe('getScrollRestorationScriptForRouter', () => {
+ test('restores SSR scroll entries for a user supplied key', () => {
+ const router = createScrollRestorationRouter(() => 'custom-key')
+ const script = getScrollRestorationScriptForRouter(router)
+ const scrollTo = vi.fn()
+
+ window.history.replaceState({ __TSR_key: 'history-key' }, '', '/')
+ window.sessionStorage.setItem(
+ storageKey,
+ JSON.stringify({
+ 'custom-key': {
+ window: { scrollX: 11, scrollY: 22 },
+ },
+ 'history-key': {
+ window: { scrollX: 33, scrollY: 44 },
+ },
+ }),
+ )
+
+ vi.stubGlobal('scrollTo', scrollTo)
+
+ runScrollRestorationScript(script)
+
+ expect(scrollTo).toHaveBeenCalledWith(11, 22)
+ })
+
+ test('restores SSR scroll entries for the default history key', () => {
+ const router = createScrollRestorationRouter()
+ const script = getScrollRestorationScriptForRouter(router)
+ const scrollTo = vi.fn()
+
+ window.history.replaceState({ __TSR_key: 'history-key' }, '', '/')
+ window.sessionStorage.setItem(
+ storageKey,
+ JSON.stringify({
+ 'history-key': {
+ window: { scrollX: 33, scrollY: 44 },
+ },
+ '/': {
+ window: { scrollX: 11, scrollY: 22 },
+ },
+ }),
+ )
+
+ vi.stubGlobal('scrollTo', scrollTo)
+
+ runScrollRestorationScript(script)
+
+ expect(scrollTo).toHaveBeenCalledWith(33, 44)
+ })
+
+ test('ignores invalid serialized scroll storage', () => {
+ const router = createScrollRestorationRouter()
+ const script = getScrollRestorationScriptForRouter(router)
+ const scrollTo = vi.fn()
+
+ window.history.replaceState({ __TSR_key: 'history-key' }, '', '/')
+ window.sessionStorage.setItem(storageKey, '{')
+ vi.stubGlobal('scrollTo', scrollTo)
+
+ expect(() => runScrollRestorationScript(script)).not.toThrow()
+ expect(scrollTo).not.toHaveBeenCalled()
+ })
+
+ test('ignores malformed scroll entries and falls back to top', () => {
+ const router = createScrollRestorationRouter()
+ const script = getScrollRestorationScriptForRouter(router)
+ const scrollTo = vi.fn()
+
+ window.history.replaceState({ __TSR_key: 'history-key' }, '', '/')
+ window.sessionStorage.setItem(
+ storageKey,
+ JSON.stringify({
+ 'history-key': {
+ window: { scrollX: 'bad', scrollY: 44 },
+ '[': { scrollX: 11, scrollY: 22 },
+ empty: null,
+ },
+ }),
+ )
+ vi.stubGlobal('scrollTo', scrollTo)
+
+ expect(() => runScrollRestorationScript(script)).not.toThrow()
+ expect(scrollTo).toHaveBeenCalledWith(0, 0)
+ })
+
+ test('ignores a malformed restoration key payload and falls back to top', () => {
+ const router = createScrollRestorationRouter()
+ const script = getScrollRestorationScriptForRouter(router)
+ const scrollTo = vi.fn()
+
+ window.history.replaceState({ __TSR_key: 'history-key' }, '', '/')
+ window.sessionStorage.setItem(
+ storageKey,
+ JSON.stringify({
+ 'history-key': 'not an entry object',
+ }),
+ )
+ vi.stubGlobal('scrollTo', scrollTo)
+
+ expect(() => runScrollRestorationScript(script)).not.toThrow()
+ expect(scrollTo).toHaveBeenCalledWith(0, 0)
+ })
+
+ test('falls back safely when restoration key is missing from storage', () => {
+ const router = createScrollRestorationRouter()
+ const script = getScrollRestorationScriptForRouter(router)
+ const scrollTo = vi.fn()
+
+ window.history.replaceState({ __TSR_key: 'history-key' }, '', '/')
+ window.sessionStorage.setItem(
+ storageKey,
+ JSON.stringify({
+ 'other-key': { window: { scrollX: 1, scrollY: 2 } },
+ }),
+ )
+ vi.stubGlobal('scrollTo', scrollTo)
+
+ expect(() => runScrollRestorationScript(script)).not.toThrow()
+ expect(scrollTo).toHaveBeenCalledWith(0, 0)
+ })
+})