diff --git a/.changeset/tall-buses-bow.md b/.changeset/tall-buses-bow.md new file mode 100644 index 00000000000..07cc08dc8f4 --- /dev/null +++ b/.changeset/tall-buses-bow.md @@ -0,0 +1,8 @@ +--- +'@tanstack/router-core': patch +'@tanstack/react-router': patch +'@tanstack/solid-router': patch +'@tanstack/vue-router': patch +--- + +fix: scroll restoration without throttling diff --git a/e2e/react-router/basepath-file-based/tests/scroll-restoration-session-storage-error.test.ts b/e2e/react-router/basepath-file-based/tests/scroll-restoration-session-storage-error.test.ts index ab0bb4bc993..06b90f6528f 100644 --- a/e2e/react-router/basepath-file-based/tests/scroll-restoration-session-storage-error.test.ts +++ b/e2e/react-router/basepath-file-based/tests/scroll-restoration-session-storage-error.test.ts @@ -1,25 +1,10 @@ /* eslint-disable */ import { expect, test } from '@playwright/test' -import type { Page } from '@playwright/test' - -const trackConsole = (page: Page) => { - const consoleWarnings: Array = [] - - page.on('console', (msg) => { - if (msg.type() === 'warning') { - consoleWarnings.push(msg.text()) - } - }) - - return consoleWarnings -} test.describe('Scroll Restoration with Session Storage Error', () => { test('should not crash when sessionStorage.setItem throws an error', async ({ page, }) => { - const consoleWarnings = trackConsole(page) - await page.goto('/app/scroll-error') await page.waitForLoadState('networkidle') @@ -32,20 +17,9 @@ test.describe('Scroll Restoration with Session Storage Error', () => { await page.evaluate(() => window.scrollTo(0, 200)) await page.waitForTimeout(150) - await page.click('a[href="/app/about"]') - await page.waitForLoadState('networkidle') - - await page.goBack() + await page.reload() await page.waitForLoadState('networkidle') - expect( - consoleWarnings.some((warning) => - warning.includes( - '[ts-router] Could not persist scroll restoration state to sessionStorage.', - ), - ), - ).toBeTruthy() - const heading = page.locator('h1:has-text("Scroll Error Test")') await expect(heading).toBeVisible() @@ -53,11 +27,9 @@ test.describe('Scroll Restoration with Session Storage Error', () => { expect(scrollPosition).not.toBe(200) }) - test('should surface warning when sessionStorage quota is exceeded', async ({ + test('should not crash when sessionStorage quota is exceeded', async ({ page, }) => { - const consoleWarnings = trackConsole(page) - await page.goto('/app/scroll-error') await page.waitForLoadState('networkidle') @@ -78,20 +50,9 @@ test.describe('Scroll Restoration with Session Storage Error', () => { await page.evaluate(() => window.scrollTo(0, 200)) await page.waitForTimeout(150) - await page.click('a[href="/app/about"]') + await page.reload() await page.waitForLoadState('networkidle') - await page.goBack() - await page.waitForLoadState('networkidle') - - expect( - consoleWarnings.some((warning) => - warning.includes( - '[ts-router] Could not persist scroll restoration state to sessionStorage.', - ), - ), - ).toBeTruthy() - const heading = page.locator('h1:has-text("Scroll Error Test")') await expect(heading).toBeVisible() diff --git a/e2e/react-router/scroll-restoration-sandbox-vite/src/routeTree.gen.ts b/e2e/react-router/scroll-restoration-sandbox-vite/src/routeTree.gen.ts index 6664a9ddf75..a4dc8750b34 100644 --- a/e2e/react-router/scroll-restoration-sandbox-vite/src/routeTree.gen.ts +++ b/e2e/react-router/scroll-restoration-sandbox-vite/src/routeTree.gen.ts @@ -16,6 +16,8 @@ import { Route as testsPageWithSearchRouteImport } from './routes/(tests)/page-w import { Route as testsNormalPageRouteImport } from './routes/(tests)/normal-page' import { Route as testsLazyWithLoaderPageRouteImport } from './routes/(tests)/lazy-with-loader-page' import { Route as testsLazyPageRouteImport } from './routes/(tests)/lazy-page' +import { Route as testsIssue7040TargetRouteImport } from './routes/(tests)/issue-7040-target' +import { Route as testsIssue7040SourceRouteImport } from './routes/(tests)/issue-7040-source' const testsVirtualPageLazyRouteImport = createFileRoute( '/(tests)/virtual-page', @@ -59,9 +61,21 @@ const testsLazyPageRoute = testsLazyPageRouteImport getParentRoute: () => rootRouteImport, } as any) .lazy(() => import('./routes/(tests)/lazy-page.lazy').then((d) => d.Route)) +const testsIssue7040TargetRoute = testsIssue7040TargetRouteImport.update({ + id: '/(tests)/issue-7040-target', + path: '/issue-7040-target', + getParentRoute: () => rootRouteImport, +} as any) +const testsIssue7040SourceRoute = testsIssue7040SourceRouteImport.update({ + id: '/(tests)/issue-7040-source', + path: '/issue-7040-source', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/issue-7040-source': typeof testsIssue7040SourceRoute + '/issue-7040-target': typeof testsIssue7040TargetRoute '/lazy-page': typeof testsLazyPageRoute '/lazy-with-loader-page': typeof testsLazyWithLoaderPageRoute '/normal-page': typeof testsNormalPageRoute @@ -70,6 +84,8 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/issue-7040-source': typeof testsIssue7040SourceRoute + '/issue-7040-target': typeof testsIssue7040TargetRoute '/lazy-page': typeof testsLazyPageRoute '/lazy-with-loader-page': typeof testsLazyWithLoaderPageRoute '/normal-page': typeof testsNormalPageRoute @@ -79,6 +95,8 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/(tests)/issue-7040-source': typeof testsIssue7040SourceRoute + '/(tests)/issue-7040-target': typeof testsIssue7040TargetRoute '/(tests)/lazy-page': typeof testsLazyPageRoute '/(tests)/lazy-with-loader-page': typeof testsLazyWithLoaderPageRoute '/(tests)/normal-page': typeof testsNormalPageRoute @@ -89,6 +107,8 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/issue-7040-source' + | '/issue-7040-target' | '/lazy-page' | '/lazy-with-loader-page' | '/normal-page' @@ -97,6 +117,8 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/issue-7040-source' + | '/issue-7040-target' | '/lazy-page' | '/lazy-with-loader-page' | '/normal-page' @@ -105,6 +127,8 @@ export interface FileRouteTypes { id: | '__root__' | '/' + | '/(tests)/issue-7040-source' + | '/(tests)/issue-7040-target' | '/(tests)/lazy-page' | '/(tests)/lazy-with-loader-page' | '/(tests)/normal-page' @@ -114,6 +138,8 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + testsIssue7040SourceRoute: typeof testsIssue7040SourceRoute + testsIssue7040TargetRoute: typeof testsIssue7040TargetRoute testsLazyPageRoute: typeof testsLazyPageRoute testsLazyWithLoaderPageRoute: typeof testsLazyWithLoaderPageRoute testsNormalPageRoute: typeof testsNormalPageRoute @@ -165,11 +191,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof testsLazyPageRouteImport parentRoute: typeof rootRouteImport } + '/(tests)/issue-7040-target': { + id: '/(tests)/issue-7040-target' + path: '/issue-7040-target' + fullPath: '/issue-7040-target' + preLoaderRoute: typeof testsIssue7040TargetRouteImport + parentRoute: typeof rootRouteImport + } + '/(tests)/issue-7040-source': { + id: '/(tests)/issue-7040-source' + path: '/issue-7040-source' + fullPath: '/issue-7040-source' + preLoaderRoute: typeof testsIssue7040SourceRouteImport + parentRoute: typeof rootRouteImport + } } } const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + testsIssue7040SourceRoute: testsIssue7040SourceRoute, + testsIssue7040TargetRoute: testsIssue7040TargetRoute, testsLazyPageRoute: testsLazyPageRoute, testsLazyWithLoaderPageRoute: testsLazyWithLoaderPageRoute, testsNormalPageRoute: testsNormalPageRoute, diff --git a/e2e/react-router/scroll-restoration-sandbox-vite/src/routes/(tests)/issue-7040-source.tsx b/e2e/react-router/scroll-restoration-sandbox-vite/src/routes/(tests)/issue-7040-source.tsx new file mode 100644 index 00000000000..3c1702a942a --- /dev/null +++ b/e2e/react-router/scroll-restoration-sandbox-vite/src/routes/(tests)/issue-7040-source.tsx @@ -0,0 +1,49 @@ +import * as React from 'react' +import { Link, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/(tests)/issue-7040-source')({ + component: Component, +}) + +const sectionCount = 180 + +function Component() { + return ( +
+
+

issue-7040-source

+

+ Tall page for reproducing the scroll restoration race. +

+
+ + + Go to issue-7040-target + + +
+ {Array.from({ length: sectionCount }).map((_, index) => ( +
+

Section {index + 1}

+

+ This section adds vertical space so the page can be scrolled far + enough to reproduce issue #7040. +

+
+ ))} +
+ +
+ Bottom of issue-7040-source +
+
+ ) +} diff --git a/e2e/react-router/scroll-restoration-sandbox-vite/src/routes/(tests)/issue-7040-target.tsx b/e2e/react-router/scroll-restoration-sandbox-vite/src/routes/(tests)/issue-7040-target.tsx new file mode 100644 index 00000000000..df541c6dbb0 --- /dev/null +++ b/e2e/react-router/scroll-restoration-sandbox-vite/src/routes/(tests)/issue-7040-target.tsx @@ -0,0 +1,40 @@ +import * as React from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { sleep } from '../../posts' + +export const Route = createFileRoute('/(tests)/issue-7040-target')({ + loader: async () => { + await sleep(250) + return null + }, + component: Component, +}) + +function Component() { + return ( +
+
+

issue-7040-target

+

+ Short page that should reset to top on navigation. +

+
+ +
+ {Array.from({ length: 18 }).map((_, index) => ( +
+

Target section {index + 1}

+

This page is intentionally much shorter than the source page.

+
+ ))} +
+ +
+ Bottom of issue-7040-target +
+
+ ) +} diff --git a/e2e/react-router/scroll-restoration-sandbox-vite/src/routes/__root.tsx b/e2e/react-router/scroll-restoration-sandbox-vite/src/routes/__root.tsx index 18726e1168a..ed80300a68b 100644 --- a/e2e/react-router/scroll-restoration-sandbox-vite/src/routes/__root.tsx +++ b/e2e/react-router/scroll-restoration-sandbox-vite/src/routes/__root.tsx @@ -45,6 +45,8 @@ function Nav({ type }: { type: 'header' | 'footer' }) { linkOptions({ to: '/virtual-page' }), linkOptions({ to: '/lazy-with-loader-page' }), linkOptions({ to: '/page-with-search', search: { where: type } }), + linkOptions({ to: '/issue-7040-source' }), + linkOptions({ to: '/issue-7040-target' }), ] as const ).map((options, i) => ( {( [ + linkOptions({ to: '/issue-7040-source' }), + linkOptions({ to: '/issue-7040-target' }), linkOptions({ to: '/normal-page' }), linkOptions({ to: '/lazy-page' }), linkOptions({ to: '/virtual-page' }), diff --git a/e2e/react-router/scroll-restoration-sandbox-vite/tests/issue-7040.repro.spec.ts b/e2e/react-router/scroll-restoration-sandbox-vite/tests/issue-7040.repro.spec.ts new file mode 100644 index 00000000000..db335f8e2ea --- /dev/null +++ b/e2e/react-router/scroll-restoration-sandbox-vite/tests/issue-7040.repro.spec.ts @@ -0,0 +1,151 @@ +import { expect, test } from '@playwright/test' +import type { Page } from '@playwright/test' +import { toRuntimePath } from '@tanstack/router-e2e-utils' + +const attemptCount = 3 +const sourceScrollTargetY = 6000 +const fastScrollStepPx = 220 +const fastScrollDelayMs = 3 +const maxFastScrollSteps = 80 +const preClickReverseScrollPx = 60 + +type ReproAttempt = { + attempt: number + before: { + sourceKey: string + sourceScrollY: number + sourceHeight: number + } + after: { + destinationKey: string + destinationScrollY: number + destinationHeight: number + destinationMaxScrollY: number + } +} + +async function openFreshSourcePage(page: Page) { + await page.goto(toRuntimePath('/issue-7040-source')) + await page.waitForLoadState('networkidle') + + await page.evaluate(() => { + sessionStorage.clear() + window.scrollTo(0, 0) + }) + + await page.reload() + await page.waitForLoadState('networkidle') +} + +async function fastScrollSourcePage(page: Page) { + await page.evaluate( + async ({ targetScrollY, stepPx, stepDelayMs, maxSteps }) => { + const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)) + + window.scrollTo(0, 0) + + let steps = 0 + + while (window.scrollY < targetScrollY && steps < maxSteps) { + window.scrollBy(0, stepPx) + steps += 1 + await sleep(stepDelayMs) + } + + if (window.scrollY < targetScrollY) { + throw new Error( + `Could not reach source scroll target ${targetScrollY}; got ${window.scrollY}`, + ) + } + }, + { + targetScrollY: sourceScrollTargetY, + stepPx: fastScrollStepPx, + stepDelayMs: fastScrollDelayMs, + maxSteps: maxFastScrollSteps, + }, + ) +} + +async function clickTargetLinkImmediatelyAfterScroll(page: Page) { + await page.evaluate( + ({ reverseScrollPx }) => { + const link = document.querySelector( + '[data-testid="issue-7040-target-link"]', + ) + + if (!(link instanceof HTMLAnchorElement)) { + throw new Error('issue-7040 target link not found') + } + + // Trigger one more browser scroll update immediately before navigation + // so the test stays focused on the stale-scroll race. + window.scrollBy(0, -reverseScrollPx) + link.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + cancelable: true, + view: window, + }), + ) + }, + { reverseScrollPx: preClickReverseScrollPx }, + ) +} + +async function waitForTargetPageToSettle(page: Page) { + await page.waitForURL('**/issue-7040-target') + await page.waitForLoadState('networkidle') + await page.waitForFunction(() => window.scrollY === 0) +} + +test('Issue #7040 repro: fast scroll then click can restore target below top', async ({ + page, +}) => { + const attempts: Array = [] + + for (let attempt = 0; attempt < attemptCount; attempt++) { + await openFreshSourcePage(page) + await fastScrollSourcePage(page) + + const before = await page.evaluate(() => { + return { + sourceKey: window.history.state.__TSR_key, + sourceScrollY: window.scrollY, + sourceHeight: document.documentElement.scrollHeight, + } + }) + + await clickTargetLinkImmediatelyAfterScroll(page) + await waitForTargetPageToSettle(page) + + const after = await page.evaluate(() => { + return { + destinationKey: window.history.state.__TSR_key, + destinationScrollY: window.scrollY, + destinationHeight: document.documentElement.scrollHeight, + destinationMaxScrollY: + document.documentElement.scrollHeight - window.innerHeight, + } + }) + + attempts.push({ attempt, before, after }) + } + + for (const attempt of attempts) { + expect(attempt.before.sourceHeight).toBeGreaterThan(15000) + expect(attempt.before.sourceScrollY).toBeGreaterThan(sourceScrollTargetY) + expect(attempt.before.sourceKey).not.toBe(attempt.after.destinationKey) + expect(attempt.before.sourceScrollY).toBeGreaterThan( + attempt.after.destinationMaxScrollY, + ) + expect(attempt.after.destinationHeight).toBeLessThan( + attempt.before.sourceHeight, + ) + expect(attempt.after.destinationScrollY).toBe(0) + expect(attempt.after.destinationScrollY).toBeLessThanOrEqual( + attempt.after.destinationMaxScrollY, + ) + } +}) diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index ac3a55a5a56..04e354d0835 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -202,11 +202,12 @@ function MatchView({ - {matchState.parentRouteId === rootRouteId && - router.options.scrollRestoration ? ( + {matchState.parentRouteId === rootRouteId ? ( <> - + {router.options.scrollRestoration && (isServer ?? router.isServer) ? ( + + ) : null} ) : null} diff --git a/packages/react-router/src/ScrollRestoration.tsx b/packages/react-router/src/ScrollRestoration.tsx index 55591c92cee..d36210db415 100644 --- a/packages/react-router/src/ScrollRestoration.tsx +++ b/packages/react-router/src/ScrollRestoration.tsx @@ -1,7 +1,5 @@ import { - defaultGetScrollRestorationKey, - getCssSelector, - scrollRestorationCache, + getElementScrollRestorationEntry, setupScrollRestoration, } from '@tanstack/router-core' import { useRouter } from './useRouter' @@ -47,23 +45,5 @@ export function useElementScrollRestoration( ): ScrollRestorationEntry | undefined { useScrollRestoration() - const router = useRouter() - const getKey = options.getKey || defaultGetScrollRestorationKey - - let elementSelector = '' - - if (options.id) { - elementSelector = `[data-scroll-restoration-id="${options.id}"]` - } else { - const element = options.getElement?.() - if (!element) { - return - } - elementSelector = - element instanceof Window ? 'window' : getCssSelector(element) - } - - const restoreKey = getKey(router.latestLocation) - const byKey = scrollRestorationCache?.state[restoreKey] - return byKey?.[elementSelector] + return getElementScrollRestorationEntry(useRouter(), options) } diff --git a/packages/react-router/src/scroll-restoration.tsx b/packages/react-router/src/scroll-restoration.tsx index f8c3c4559a8..5c49425e777 100644 --- a/packages/react-router/src/scroll-restoration.tsx +++ b/packages/react-router/src/scroll-restoration.tsx @@ -1,45 +1,14 @@ -import { - defaultGetScrollRestorationKey, - escapeHtml, - restoreScroll, - storageKey, -} from '@tanstack/router-core' -import { isServer } from '@tanstack/router-core/isServer' +import { getScrollRestorationScriptForRouter } from '@tanstack/router-core/scroll-restoration-script' import { useRouter } from './useRouter' import { ScriptOnce } from './ScriptOnce' export function ScrollRestoration() { const router = useRouter() - if (!router.isScrollRestoring || !(isServer ?? router.isServer)) { - return null - } - if (typeof router.options.scrollRestoration === 'function') { - const shouldRestore = router.options.scrollRestoration({ - location: router.latestLocation, - }) - if (!shouldRestore) { - return null - } - } - const getKey = - router.options.getScrollRestorationKey || defaultGetScrollRestorationKey - const userKey = getKey(router.latestLocation) - const resolvedKey = - userKey !== defaultGetScrollRestorationKey(router.latestLocation) - ? userKey - : undefined + const script = getScrollRestorationScriptForRouter(router) - const restoreScrollOptions: Parameters[0] = { - storageKey, - shouldScrollRestoration: true, - } - if (resolvedKey) { - restoreScrollOptions.key = resolvedKey + if (!script) { + return null } - return ( - - ) + return } diff --git a/packages/react-router/tests/Scripts.test.tsx b/packages/react-router/tests/Scripts.test.tsx index 0ca10b794a5..cd604ce77e2 100644 --- a/packages/react-router/tests/Scripts.test.tsx +++ b/packages/react-router/tests/Scripts.test.tsx @@ -162,7 +162,7 @@ describe('ssr scripts', () => { expect(await screen.findByTestId('index')).toBeInTheDocument() expect(container.innerHTML).toEqual( - `
root
index
`, + `
root
index
`, ) }) }) diff --git a/packages/router-core/package.json b/packages/router-core/package.json index e170b02985f..232873bb834 100644 --- a/packages/router-core/package.json +++ b/packages/router-core/package.json @@ -148,6 +148,26 @@ "default": "./dist/cjs/isServer/client.cjs" } }, + "./scroll-restoration-script": { + "browser": { + "import": { + "types": "./dist/esm/scroll-restoration-script/client.d.ts", + "default": "./dist/esm/scroll-restoration-script/client.js" + }, + "require": { + "types": "./dist/cjs/scroll-restoration-script/client.d.cts", + "default": "./dist/cjs/scroll-restoration-script/client.cjs" + } + }, + "import": { + "types": "./dist/esm/scroll-restoration-script/server.d.ts", + "default": "./dist/esm/scroll-restoration-script/server.js" + }, + "require": { + "types": "./dist/cjs/scroll-restoration-script/server.d.cts", + "default": "./dist/cjs/scroll-restoration-script/server.cjs" + } + }, "./package.json": "./package.json" }, "sideEffects": false, diff --git a/packages/router-core/src/hash-scroll.ts b/packages/router-core/src/hash-scroll.ts new file mode 100644 index 00000000000..a51ed01b2df --- /dev/null +++ b/packages/router-core/src/hash-scroll.ts @@ -0,0 +1,21 @@ +import type { AnyRouter } from './router' + +/** + * @private + * Handles hash-based scrolling after navigation completes. + * To be used in framework-specific components during the onResolved event. + */ +export function handleHashScroll(router: AnyRouter) { + if (typeof document !== 'undefined' && (document as any).querySelector) { + const location = router.stores.location.state + const hashScrollIntoViewOptions = + location.state.__hashScrollIntoViewOptions ?? true + + if (hashScrollIntoViewOptions && location.hash !== '') { + const el = document.getElementById(location.hash) + if (el) { + el.scrollIntoView(hashScrollIntoViewOptions) + } + } + } +} diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index 2d4705c3d62..86731b944f1 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -397,14 +397,14 @@ export { isNotFound, notFound } from './not-found' export { defaultGetScrollRestorationKey, - restoreScroll, + getElementScrollRestorationEntry, storageKey, - getCssSelector, scrollRestorationCache, setupScrollRestoration, - handleHashScroll, } from './scroll-restoration' +export { handleHashScroll } from './hash-scroll' + export type { ScrollRestorationOptions, ScrollRestorationEntry, diff --git a/packages/router-core/src/scroll-restoration-inline.ts b/packages/router-core/src/scroll-restoration-inline.ts new file mode 100644 index 00000000000..b6c772fa693 --- /dev/null +++ b/packages/router-core/src/scroll-restoration-inline.ts @@ -0,0 +1,81 @@ +export default function (options: { + storageKey: string + key?: string + behavior?: ScrollToOptions['behavior'] + shouldScrollRestoration?: boolean +}) { + let byKey + + try { + byKey = JSON.parse(sessionStorage.getItem(options.storageKey) || '{}') + } catch (error) { + console.error(error) + return + } + + const resolvedKey = options.key || window.history.state?.__TSR_key + const elementEntries = resolvedKey ? byKey[resolvedKey] : undefined + + if ( + options.shouldScrollRestoration && + elementEntries && + typeof elementEntries === 'object' && + Object.keys(elementEntries).length > 0 + ) { + 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 + } + + if (elementSelector === 'window') { + window.scrollTo({ + top: scrollY, + left: scrollX, + behavior: options.behavior, + }) + } else if (elementSelector) { + let element + + try { + element = document.querySelector(elementSelector) + } catch { + continue + } + + if (element) { + element.scrollLeft = scrollX + element.scrollTop = scrollY + } + } + } + + return + } + + const hash = window.location.hash.split('#', 2)[1] + + if (hash) { + const hashScrollIntoViewOptions = + window.history.state?.__hashScrollIntoViewOptions ?? true + + if (hashScrollIntoViewOptions) { + const el = document.getElementById(hash) + if (el) { + el.scrollIntoView(hashScrollIntoViewOptions) + } + } + + return + } + + window.scrollTo({ top: 0, left: 0, behavior: options.behavior }) +} diff --git a/packages/router-core/src/scroll-restoration-script/client.ts b/packages/router-core/src/scroll-restoration-script/client.ts new file mode 100644 index 00000000000..7552ebcce30 --- /dev/null +++ b/packages/router-core/src/scroll-restoration-script/client.ts @@ -0,0 +1,5 @@ +import type { AnyRouter } from '../router' + +export function getScrollRestorationScriptForRouter(_router: AnyRouter) { + return null +} diff --git a/packages/router-core/src/scroll-restoration-script/server.ts b/packages/router-core/src/scroll-restoration-script/server.ts new file mode 100644 index 00000000000..cc7f0f38fbb --- /dev/null +++ b/packages/router-core/src/scroll-restoration-script/server.ts @@ -0,0 +1,64 @@ +import minifiedScrollRestorationScript from '../scroll-restoration-inline?script-string' +import { + defaultGetScrollRestorationKey, + storageKey, +} from '../scroll-restoration' +import { escapeHtml } from '../utils' +import type { AnyRouter } from '../router' + +type InlineScrollRestorationScriptOptions = { + storageKey: string + key?: string + behavior?: ScrollToOptions['behavior'] + shouldScrollRestoration?: boolean +} + +const defaultInlineScrollRestorationScript = `(${minifiedScrollRestorationScript})(${escapeHtml( + JSON.stringify({ + storageKey, + shouldScrollRestoration: true, + } satisfies InlineScrollRestorationScriptOptions), +)})` + +function getScrollRestorationScript( + options: InlineScrollRestorationScriptOptions, +) { + if ( + options.storageKey === storageKey && + options.shouldScrollRestoration === true && + options.key === undefined && + options.behavior === undefined + ) { + return defaultInlineScrollRestorationScript + } + + return `(${minifiedScrollRestorationScript})(${escapeHtml(JSON.stringify(options))})` +} + +export function getScrollRestorationScriptForRouter(router: AnyRouter) { + if ( + typeof router.options.scrollRestoration === 'function' && + !router.options.scrollRestoration({ location: router.latestLocation }) + ) { + return null + } + + const getKey = router.options.getScrollRestorationKey + if (!getKey) { + return defaultInlineScrollRestorationScript + } + + const location = router.latestLocation + const userKey = getKey(location) + const defaultKey = defaultGetScrollRestorationKey(location) + + if (userKey === defaultKey) { + return defaultInlineScrollRestorationScript + } + + return getScrollRestorationScript({ + storageKey, + shouldScrollRestoration: true, + key: userKey, + }) +} diff --git a/packages/router-core/src/scroll-restoration.ts b/packages/router-core/src/scroll-restoration.ts index 4f5cce0648f..6091a4560aa 100644 --- a/packages/router-core/src/scroll-restoration.ts +++ b/packages/router-core/src/scroll-restoration.ts @@ -1,20 +1,21 @@ import { isServer } from '@tanstack/router-core/isServer' -import { functionalUpdate } from './utils' +import { functionalUpdate, isPlainObject } from './utils' import type { AnyRouter } from './router' import type { ParsedLocation } from './location' import type { NonNullableUpdater } from './utils' -import type { HistoryLocation } from '@tanstack/history' export type ScrollRestorationEntry = { scrollX: number; scrollY: number } -export type ScrollRestorationByElement = Record +type ScrollRestorationByElement = Record -export type ScrollRestorationByKey = Record +type ScrollRestorationByKey = Record -export type ScrollRestorationCache = { - state: ScrollRestorationByKey +type ScrollRestorationCache = { + readonly state: ScrollRestorationByKey set: (updater: NonNullableUpdater) => void + persist: () => void } + export type ScrollRestorationOptions = { getKey?: (location: ParsedLocation) => string scrollBehavior?: ScrollToOptions['behavior'] @@ -22,65 +23,59 @@ export type ScrollRestorationOptions = { function getSafeSessionStorage() { try { - if ( - typeof window !== 'undefined' && + return typeof window !== 'undefined' && typeof window.sessionStorage === 'object' - ) { - return window.sessionStorage - } + ? window.sessionStorage + : undefined } catch { // silent + return undefined } - return undefined } -/** SessionStorage key used to persist scroll restoration state. */ -/** SessionStorage key used to store scroll positions across navigations. */ -/** SessionStorage key used to store scroll positions across navigations. */ +// SessionStorage key used to store scroll positions across navigations. export const storageKey = 'tsr-scroll-restoration-v1_3' -const throttle = (fn: (...args: Array) => void, wait: number) => { - let timeout: any - return (...args: Array) => { - if (!timeout) { - timeout = setTimeout(() => { - fn(...args) - timeout = null - }, wait) - } - } -} - function createScrollRestorationCache(): ScrollRestorationCache | null { const safeSessionStorage = getSafeSessionStorage() if (!safeSessionStorage) { return null } - const persistedState = safeSessionStorage.getItem(storageKey) - let state: ScrollRestorationByKey = persistedState - ? JSON.parse(persistedState) - : {} + let state: ScrollRestorationByKey = {} - return { - state, - // This setter is simply to make sure that we set the sessionStorage right - // after the state is updated. It doesn't necessarily need to be a functional - // update. - set: (updater) => { - state = functionalUpdate(updater, state) || state - try { - safeSessionStorage.setItem(storageKey, JSON.stringify(state)) - } catch { + try { + const parsed = JSON.parse(safeSessionStorage.getItem(storageKey) || '{}') + if (isPlainObject(parsed)) { + state = parsed as ScrollRestorationByKey + } + } catch { + // ignore invalid session storage payloads + } + + const persist = () => { + try { + safeSessionStorage.setItem(storageKey, JSON.stringify(state)) + } catch { + if (process.env.NODE_ENV !== 'production') { console.warn( '[ts-router] Could not persist scroll restoration state to sessionStorage.', ) } + } + } + + return { + get state() { + return state + }, + set: (updater) => { + state = functionalUpdate(updater, state) || state }, + persist, } } -/** In-memory handle to the persisted scroll restoration cache. */ export const scrollRestorationCache = createScrollRestorationCache() /** @@ -89,16 +84,11 @@ export const scrollRestorationCache = createScrollRestorationCache() * * The `location.href` is used as a fallback to support the use case where the location state is not available like the initial render. */ - -/** - * Default scroll restoration cache key: location state key or full href. - */ export const defaultGetScrollRestorationKey = (location: ParsedLocation) => { return location.state.__TSR_key! || location.href } -/** Best-effort nth-child CSS selector for a given element. */ -export function getCssSelector(el: any): string { +function getCssSelector(el: any): string { const path = [] let parent: HTMLElement while ((parent = el.parentNode)) { @@ -110,117 +100,52 @@ export function getCssSelector(el: any): string { return `${path.reverse().join(' > ')}`.toLowerCase() } -let ignoreScroll = false - -// NOTE: This function must remain pure and not use any outside variables -// unless they are passed in as arguments. Why? Because we need to be able to -// toString() it into a script tag to execute as early as possible in the browser -// during SSR. Additionally, we also call it from within the router lifecycle -export function restoreScroll({ - storageKey, - key, - behavior, - shouldScrollRestoration, - scrollToTopSelectors, - location, -}: { - storageKey: string - key?: string - behavior?: ScrollToOptions['behavior'] - shouldScrollRestoration?: boolean - scrollToTopSelectors?: Array Element | null | undefined)> - location?: HistoryLocation -}) { - let byKey: ScrollRestorationByKey - - try { - byKey = JSON.parse(sessionStorage.getItem(storageKey) || '{}') - } catch (error) { - console.error(error) - return - } - - const resolvedKey = key || window.history.state?.__TSR_key - const elementEntries = byKey[resolvedKey] - - // - ignoreScroll = true - - // - scroll: { - // If we have a cached entry for this location state, - // we always need to prefer that over the hash scroll. - if ( - shouldScrollRestoration && - elementEntries && - Object.keys(elementEntries).length > 0 - ) { - for (const elementSelector in elementEntries) { - const entry = elementEntries[elementSelector]! - if (elementSelector === 'window') { - window.scrollTo({ - top: entry.scrollY, - left: entry.scrollX, - behavior, - }) - } else if (elementSelector) { - const element = document.querySelector(elementSelector) - if (element) { - element.scrollLeft = entry.scrollX - element.scrollTop = entry.scrollY - } - } +export function getElementScrollRestorationEntry( + router: AnyRouter, + options: ( + | { + id: string + getElement?: () => Window | Element | undefined | null } - - break scroll - } - - // If we don't have a cached entry for the hash, - // Which means we've never seen this location before, - // we need to check if there is a hash in the URL. - // If there is, we need to scroll it's ID into view. - const hash = (location ?? window.location).hash.split('#', 2)[1] - - if (hash) { - const hashScrollIntoViewOptions = - window.history.state?.__hashScrollIntoViewOptions ?? true - - if (hashScrollIntoViewOptions) { - const el = document.getElementById(hash) - if (el) { - el.scrollIntoView(hashScrollIntoViewOptions) - } + | { + id?: string + getElement: () => Window | Element | undefined | null } + ) & { + getKey?: (location: ParsedLocation) => string + }, +): ScrollRestorationEntry | undefined { + const getKey = options.getKey || defaultGetScrollRestorationKey + const restoreKey = getKey(router.latestLocation) + + if (options.id) { + return scrollRestorationCache?.state[restoreKey]?.[ + `[${scrollRestorationIdAttribute}="${options.id}"]` + ] + } - break scroll - } - - // If there is no cached entry for the hash and there is no hash in the URL, - // we need to scroll to the top of the page for every scrollToTop element - const scrollOptions = { top: 0, left: 0, behavior } - window.scrollTo(scrollOptions) - if (scrollToTopSelectors) { - for (const selector of scrollToTopSelectors) { - if (selector === 'window') continue - const element = - typeof selector === 'function' - ? selector() - : document.querySelector(selector) - if (element) element.scrollTo(scrollOptions) - } - } + const element = options.getElement?.() + if (!element) { + return } - // - ignoreScroll = false + return scrollRestorationCache?.state[restoreKey]?.[ + element instanceof Window ? windowScrollTarget : getCssSelector(element) + ] } -/** Setup global listeners and hooks to support scroll restoration. */ -/** Setup global listeners and hooks to support scroll restoration. */ +let ignoreScroll = false +const windowScrollTarget = 'window' +const scrollRestorationIdAttribute = 'data-scroll-restoration-id' +type ScrollTarget = typeof windowScrollTarget | Element + export function setupScrollRestoration(router: AnyRouter, force?: boolean) { if (!scrollRestorationCache && !(isServer ?? router.isServer)) { return } + + const cache = scrollRestorationCache + const shouldScrollRestoration = force ?? router.options.scrollRestoration ?? false @@ -231,173 +156,220 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) { if ( (isServer ?? router.isServer) || router.isScrollRestorationSetup || - !scrollRestorationCache + !cache ) { return } router.isScrollRestorationSetup = true - - // ignoreScroll = false const getKey = router.options.getScrollRestorationKey || defaultGetScrollRestorationKey + const trackedScrollEntries = new Map() window.history.scrollRestoration = 'manual' - // // Create a MutationObserver to monitor DOM changes - // const mutationObserver = new MutationObserver(() => { - // ;ignoreScroll = true - // requestAnimationFrame(() => { - // ;ignoreScroll = false - - // // Attempt to restore scroll position on each dom - // // mutation until the user scrolls. We do this - // // because dynamic content may come in at different - // // ticks after the initial render and we want to - // // keep up with that content as much as possible. - // // As soon as the user scrolls, we no longer need - // // to attempt router. - // // console.log('mutation observer restoreScroll') - // restoreScroll( - // storageKey, - // getKey(router.stores.location.state), - // router.options.scrollRestorationBehavior, - // ) - // }) - // }) - - // const observeDom = () => { - // // Observe changes to the entire document - // mutationObserver.observe(document, { - // childList: true, // Detect added or removed child nodes - // subtree: true, // Monitor all descendants - // characterData: true, // Detect text content changes - // }) - // } - - // const unobserveDom = () => { - // mutationObserver.disconnect() - // } - - // observeDom() - const onScroll = (event: Event) => { - // unobserveDom() - if (ignoreScroll || !router.isScrollRestoring) { return } - let elementSelector = '' - if (event.target === document || event.target === window) { - elementSelector = 'window' + trackedScrollEntries.set(windowScrollTarget, { + scrollX: window.scrollX || 0, + scrollY: window.scrollY || 0, + }) } else { - const attrId = (event.target as Element).getAttribute( - 'data-scroll-restoration-id', - ) - - if (attrId) { - elementSelector = `[data-scroll-restoration-id="${attrId}"]` - } else { - elementSelector = getCssSelector(event.target) - } + const target = event.target as Element + trackedScrollEntries.set(target, { + scrollX: target.scrollLeft || 0, + scrollY: target.scrollTop || 0, + }) } + } - const restoreKey = getKey(router.stores.location.state) + // Snapshot the current page's tracked scroll targets before navigation or unload. + const snapshotCurrentScrollTargets = (restoreKey?: string) => { + if ( + !router.isScrollRestoring || + !restoreKey || + trackedScrollEntries.size === 0 || + !cache + ) { + return + } - scrollRestorationCache.set((state) => { - const keyEntry = (state[restoreKey] ||= {} as ScrollRestorationByElement) + const keyEntry = (cache.state[restoreKey] ||= + {} as ScrollRestorationByElement) - const elementEntry = (keyEntry[elementSelector] ||= - {} as ScrollRestorationEntry) + for (const [target, position] of trackedScrollEntries) { + let selector: string | undefined - if (elementSelector === 'window') { - elementEntry.scrollX = window.scrollX || 0 - elementEntry.scrollY = window.scrollY || 0 - } else if (elementSelector) { - const element = document.querySelector(elementSelector) - if (element) { - elementEntry.scrollX = element.scrollLeft || 0 - elementEntry.scrollY = element.scrollTop || 0 - } + if (target === windowScrollTarget) { + selector = windowScrollTarget + } else if (target.isConnected) { + const attrId = target.getAttribute(scrollRestorationIdAttribute) + selector = attrId + ? `[${scrollRestorationIdAttribute}="${attrId}"]` + : getCssSelector(target) } - return state - }) - } + if (!selector) { + continue + } - // Throttle the scroll event to avoid excessive updates - if (typeof document !== 'undefined') { - document.addEventListener('scroll', throttle(onScroll, 100), true) + keyEntry[selector] = position + } } - router.subscribe('onRendered', (event) => { - // unobserveDom() + document.addEventListener('scroll', onScroll, true) + router.subscribe('onBeforeLoad', (event) => { + snapshotCurrentScrollTargets( + event.fromLocation ? getKey(event.fromLocation) : undefined, + ) + trackedScrollEntries.clear() + }) + window.addEventListener('pagehide', () => { + snapshotCurrentScrollTargets( + getKey( + router.stores.resolvedLocation.state ?? router.stores.location.state, + ), + ) + cache.persist() + }) + // Restore destination scroll after the new route has rendered. + router.subscribe('onRendered', (event) => { const cacheKey = getKey(event.toLocation) + const behavior = router.options.scrollRestorationBehavior + const scrollToTopSelectors = router.options.scrollToTopSelectors + trackedScrollEntries.clear() - // If the user doesn't want to restore the scroll position, - // we don't need to do anything. if (!router.resetNextScroll) { router.resetNextScroll = true return } - if (typeof router.options.scrollRestoration === 'function') { - const shouldRestore = router.options.scrollRestoration({ - location: router.latestLocation, + + if ( + typeof router.options.scrollRestoration === 'function' && + !router.options.scrollRestoration({ location: router.latestLocation }) + ) { + return + } + + const fromIndex = event.fromLocation?.state.__TSR_index + const toIndex = event.toLocation.state.__TSR_index + // Clear on forward navigations, and on same-entry replace navigations where + // the href changed. Preserve back/restore entries so they can be restored. + const shouldClearCache = + typeof fromIndex === 'number' && typeof toIndex === 'number' + ? toIndex > fromIndex || + (toIndex === fromIndex && + event.fromLocation?.href !== event.toLocation.href) + : true + + if (shouldClearCache) { + cache.set((state) => { + delete state[cacheKey] + return state }) - if (!shouldRestore) { - return - } } - restoreScroll({ - storageKey, - key: cacheKey, - behavior: router.options.scrollRestorationBehavior, - shouldScrollRestoration: router.isScrollRestoring, - scrollToTopSelectors: router.options.scrollToTopSelectors, - location: router.history.location, - }) + ignoreScroll = true + + try { + const elementEntries = router.isScrollRestoring + ? cache.state[cacheKey] + : undefined + let restored = false + + if (elementEntries) { + for (const elementSelector in elementEntries) { + const entry = elementEntries[elementSelector] + + if (!isPlainObject(entry)) { + continue + } + + const { scrollX, scrollY } = entry as { + scrollX?: unknown + scrollY?: unknown + } + + if (!Number.isFinite(scrollX) || !Number.isFinite(scrollY)) { + continue + } + + if (elementSelector === windowScrollTarget) { + window.scrollTo({ + top: scrollY as number, + left: scrollX as number, + behavior, + }) + restored = true + } else if (elementSelector) { + let element + + try { + element = document.querySelector(elementSelector) + } catch { + continue + } + + if (element) { + element.scrollLeft = scrollX as number + element.scrollTop = scrollY as number + restored = true + } + } + } + } + + if (!restored) { + const hash = router.history.location.hash.slice(1) + + if (hash) { + const hashScrollIntoViewOptions = + window.history.state?.__hashScrollIntoViewOptions ?? true + + if (hashScrollIntoViewOptions) { + const el = document.getElementById(hash) + if (el) { + el.scrollIntoView(hashScrollIntoViewOptions) + } + } + } else { + const scrollOptions = { + top: 0, + left: 0, + behavior, + } + + window.scrollTo(scrollOptions) + if (scrollToTopSelectors) { + for (const selector of scrollToTopSelectors) { + if (selector === windowScrollTarget) continue + const element = + typeof selector === 'function' + ? selector() + : document.querySelector(selector) + if (element) { + element.scrollTo(scrollOptions) + } + } + } + } + } + } finally { + ignoreScroll = false + } if (router.isScrollRestoring) { - // Mark the location as having been seen - scrollRestorationCache.set((state) => { + cache.set((state) => { state[cacheKey] ||= {} as ScrollRestorationByElement - return state }) } }) } - -/** - * @private - * Handles hash-based scrolling after navigation completes. - * To be used in framework-specific components during the onResolved event. - * - * Provides hash scrolling for programmatic navigation when default browser handling is prevented. - * @param router The router instance containing current location and state - */ -/** - * @private - * Handles hash-based scrolling after navigation completes. - * To be used in framework-specific Transitioners. - */ -export function handleHashScroll(router: AnyRouter) { - if (typeof document !== 'undefined' && (document as any).querySelector) { - const location = router.stores.location.state - const hashScrollIntoViewOptions = - location.state.__hashScrollIntoViewOptions ?? true - - if (hashScrollIntoViewOptions && location.hash !== '') { - const el = document.getElementById(location.hash) - if (el) { - el.scrollIntoView(hashScrollIntoViewOptions) - } - } - } -} diff --git a/packages/router-core/vite-minify-plugin.ts b/packages/router-core/vite-minify-plugin.ts index e379f16ea45..adeb8fb5c2b 100644 --- a/packages/router-core/vite-minify-plugin.ts +++ b/packages/router-core/vite-minify-plugin.ts @@ -14,8 +14,16 @@ export default function minifyScriptPlugin(): Plugin { target: 'esnext', }) + // Source files may use `export default …`. esbuild preserves that, + // but we need a bare expression so it can be embedded in inline + // scripts (e.g. as an IIFE: `(function(…){…})(args)`). + // Strip `export default` and any trailing semicolon/whitespace. + const normalizedCode = result.code + .replace(/^export default /, '') + .replace(/;?\s*$/, '') + return { - code: `export default ${JSON.stringify(result.code)};`, + code: `export default ${JSON.stringify(normalizedCode)};`, map: null, } }, diff --git a/packages/router-core/vite.config.ts b/packages/router-core/vite.config.ts index 01d7548776e..816b0602b87 100644 --- a/packages/router-core/vite.config.ts +++ b/packages/router-core/vite.config.ts @@ -31,6 +31,8 @@ export default mergeConfig( './src/index.ts', './src/ssr/client.ts', './src/ssr/server.ts', + './src/scroll-restoration-script/client.ts', + './src/scroll-restoration-script/server.ts', './src/isServer/server.ts', './src/isServer/client.ts', './src/isServer/development.ts', diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx index 2f2320360df..9274c86638b 100644 --- a/packages/solid-router/src/Match.tsx +++ b/packages/solid-router/src/Match.tsx @@ -170,7 +170,10 @@ export const Match = (props: { matchId: string }) => { {currentMatchState().parentRouteId === rootRouteId ? ( <> - + {router.options.scrollRestoration && + (isServer ?? router.isServer) ? ( + + ) : null} ) : null} diff --git a/packages/solid-router/src/ScrollRestoration.tsx b/packages/solid-router/src/ScrollRestoration.tsx index 71d472723de..068243a9feb 100644 --- a/packages/solid-router/src/ScrollRestoration.tsx +++ b/packages/solid-router/src/ScrollRestoration.tsx @@ -1,7 +1,5 @@ import { - defaultGetScrollRestorationKey, - getCssSelector, - scrollRestorationCache, + getElementScrollRestorationEntry, setupScrollRestoration, } from '@tanstack/router-core' import { useRouter } from './useRouter' @@ -47,23 +45,5 @@ export function useElementScrollRestoration( ): ScrollRestorationEntry | undefined { useScrollRestoration() - const router = useRouter() - const getKey = options.getKey || defaultGetScrollRestorationKey - - let elementSelector = '' - - if (options.id) { - elementSelector = `[data-scroll-restoration-id="${options.id}"]` - } else { - const element = options.getElement?.() - if (!element) { - return - } - elementSelector = - element instanceof Window ? 'window' : getCssSelector(element) - } - - const restoreKey = getKey(router.latestLocation) - const byKey = scrollRestorationCache?.state[restoreKey] - return byKey?.[elementSelector] + return getElementScrollRestorationEntry(useRouter(), options) } diff --git a/packages/solid-router/src/scroll-restoration.tsx b/packages/solid-router/src/scroll-restoration.tsx index f8c3c4559a8..5c49425e777 100644 --- a/packages/solid-router/src/scroll-restoration.tsx +++ b/packages/solid-router/src/scroll-restoration.tsx @@ -1,45 +1,14 @@ -import { - defaultGetScrollRestorationKey, - escapeHtml, - restoreScroll, - storageKey, -} from '@tanstack/router-core' -import { isServer } from '@tanstack/router-core/isServer' +import { getScrollRestorationScriptForRouter } from '@tanstack/router-core/scroll-restoration-script' import { useRouter } from './useRouter' import { ScriptOnce } from './ScriptOnce' export function ScrollRestoration() { const router = useRouter() - if (!router.isScrollRestoring || !(isServer ?? router.isServer)) { - return null - } - if (typeof router.options.scrollRestoration === 'function') { - const shouldRestore = router.options.scrollRestoration({ - location: router.latestLocation, - }) - if (!shouldRestore) { - return null - } - } - const getKey = - router.options.getScrollRestorationKey || defaultGetScrollRestorationKey - const userKey = getKey(router.latestLocation) - const resolvedKey = - userKey !== defaultGetScrollRestorationKey(router.latestLocation) - ? userKey - : undefined + const script = getScrollRestorationScriptForRouter(router) - const restoreScrollOptions: Parameters[0] = { - storageKey, - shouldScrollRestoration: true, - } - if (resolvedKey) { - restoreScrollOptions.key = resolvedKey + if (!script) { + return null } - return ( - - ) + return } diff --git a/packages/vue-router/src/Match.tsx b/packages/vue-router/src/Match.tsx index 104ec3a2908..380ff00843e 100644 --- a/packages/vue-router/src/Match.tsx +++ b/packages/vue-router/src/Match.tsx @@ -203,10 +203,13 @@ export const Match = Vue.defineComponent({ // Add scroll restoration if needed const withScrollRestoration: Array = [ content, - isChildOfRoot && router.options.scrollRestoration + isChildOfRoot ? Vue.h(Vue.Fragment, null, [ Vue.h(OnRendered), - Vue.h(ScrollRestoration), + router.options.scrollRestoration && + (isServer ?? router.isServer) + ? Vue.h(ScrollRestoration) + : null, ]) : null, ].filter(Boolean) as Array diff --git a/packages/vue-router/src/ScrollRestoration.tsx b/packages/vue-router/src/ScrollRestoration.tsx index 71d472723de..068243a9feb 100644 --- a/packages/vue-router/src/ScrollRestoration.tsx +++ b/packages/vue-router/src/ScrollRestoration.tsx @@ -1,7 +1,5 @@ import { - defaultGetScrollRestorationKey, - getCssSelector, - scrollRestorationCache, + getElementScrollRestorationEntry, setupScrollRestoration, } from '@tanstack/router-core' import { useRouter } from './useRouter' @@ -47,23 +45,5 @@ export function useElementScrollRestoration( ): ScrollRestorationEntry | undefined { useScrollRestoration() - const router = useRouter() - const getKey = options.getKey || defaultGetScrollRestorationKey - - let elementSelector = '' - - if (options.id) { - elementSelector = `[data-scroll-restoration-id="${options.id}"]` - } else { - const element = options.getElement?.() - if (!element) { - return - } - elementSelector = - element instanceof Window ? 'window' : getCssSelector(element) - } - - const restoreKey = getKey(router.latestLocation) - const byKey = scrollRestorationCache?.state[restoreKey] - return byKey?.[elementSelector] + return getElementScrollRestorationEntry(useRouter(), options) } diff --git a/packages/vue-router/src/scroll-restoration.tsx b/packages/vue-router/src/scroll-restoration.tsx index 156bc0a93e1..86b36063a42 100644 --- a/packages/vue-router/src/scroll-restoration.tsx +++ b/packages/vue-router/src/scroll-restoration.tsx @@ -1,79 +1,21 @@ import * as Vue from 'vue' -import { - defaultGetScrollRestorationKey, - escapeHtml, - restoreScroll, - storageKey, -} from '@tanstack/router-core' -import { isServer } from '@tanstack/router-core/isServer' +import { getScrollRestorationScriptForRouter } from '@tanstack/router-core/scroll-restoration-script' import { useRouter } from './useRouter' import { ScriptOnce } from './ScriptOnce' -/** - * ScrollRestoration component for Vue. - * On server: renders a ScriptOnce with scroll restoration logic. - * On client during hydration: renders a matching ScriptOnce to avoid mismatch. - * After mount: renders nothing. - */ export const ScrollRestoration = Vue.defineComponent({ name: 'ScrollRestoration', setup() { const router = useRouter() - // Track mounted state for hydration handling - const mounted = Vue.ref(false) - Vue.onMounted(() => { - mounted.value = true - }) - return () => { - // After mount, render nothing - if (mounted.value) { - return null - } - - // Check if scroll restoration is enabled - if (!router.isScrollRestoring) { - return null - } - - // Check custom scroll restoration function - if (typeof router.options.scrollRestoration === 'function') { - const shouldRestore = router.options.scrollRestoration({ - location: router.latestLocation, - }) - if (!shouldRestore) { - return null - } - } - - const getKey = - router.options.getScrollRestorationKey || defaultGetScrollRestorationKey - const userKey = getKey(router.latestLocation) - const resolvedKey = - userKey !== defaultGetScrollRestorationKey(router.latestLocation) - ? userKey - : undefined - - const restoreScrollOptions: Parameters[0] = { - storageKey, - shouldScrollRestoration: true, - } - if (resolvedKey) { - restoreScrollOptions.key = resolvedKey - } + const script = getScrollRestorationScriptForRouter(router) - // Server-side: render the actual scroll restoration script - if (isServer ?? router.isServer) { - return ( - - ) + if (script) { + return } - // Client-side during hydration: render empty ScriptOnce to match server structure - return + return null } }, })