From efcc684292fc13c5350d9315bd7230b08e3fc34a Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Thu, 21 May 2026 22:26:24 +0200 Subject: [PATCH 1/2] fix: Fix hash scrolling with `resetScroll={false}` --- .changeset/scroll-restoration-fixes.md | 5 + .../scroll-restoration/src/routeTree.gen.ts | 21 +++ .../scroll-restoration/src/router.tsx | 4 + .../src/routes/(tests)/hash-scroll-repro.tsx | 9 ++ .../src/routes/(tests)/ssr-scroll-key.tsx | 16 ++ .../scroll-restoration/tests/app.spec.ts | 40 +++++ .../tests/hash-scroll-repro.spec.ts | 16 ++ .../tests/ssr-scroll-key.spec.ts | 41 ++++++ .../src/scroll-restoration-inline.ts | 57 +++----- .../src/scroll-restoration-script/server.ts | 22 +-- .../router-core/src/scroll-restoration.ts | 75 +++++----- .../tests/scroll-restoration-script.test.ts | 137 ++++++++++++++++++ 12 files changed, 348 insertions(+), 95 deletions(-) create mode 100644 .changeset/scroll-restoration-fixes.md create mode 100644 e2e/react-start/scroll-restoration/src/routes/(tests)/ssr-scroll-key.tsx create mode 100644 e2e/react-start/scroll-restoration/tests/ssr-scroll-key.spec.ts create mode 100644 packages/router-core/tests/scroll-restoration-script.test.ts 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..2b1a06e9f3 --- /dev/null +++ b/packages/router-core/tests/scroll-restoration-script.test.ts @@ -0,0 +1,137 @@ +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) + }) +}) From 3a0512bb055da148009116f2dcff4921add165bf Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Thu, 21 May 2026 22:38:51 +0200 Subject: [PATCH 2/2] Update packages/router-core/tests/scroll-restoration-script.test.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../tests/scroll-restoration-script.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/router-core/tests/scroll-restoration-script.test.ts b/packages/router-core/tests/scroll-restoration-script.test.ts index 2b1a06e9f3..ed1f151562 100644 --- a/packages/router-core/tests/scroll-restoration-script.test.ts +++ b/packages/router-core/tests/scroll-restoration-script.test.ts @@ -134,4 +134,22 @@ describe('getScrollRestorationScriptForRouter', () => { 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) + }) })