From b18d5ba270cfcb32f49d8193f5500f91b2167016 Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:44:03 +0200 Subject: [PATCH] fix(router-core): handle window and element scroll restoration independently Window and element scroll targets are now handled independently. Restoring one target no longer suppresses resets for other uncached configured targets, and a restored element is no longer reset when the window has no cached position. Hash navigation no longer resets elements configured through `scrollToTopSelectors` and retains precedence over stale window positions through destination invalidations. Scroll positions are sampled when leaving a route, preserving live changes made after the most recent scroll event. This also prevents client hydration from undoing nested positions restored by the SSR script. Fixes #7687. --- .changeset/fix-independent-scroll-targets.md | 13 + .../scroll-restoration/src/routeTree.gen.ts | 71 +++++ .../scroll-restoration/src/router.tsx | 21 +- .../src/routes/(tests)/hash-scroll-about.tsx | 16 +- .../src/routes/(tests)/hash-scroll-repro.tsx | 46 ++++ .../src/routes/(tests)/issue-7687.detail.tsx | 16 ++ .../src/routes/(tests)/issue-7687.index.tsx | 46 ++++ .../src/routes/(tests)/issue-7687.tsx | 49 ++++ .../src/routes/(tests)/ssr-scroll-key.tsx | 17 ++ .../tests/hash-scroll-repro.spec.ts | 119 +++++++-- .../tests/issue-7687.spec.ts | 242 +++++++++++++++++ .../tests/ssr-scroll-key.spec.ts | 74 ++++++ packages/router-core/src/router.ts | 12 +- .../router-core/src/scroll-restoration.ts | 96 +++---- .../tests/scroll-restoration.test.ts | 249 +++++++++++++++++- 15 files changed, 997 insertions(+), 90 deletions(-) create mode 100644 .changeset/fix-independent-scroll-targets.md create mode 100644 e2e/react-start/scroll-restoration/src/routes/(tests)/issue-7687.detail.tsx create mode 100644 e2e/react-start/scroll-restoration/src/routes/(tests)/issue-7687.index.tsx create mode 100644 e2e/react-start/scroll-restoration/src/routes/(tests)/issue-7687.tsx create mode 100644 e2e/react-start/scroll-restoration/tests/issue-7687.spec.ts diff --git a/.changeset/fix-independent-scroll-targets.md b/.changeset/fix-independent-scroll-targets.md new file mode 100644 index 0000000000..d47294ee3e --- /dev/null +++ b/.changeset/fix-independent-scroll-targets.md @@ -0,0 +1,13 @@ +--- +'@tanstack/router-core': patch +--- + +fix(router-core): handle window and element scroll restoration independently + +Window and element scroll targets are now handled independently. Restoring one target no longer suppresses resets for other uncached configured targets, and a restored element is no longer reset when the window has no cached position. + +Hash navigation no longer resets elements configured through `scrollToTopSelectors` and retains precedence over stale window positions through destination invalidations. + +Scroll positions are sampled when leaving a route, preserving live changes made after the most recent scroll event. This also prevents client hydration from undoing nested positions restored by the SSR script. + +Fixes #7687. diff --git a/e2e/react-start/scroll-restoration/src/routeTree.gen.ts b/e2e/react-start/scroll-restoration/src/routeTree.gen.ts index 1df27a0424..7acedcb1d7 100644 --- a/e2e/react-start/scroll-restoration/src/routeTree.gen.ts +++ b/e2e/react-start/scroll-restoration/src/routeTree.gen.ts @@ -21,8 +21,11 @@ import { Route as testsNestedScrollSearchRouteImport } from './routes/(tests)/ne import { Route as testsNestedScrollCarryOverBRouteImport } from './routes/(tests)/nested-scroll-carry-over-b' import { Route as testsNestedScrollCarryOverARouteImport } from './routes/(tests)/nested-scroll-carry-over-a' import { Route as testsNestedScrollAwayRouteImport } from './routes/(tests)/nested-scroll-away' +import { Route as testsIssue7687RouteImport } from './routes/(tests)/issue-7687' import { Route as testsHashScrollReproRouteImport } from './routes/(tests)/hash-scroll-repro' import { Route as testsHashScrollAboutRouteImport } from './routes/(tests)/hash-scroll-about' +import { Route as testsIssue7687IndexRouteImport } from './routes/(tests)/issue-7687.index' +import { Route as testsIssue7687DetailRouteImport } from './routes/(tests)/issue-7687.detail' const IndexRoute = IndexRouteImport.update({ id: '/', @@ -86,6 +89,11 @@ const testsNestedScrollAwayRoute = testsNestedScrollAwayRouteImport.update({ path: '/nested-scroll-away', getParentRoute: () => rootRouteImport, } as any) +const testsIssue7687Route = testsIssue7687RouteImport.update({ + id: '/(tests)/issue-7687', + path: '/issue-7687', + getParentRoute: () => rootRouteImport, +} as any) const testsHashScrollReproRoute = testsHashScrollReproRouteImport.update({ id: '/(tests)/hash-scroll-repro', path: '/hash-scroll-repro', @@ -96,11 +104,22 @@ const testsHashScrollAboutRoute = testsHashScrollAboutRouteImport.update({ path: '/hash-scroll-about', getParentRoute: () => rootRouteImport, } as any) +const testsIssue7687IndexRoute = testsIssue7687IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => testsIssue7687Route, +} as any) +const testsIssue7687DetailRoute = testsIssue7687DetailRouteImport.update({ + id: '/detail', + path: '/detail', + getParentRoute: () => testsIssue7687Route, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/hash-scroll-about': typeof testsHashScrollAboutRoute '/hash-scroll-repro': typeof testsHashScrollReproRoute + '/issue-7687': typeof testsIssue7687RouteWithChildren '/nested-scroll-away': typeof testsNestedScrollAwayRoute '/nested-scroll-carry-over-a': typeof testsNestedScrollCarryOverARoute '/nested-scroll-carry-over-b': typeof testsNestedScrollCarryOverBRoute @@ -112,6 +131,8 @@ export interface FileRoutesByFullPath { '/ssr-scroll-key': typeof testsSsrScrollKeyRoute '/with-loader': typeof testsWithLoaderRoute '/with-search': typeof testsWithSearchRoute + '/issue-7687/detail': typeof testsIssue7687DetailRoute + '/issue-7687/': typeof testsIssue7687IndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -128,12 +149,15 @@ export interface FileRoutesByTo { '/ssr-scroll-key': typeof testsSsrScrollKeyRoute '/with-loader': typeof testsWithLoaderRoute '/with-search': typeof testsWithSearchRoute + '/issue-7687/detail': typeof testsIssue7687DetailRoute + '/issue-7687': typeof testsIssue7687IndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/(tests)/hash-scroll-about': typeof testsHashScrollAboutRoute '/(tests)/hash-scroll-repro': typeof testsHashScrollReproRoute + '/(tests)/issue-7687': typeof testsIssue7687RouteWithChildren '/(tests)/nested-scroll-away': typeof testsNestedScrollAwayRoute '/(tests)/nested-scroll-carry-over-a': typeof testsNestedScrollCarryOverARoute '/(tests)/nested-scroll-carry-over-b': typeof testsNestedScrollCarryOverBRoute @@ -145,6 +169,8 @@ export interface FileRoutesById { '/(tests)/ssr-scroll-key': typeof testsSsrScrollKeyRoute '/(tests)/with-loader': typeof testsWithLoaderRoute '/(tests)/with-search': typeof testsWithSearchRoute + '/(tests)/issue-7687/detail': typeof testsIssue7687DetailRoute + '/(tests)/issue-7687/': typeof testsIssue7687IndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -152,6 +178,7 @@ export interface FileRouteTypes { | '/' | '/hash-scroll-about' | '/hash-scroll-repro' + | '/issue-7687' | '/nested-scroll-away' | '/nested-scroll-carry-over-a' | '/nested-scroll-carry-over-b' @@ -163,6 +190,8 @@ export interface FileRouteTypes { | '/ssr-scroll-key' | '/with-loader' | '/with-search' + | '/issue-7687/detail' + | '/issue-7687/' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -179,11 +208,14 @@ export interface FileRouteTypes { | '/ssr-scroll-key' | '/with-loader' | '/with-search' + | '/issue-7687/detail' + | '/issue-7687' id: | '__root__' | '/' | '/(tests)/hash-scroll-about' | '/(tests)/hash-scroll-repro' + | '/(tests)/issue-7687' | '/(tests)/nested-scroll-away' | '/(tests)/nested-scroll-carry-over-a' | '/(tests)/nested-scroll-carry-over-b' @@ -195,12 +227,15 @@ export interface FileRouteTypes { | '/(tests)/ssr-scroll-key' | '/(tests)/with-loader' | '/(tests)/with-search' + | '/(tests)/issue-7687/detail' + | '/(tests)/issue-7687/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute testsHashScrollAboutRoute: typeof testsHashScrollAboutRoute testsHashScrollReproRoute: typeof testsHashScrollReproRoute + testsIssue7687Route: typeof testsIssue7687RouteWithChildren testsNestedScrollAwayRoute: typeof testsNestedScrollAwayRoute testsNestedScrollCarryOverARoute: typeof testsNestedScrollCarryOverARoute testsNestedScrollCarryOverBRoute: typeof testsNestedScrollCarryOverBRoute @@ -300,6 +335,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof testsNestedScrollAwayRouteImport parentRoute: typeof rootRouteImport } + '/(tests)/issue-7687': { + id: '/(tests)/issue-7687' + path: '/issue-7687' + fullPath: '/issue-7687' + preLoaderRoute: typeof testsIssue7687RouteImport + parentRoute: typeof rootRouteImport + } '/(tests)/hash-scroll-repro': { id: '/(tests)/hash-scroll-repro' path: '/hash-scroll-repro' @@ -314,13 +356,42 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof testsHashScrollAboutRouteImport parentRoute: typeof rootRouteImport } + '/(tests)/issue-7687/': { + id: '/(tests)/issue-7687/' + path: '/' + fullPath: '/issue-7687/' + preLoaderRoute: typeof testsIssue7687IndexRouteImport + parentRoute: typeof testsIssue7687Route + } + '/(tests)/issue-7687/detail': { + id: '/(tests)/issue-7687/detail' + path: '/detail' + fullPath: '/issue-7687/detail' + preLoaderRoute: typeof testsIssue7687DetailRouteImport + parentRoute: typeof testsIssue7687Route + } } } +interface testsIssue7687RouteChildren { + testsIssue7687DetailRoute: typeof testsIssue7687DetailRoute + testsIssue7687IndexRoute: typeof testsIssue7687IndexRoute +} + +const testsIssue7687RouteChildren: testsIssue7687RouteChildren = { + testsIssue7687DetailRoute: testsIssue7687DetailRoute, + testsIssue7687IndexRoute: testsIssue7687IndexRoute, +} + +const testsIssue7687RouteWithChildren = testsIssue7687Route._addFileChildren( + testsIssue7687RouteChildren, +) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, testsHashScrollAboutRoute: testsHashScrollAboutRoute, testsHashScrollReproRoute: testsHashScrollReproRoute, + testsIssue7687Route: testsIssue7687RouteWithChildren, testsNestedScrollAwayRoute: testsNestedScrollAwayRoute, testsNestedScrollCarryOverARoute: testsNestedScrollCarryOverARoute, testsNestedScrollCarryOverBRoute: testsNestedScrollCarryOverBRoute, diff --git a/e2e/react-start/scroll-restoration/src/router.tsx b/e2e/react-start/scroll-restoration/src/router.tsx index eb81f5bfa9..27d68369db 100644 --- a/e2e/react-start/scroll-restoration/src/router.tsx +++ b/e2e/react-start/scroll-restoration/src/router.tsx @@ -7,13 +7,32 @@ export function getRouter() { const router = createRouter({ routeTree, scrollRestoration: true, - scrollToTopSelectors: ['[data-scroll-restoration-id="carry-over-reset"]'], + scrollToTopSelectors: [ + '[data-scroll-restoration-id="carry-over-reset"]', + '#hash-scroll-nested', + '#hash-scroll-reset-target', + '#issue-7687-reset-probe', + '#issue-7687-scroller', + () => + typeof document === 'undefined' + ? undefined + : document.getElementById('issue-7687-stale-selector'), + '#ssr-scroll-key-nested', + ], getScrollRestorationKey: (location) => { if (location.pathname === '/ssr-scroll-key') { return 'ssr-scroll-key' } if (location.pathname === '/hash-scroll-repro') { + const scrollKey = + 'scrollKey' in location.search ? location.search.scrollKey : undefined + return typeof scrollKey === 'string' + ? `${location.pathname}:${scrollKey}` + : location.pathname + } + + if (location.pathname.startsWith('/issue-7687')) { return location.pathname } diff --git a/e2e/react-start/scroll-restoration/src/routes/(tests)/hash-scroll-about.tsx b/e2e/react-start/scroll-restoration/src/routes/(tests)/hash-scroll-about.tsx index 2c2748e6a5..1ee24f4b1f 100644 --- a/e2e/react-start/scroll-restoration/src/routes/(tests)/hash-scroll-about.tsx +++ b/e2e/react-start/scroll-restoration/src/routes/(tests)/hash-scroll-about.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { createFileRoute } from '@tanstack/react-router' +import { Link, createFileRoute } from '@tanstack/react-router' import { sleep } from '~/utils/posts' export const Route = createFileRoute('/(tests)/hash-scroll-about')({ @@ -17,6 +17,20 @@ function Component() {

This route exists so hover preloading can reproduce the hash scroll bug.

+ + Return to #one + + + Return to the destination key + ) } diff --git a/e2e/react-start/scroll-restoration/src/routes/(tests)/hash-scroll-repro.tsx b/e2e/react-start/scroll-restoration/src/routes/(tests)/hash-scroll-repro.tsx index 1017b63bc3..c2c681247a 100644 --- a/e2e/react-start/scroll-restoration/src/routes/(tests)/hash-scroll-repro.tsx +++ b/e2e/react-start/scroll-restoration/src/routes/(tests)/hash-scroll-repro.tsx @@ -1,8 +1,13 @@ import * as React from 'react' import { Link, createFileRoute, useRouter } from '@tanstack/react-router' +import { z } from 'zod' import { sleep } from '~/utils/posts' export const Route = createFileRoute('/(tests)/hash-scroll-repro')({ + validateSearch: z.object({ + scrollKey: z.string().optional(), + invalidateOnMount: z.boolean().optional(), + }), loader: async () => { await sleep(50) return null @@ -14,7 +19,20 @@ const sectionIds = ['one', 'two', 'three', 'four', 'five'] as const function Component() { const router = useRouter() + const { invalidateOnMount } = Route.useSearch() const [invalidateCount, setInvalidateCount] = React.useState(0) + const invalidatedOnMount = React.useRef(false) + + React.useLayoutEffect(() => { + if (!invalidateOnMount || invalidatedOnMount.current) { + return + } + + invalidatedOnMount.current = true + void router.invalidate().then(() => { + setInvalidateCount((count) => count + 1) + }) + }, [invalidateOnMount, router]) return (
@@ -61,6 +79,24 @@ function Component() { > #four no reset + + #one different key + + + #one and invalidate in layout effect +
@@ -74,6 +110,16 @@ function Component() { ))} +
+ {Array.from({ length: 20 }).map((_, i) => ( +
Hash reset target row {i}
+ ))} +
+
{sectionIds.map((sectionId) => (
+

issue-7687-detail

+ {Array.from({ length: 40 }).map((_, i) => ( +
Detail row {i}
+ ))} +
+ ) +} diff --git a/e2e/react-start/scroll-restoration/src/routes/(tests)/issue-7687.index.tsx b/e2e/react-start/scroll-restoration/src/routes/(tests)/issue-7687.index.tsx new file mode 100644 index 0000000000..82450e43f3 --- /dev/null +++ b/e2e/react-start/scroll-restoration/src/routes/(tests)/issue-7687.index.tsx @@ -0,0 +1,46 @@ +import * as React from 'react' +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/(tests)/issue-7687/')({ + component: Component, +}) + +const visitKey = 'issue-7687-list-visits' + +function ResetProbe() { + const ref = React.useRef(null) + + React.useLayoutEffect(() => { + const visits = Number(window.sessionStorage.getItem(visitKey) ?? 0) + window.sessionStorage.setItem(visitKey, String(visits + 1)) + + if (visits > 0 && ref.current) { + ref.current.scrollTop = 80 + } + }, []) + + return ( +
+ {Array.from({ length: 20 }).map((_, i) => ( +
Reset probe row {i}
+ ))} +
+ ) +} + +function Component() { + return ( +
+

issue-7687-list

+ + {Array.from({ length: 40 }).map((_, i) => ( +
List row {i}
+ ))} +
+ ) +} diff --git a/e2e/react-start/scroll-restoration/src/routes/(tests)/issue-7687.tsx b/e2e/react-start/scroll-restoration/src/routes/(tests)/issue-7687.tsx new file mode 100644 index 0000000000..f7091cd829 --- /dev/null +++ b/e2e/react-start/scroll-restoration/src/routes/(tests)/issue-7687.tsx @@ -0,0 +1,49 @@ +import { Link, Outlet, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/(tests)/issue-7687')({ + component: Component, +}) + +function Component() { + return ( +
+ + +
+ +
+ +
+ {Array.from({ length: 20 }).map((_, i) => ( +
Stale selector row {i}
+ ))} +
+ +
+
+ ) +} diff --git a/e2e/react-start/scroll-restoration/src/routes/(tests)/ssr-scroll-key.tsx b/e2e/react-start/scroll-restoration/src/routes/(tests)/ssr-scroll-key.tsx index e224ed9137..dd7375660b 100644 --- a/e2e/react-start/scroll-restoration/src/routes/(tests)/ssr-scroll-key.tsx +++ b/e2e/react-start/scroll-restoration/src/routes/(tests)/ssr-scroll-key.tsx @@ -7,9 +7,26 @@ export const Route = createFileRoute('/(tests)/ssr-scroll-key')({ }) function Component() { + const [hydrated, setHydrated] = React.useState(false) + + React.useEffect(() => { + setHydrated(true) + }, []) + return (

ssr-scroll-key

+ {hydrated ? : null} +
+ {Array.from({ length: 20 }).map((_, i) => ( +
Nested SSR row {i}
+ ))} +
) diff --git a/e2e/react-start/scroll-restoration/tests/hash-scroll-repro.spec.ts b/e2e/react-start/scroll-restoration/tests/hash-scroll-repro.spec.ts index d203ea8c26..7a6aa72161 100644 --- a/e2e/react-start/scroll-restoration/tests/hash-scroll-repro.spec.ts +++ b/e2e/react-start/scroll-restoration/tests/hash-scroll-repro.spec.ts @@ -103,43 +103,59 @@ test('hash navigation wins over stale same-tab scroll restoration entries', asyn expect(scrollYAfterHashNavigation).toBeLessThan(staleScrollY) }) -test('hash navigation still runs when only nested scroll entries restore', async ({ +test('hash navigation wins when the destination invalidates in a layout effect', async ({ page, }) => { await goToRepro(page) + const staleScrollY = await scrollUpFromHashTarget(page) - const nestedScrollTop = await page.evaluate(() => { - const nested = document.querySelector('[data-testid="hash-scroll-nested"]') - if (!(nested instanceof HTMLElement)) { - throw new Error('Missing nested scroller') - } + await page.reload() + await page.waitForLoadState('networkidle') + await expect( + page.getByTestId('hash-scroll-repro-invalidate-count'), + ).toHaveText('Invalidate count: 0') + + await page.getByTestId('hash-scroll-layout-invalidate-link').click() + await expect(page).toHaveURL(/invalidateOnMount=true#one$/) + await expect( + page.getByTestId('hash-scroll-repro-invalidate-count'), + ).toHaveText('Invalidate count: 1') + + await expect(page.getByTestId('hash-scroll-section-one')).toBeInViewport() + await expect( + page.getByTestId('hash-scroll-section-five'), + ).not.toBeInViewport() - nested.scrollTop = 80 - window.dispatchEvent(new PageTransitionEvent('pagehide')) - return nested.scrollTop + const scrollYAfterHashNavigation = await page.evaluate(() => window.scrollY) + expect(scrollYAfterHashNavigation).toBeLessThan(staleScrollY) +}) + +test('hash navigation still runs when a configured nested target restores', async ({ + page, +}) => { + await goToRepro(page) + + const nested = page.getByTestId('hash-scroll-nested') + const cachedScrollTop = await nested.evaluate(async (element) => { + element.scrollTop = 80 + element.dispatchEvent(new Event('scroll', { bubbles: true })) + await new Promise((resolve) => requestAnimationFrame(resolve)) + return element.scrollTop }) - expect(nestedScrollTop).toBeGreaterThan(0) + expect(cachedScrollTop).toBeGreaterThan(0) - await page.reload() - await page.waitForLoadState('networkidle') + await page.getByTestId('hash-scroll-about-link').click() await expect( - page.getByTestId('hash-scroll-repro-invalidate-count'), + page.getByRole('heading', { name: 'hash-scroll-about' }), ).toBeVisible() - await page.getByTestId('hash-scroll-section-one-link').click() + await page.getByTestId('hash-scroll-restore-link').click() await expect(page.getByTestId('hash-scroll-section-one')).toBeInViewport() await expect - .poll(async () => { - return page.evaluate(() => { - const nested = document.querySelector( - '[data-testid="hash-scroll-nested"]', - ) - return nested instanceof HTMLElement ? nested.scrollTop : 0 - }) - }) - .toBe(nestedScrollTop) + .poll(async () => nested.evaluate((element) => element.scrollTop)) + .toBe(cachedScrollTop) }) test('hash navigation scrolls when resetScroll is false', async ({ page }) => { @@ -157,3 +173,60 @@ test('hash navigation scrolls when resetScroll is false', async ({ page }) => { .poll(async () => page.evaluate(() => window.scrollY)) .toBeGreaterThan(scrollYBeforeHashNavigation) }) + +test('hash navigation does not reset configured nested targets', async ({ + page, +}) => { + await goToRepro(page) + + const resetTarget = page.getByTestId('hash-scroll-reset-target') + const scrollTop = await resetTarget.evaluate((element) => { + element.scrollTop = 80 + return element.scrollTop + }) + expect(scrollTop).toBeGreaterThan(0) + + await page.getByTestId('hash-scroll-section-one-link').click() + await expect(page).toHaveURL(/#one$/) + await expect(page.getByTestId('hash-scroll-section-one')).toBeInViewport() + await expect + .poll(async () => resetTarget.evaluate((el) => el.scrollTop)) + .toBe(scrollTop) +}) + +test('hash navigation does not carry configured targets into a different key', async ({ + page, +}) => { + await goToRepro(page) + + const resetTarget = page.getByTestId('hash-scroll-reset-target') + const sourceScrollTop = await resetTarget.evaluate(async (element) => { + element.scrollTop = 80 + element.dispatchEvent(new Event('scroll', { bubbles: true })) + await new Promise((resolve) => requestAnimationFrame(resolve)) + return element.scrollTop + }) + expect(sourceScrollTop).toBeGreaterThan(0) + + await page.getByTestId('hash-scroll-different-key-link').click() + await expect(page).toHaveURL(/scrollKey=destination#one$/) + await expect(page.getByTestId('hash-scroll-section-one')).toBeInViewport() + await expect + .poll(async () => resetTarget.evaluate((element) => element.scrollTop)) + .toBe(sourceScrollTop) + + await page.getByTestId('hash-scroll-about-link').click() + await expect( + page.getByRole('heading', { name: 'hash-scroll-about' }), + ).toBeVisible() + + await page.getByTestId('hash-scroll-destination-link').click() + await expect(page).toHaveURL(/scrollKey=destination$/) + await expect + .poll(async () => + page + .getByTestId('hash-scroll-reset-target') + .evaluate((element) => element.scrollTop), + ) + .toBe(0) +}) diff --git a/e2e/react-start/scroll-restoration/tests/issue-7687.spec.ts b/e2e/react-start/scroll-restoration/tests/issue-7687.spec.ts new file mode 100644 index 0000000000..85a5bd5d43 --- /dev/null +++ b/e2e/react-start/scroll-restoration/tests/issue-7687.spec.ts @@ -0,0 +1,242 @@ +import { expect, test } from '@playwright/test' +import type { Page } from '@playwright/test' + +async function goToList(page: Page) { + await page.goto('/issue-7687') + await page.waitForLoadState('networkidle') + await expect( + page.getByRole('heading', { name: 'issue-7687-list' }), + ).toBeVisible() +} + +test('restores a configured nested target independently from the window', async ({ + page, +}) => { + await goToList(page) + + const scroller = page.getByTestId('issue-7687-scroller') + const scrollTop = await scroller.evaluate((element) => { + element.scrollTop = 400 + element.dispatchEvent(new Event('scroll', { bubbles: true })) + return element.scrollTop + }) + + expect(scrollTop).toBeGreaterThan(0) + expect(await page.evaluate(() => window.scrollY)).toBe(0) + + await page.getByTestId('issue-7687-detail-link').click() + await expect( + page.getByRole('heading', { name: 'issue-7687-detail' }), + ).toBeVisible() + await expect.poll(async () => scroller.evaluate((el) => el.scrollTop)).toBe(0) + + const outgoingWindowScrollY = await page.evaluate(() => { + window.scrollTo(0, 600) + window.dispatchEvent(new Event('scroll')) + return window.scrollY + }) + expect(outgoingWindowScrollY).toBeGreaterThan(0) + + await page.getByTestId('issue-7687-list-link').click() + await expect( + page.getByRole('heading', { name: 'issue-7687-list' }), + ).toBeVisible() + + await expect + .poll(async () => scroller.evaluate((el) => el.scrollTop)) + .toBe(scrollTop) + await expect.poll(async () => page.evaluate(() => window.scrollY)).toBe(0) + await expect + .poll(async () => + page.getByTestId('issue-7687-reset-probe').evaluate((el) => el.scrollTop), + ) + .toBe(0) +}) + +test('resets an uncached configured target when the window restores', async ({ + page, +}) => { + await goToList(page) + + const windowScrollY = await page.evaluate(() => { + window.scrollTo(0, 600) + window.dispatchEvent(new Event('scroll')) + return window.scrollY + }) + expect(windowScrollY).toBeGreaterThan(0) + + await page.getByTestId('issue-7687-detail-link').click() + await expect( + page.getByRole('heading', { name: 'issue-7687-detail' }), + ).toBeVisible() + await expect.poll(async () => page.evaluate(() => window.scrollY)).toBe(0) + + await page.getByTestId('issue-7687-list-link').click() + await expect( + page.getByRole('heading', { name: 'issue-7687-list' }), + ).toBeVisible() + + await expect + .poll(async () => page.evaluate(() => window.scrollY)) + .toBe(windowScrollY) + await expect + .poll(async () => + page.getByTestId('issue-7687-reset-probe').evaluate((el) => el.scrollTop), + ) + .toBe(0) +}) + +test('restores and resets nested targets independently on browser back', async ({ + page, +}) => { + await goToList(page) + + const cachedScrollTop = await page + .getByTestId('issue-7687-scroller') + .evaluate(async (element) => { + element.scrollTop = 400 + element.dispatchEvent(new Event('scroll', { bubbles: true })) + await new Promise((resolve) => requestAnimationFrame(resolve)) + return element.scrollTop + }) + expect(cachedScrollTop).toBeGreaterThan(0) + + await page.getByTestId('issue-7687-detail-link').click() + await expect( + page.getByRole('heading', { name: 'issue-7687-detail' }), + ).toBeVisible() + + await page.goBack() + await expect( + page.getByRole('heading', { name: 'issue-7687-list' }), + ).toBeVisible() + + await expect + .poll(async () => + page + .getByTestId('issue-7687-scroller') + .evaluate((element) => element.scrollTop), + ) + .toBe(cachedScrollTop) + await expect + .poll(async () => + page + .getByTestId('issue-7687-reset-probe') + .evaluate((element) => element.scrollTop), + ) + .toBe(0) +}) + +test('resets a live configured target when its cached selector becomes stale', async ({ + page, +}) => { + await goToList(page) + + const target = page.getByTestId('issue-7687-stale-selector') + const cachedScrollTop = await target.evaluate(async (element) => { + element.scrollTop = 80 + element.dispatchEvent(new Event('scroll', { bubbles: true })) + await new Promise((resolve) => requestAnimationFrame(resolve)) + return element.scrollTop + }) + expect(cachedScrollTop).toBeGreaterThan(0) + + await page.getByTestId('issue-7687-detail-link').click() + await expect( + page.getByRole('heading', { name: 'issue-7687-detail' }), + ).toBeVisible() + await expect.poll(async () => target.evaluate((el) => el.scrollTop)).toBe(0) + + const outgoingScrollTop = await target.evaluate(async (element) => { + element.dataset.scrollRestorationId = 'issue-7687-stale-current' + element.scrollTop = 40 + element.dispatchEvent(new Event('scroll', { bubbles: true })) + await new Promise((resolve) => requestAnimationFrame(resolve)) + return element.scrollTop + }) + expect(outgoingScrollTop).toBeGreaterThan(0) + + await page.getByTestId('issue-7687-list-link').click() + await expect( + page.getByRole('heading', { name: 'issue-7687-list' }), + ).toBeVisible() + + await expect.poll(async () => target.evaluate((el) => el.scrollTop)).toBe(0) +}) + +test('resetScroll false preserves window and configured nested targets', async ({ + page, +}) => { + await goToList(page) + + const positions = await page.evaluate(() => { + const scroller = document.querySelector( + '[data-testid="issue-7687-scroller"]', + ) + if (!(scroller instanceof HTMLElement)) { + throw new Error('Missing issue-7687 scroller') + } + + scroller.scrollTop = 400 + scroller.dispatchEvent(new Event('scroll', { bubbles: true })) + window.scrollTo(0, 600) + window.dispatchEvent(new Event('scroll')) + + return { nested: scroller.scrollTop, window: window.scrollY } + }) + + expect(positions.nested).toBeGreaterThan(0) + expect(positions.window).toBeGreaterThan(0) + + await page.getByTestId('issue-7687-detail-no-reset-link').click() + await expect( + page.getByRole('heading', { name: 'issue-7687-detail' }), + ).toBeVisible() + + await expect + .poll(async () => page.evaluate(() => window.scrollY)) + .toBe(positions.window) + await expect + .poll(async () => + page.getByTestId('issue-7687-scroller').evaluate((el) => el.scrollTop), + ) + .toBe(positions.nested) + + await page.getByTestId('issue-7687-list-link').click() + await expect( + page.getByRole('heading', { name: 'issue-7687-list' }), + ).toBeVisible() + + const changedPositions = await page.evaluate(async () => { + const scroller = document.querySelector( + '[data-testid="issue-7687-scroller"]', + ) + if (!(scroller instanceof HTMLElement)) { + throw new Error('Missing issue-7687 scroller') + } + + scroller.scrollTop = 200 + scroller.dispatchEvent(new Event('scroll', { bubbles: true })) + window.scrollTo(0, 300) + window.dispatchEvent(new Event('scroll')) + await new Promise((resolve) => requestAnimationFrame(resolve)) + + return { nested: scroller.scrollTop, window: window.scrollY } + }) + + expect(changedPositions.nested).not.toBe(positions.nested) + expect(changedPositions.window).not.toBe(positions.window) + + await page.getByTestId('issue-7687-detail-link').click() + await expect( + page.getByRole('heading', { name: 'issue-7687-detail' }), + ).toBeVisible() + await expect + .poll(async () => page.evaluate(() => window.scrollY)) + .toBe(positions.window) + await expect + .poll(async () => + page.getByTestId('issue-7687-scroller').evaluate((el) => el.scrollTop), + ) + .toBe(positions.nested) +}) diff --git a/e2e/react-start/scroll-restoration/tests/ssr-scroll-key.spec.ts b/e2e/react-start/scroll-restoration/tests/ssr-scroll-key.spec.ts index 737695a0cf..eb61a279d2 100644 --- a/e2e/react-start/scroll-restoration/tests/ssr-scroll-key.spec.ts +++ b/e2e/react-start/scroll-restoration/tests/ssr-scroll-key.spec.ts @@ -38,3 +38,77 @@ test('SSR scroll restoration uses a custom restoration key', async ({ .poll(async () => page.evaluate(() => window.scrollY)) .toBe(customKeyScrollY) }) + +test('SSR restores a configured nested target without client JavaScript', async ({ + page, +}) => { + const nestedScrollTop = 80 + + await page.addInitScript( + ({ nestedScrollTop, storageKey }) => { + window.sessionStorage.setItem( + storageKey, + JSON.stringify({ + 'ssr-scroll-key': { + '[data-scroll-restoration-id="ssr-scroll-key-nested"]': { + scrollX: 0, + scrollY: nestedScrollTop, + }, + }, + }), + ) + }, + { nestedScrollTop, storageKey }, + ) + await page.route(/\/assets\/.*\.js$/, (route) => route.abort()) + + await page.goto('/ssr-scroll-key', { waitUntil: 'domcontentloaded' }) + await expect( + page.getByRole('heading', { name: 'ssr-scroll-key' }), + ).toBeVisible() + await expect(page.getByTestId('ssr-scroll-key-hydrated')).toHaveCount(0) + await expect + .poll(async () => + page + .getByTestId('ssr-scroll-key-nested') + .evaluate((element) => element.scrollTop), + ) + .toBe(nestedScrollTop) +}) + +test('hydration does not reset a configured nested target restored by SSR', async ({ + page, +}) => { + const nestedScrollTop = 80 + + await page.addInitScript( + ({ nestedScrollTop, storageKey }) => { + window.sessionStorage.setItem( + storageKey, + JSON.stringify({ + 'ssr-scroll-key': { + '[data-scroll-restoration-id="ssr-scroll-key-nested"]': { + scrollX: 0, + scrollY: nestedScrollTop, + }, + }, + }), + ) + }, + { nestedScrollTop, storageKey }, + ) + + await page.goto('/ssr-scroll-key') + await page.waitForLoadState('networkidle') + await expect( + page.getByRole('heading', { name: 'ssr-scroll-key' }), + ).toBeVisible() + await expect(page.getByTestId('ssr-scroll-key-hydrated')).toBeAttached() + await expect + .poll(async () => + page + .getByTestId('ssr-scroll-key-nested') + .evaluate((element) => element.scrollTop), + ) + .toBe(nestedScrollTop) +}) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 2197dab737..df9251a739 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -904,11 +904,6 @@ export function getLocationChangeInfo( return { fromLocation, toLocation, pathChanged, hrefChanged, hashChanged } } -export const locationHistoryActions = new WeakMap< - ParsedLocation, - HistoryAction ->() - type LightweightRouteMatchResult = { matchedRoutes: ReadonlyArray fullPath: string @@ -978,6 +973,8 @@ export class RouterCore< )}` _scroll: { next: boolean + // True until the current PUSH/REPLACE renders, so its hash owns window scroll. + hash?: boolean restoring?: boolean restoration?: boolean reset?: boolean @@ -2475,9 +2472,8 @@ export class RouterCore< try { this.beforeLoad() if (historyAction) { - locationHistoryActions.set(this.latestLocation, historyAction) - } else { - locationHistoryActions.delete(this.latestLocation) + this._scroll.hash = + historyAction === 'PUSH' || historyAction === 'REPLACE' } const next = this.latestLocation const prevLocation = this.stores.resolvedLocation.get() diff --git a/packages/router-core/src/scroll-restoration.ts b/packages/router-core/src/scroll-restoration.ts index 6287a6fce0..5e4ae0d3ee 100644 --- a/packages/router-core/src/scroll-restoration.ts +++ b/packages/router-core/src/scroll-restoration.ts @@ -1,5 +1,4 @@ import { isServer } from '@tanstack/router-core/isServer' -import { locationHistoryActions } from './router' import type { AnyRouter } from './router' import type { ParsedLocation } from './location' @@ -133,7 +132,6 @@ export function getElementScrollRestorationEntry( let ignoreScroll = false const windowScrollTarget = 'window' -type ScrollTarget = typeof windowScrollTarget | Element function getElement(selector: string | (() => Element | null | undefined)) { try { @@ -148,8 +146,8 @@ function getScrollToTopElements( scrollToTopSelectors: NonNullable< AnyRouter['options']['scrollToTopSelectors'] >, -): Array { - const elements: Array = [] +) { + const elements = new Set() for (const selector of scrollToTopSelectors) { if (selector === windowScrollTarget) { @@ -158,7 +156,7 @@ function getScrollToTopElements( const element = getElement(selector) if (element) { - elements.push(element) + elements.add(element) } } @@ -180,46 +178,21 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) { const getKey = router.options.getScrollRestorationKey || defaultGetScrollRestorationKey - const trackedScrollEntries = new Map() - const setTrackedScrollEntry = ( - target: ScrollTarget, - scrollX: number, - scrollY: number, - ) => { - const entry = - trackedScrollEntries.get(target) || ({} as ScrollRestorationEntry) - entry.scrollX = scrollX - entry.scrollY = scrollY - trackedScrollEntries.set(target, entry) - } - - const onScroll = (event: Event) => { - if (ignoreScroll || !scroll.restoring) { - return - } - - if (event.target === document) { - setTrackedScrollEntry(windowScrollTarget, scrollX, scrollY) - } else { - const target = event.target as Element - setTrackedScrollEntry(target, target.scrollLeft, target.scrollTop) - } - } + const trackedScrollTargets = new Set() // Snapshot the current page's tracked scroll targets before navigation or unload. const snapshotCurrentScrollTargets = (restoreKey: string) => { - if (!scroll.restoring) { - return - } - const keyEntry = (scrollRestorationCache[restoreKey] ||= {} as ScrollRestorationByElement) - for (const [target, position] of trackedScrollEntries) { - if (target === windowScrollTarget) { - keyEntry[windowScrollTarget] = position - } else if (target.isConnected) { - keyEntry[getScrollRestorationSelector(target)] = position + for (const target of trackedScrollTargets) { + if (target === document) { + keyEntry[windowScrollTarget] = { scrollX, scrollY } + } else if ((target as Element).isConnected) { + keyEntry[getScrollRestorationSelector(target as Element)] = { + scrollX: (target as Element).scrollLeft, + scrollY: (target as Element).scrollTop, + } } } } @@ -230,12 +203,21 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) { history.scrollRestoration = 'manual' - document.addEventListener('scroll', onScroll, true) + document.addEventListener( + 'scroll', + (event) => { + if (ignoreScroll) { + return + } + trackedScrollTargets.add(event.target as Document | Element) + }, + true, + ) router.subscribe('onBeforeLoad', (event) => { if (event.fromLocation) { snapshotCurrentScrollTargets(getKey(event.fromLocation)) } - trackedScrollEntries.clear() + trackedScrollTargets.clear() }) addEventListener('pagehide', () => { snapshotCurrentScrollTargets( @@ -258,12 +240,11 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) { const behavior = router.options.scrollRestorationBehavior const scrollToTopSelectors = router.options.scrollToTopSelectors const shouldResetScroll = scroll.next - let scrollToTopElements: Array | undefined - trackedScrollEntries.clear() - - if (!shouldResetScroll) { - scroll.next = true - } + const hashNavigation = scroll.hash + let scrollToTopElements: Set | undefined + trackedScrollTargets.clear() + scroll.next = true + scroll.hash = false if ( typeof router.options.scrollRestoration === 'function' && @@ -295,7 +276,7 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) { if (shouldResetScroll && scrollToTopSelectors) { scrollToTopElements ??= getScrollToTopElements(scrollToTopSelectors) - if (scrollToTopElements.includes(element)) { + if (scrollToTopElements.has(element)) { continue } } @@ -321,11 +302,12 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) { let windowRestored = false if (shouldResetScroll) { - const action = locationHistoryActions.get(event.toLocation) + if (!hash && scrollToTopSelectors) { + scrollToTopElements ??= getScrollToTopElements(scrollToTopSelectors) + } + const skipWindowRestore = - hash && - hashScrollIntoViewOptions && - (action === 'PUSH' || action === 'REPLACE') + hash && hashScrollIntoViewOptions && hashNavigation const elementEntries = scroll.restoring ? scrollRestorationCache[cacheKey] @@ -351,21 +333,23 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) { if (element) { element.scrollLeft = scrollX element.scrollTop = scrollY + scrollToTopElements?.delete(element) } } } } - if (!windowRestored && !hash) { + if (!hash) { const scrollOptions = { top: 0, left: 0, behavior, } - scrollTo(scrollOptions) - if (scrollToTopSelectors) { - scrollToTopElements ??= getScrollToTopElements(scrollToTopSelectors) + if (!windowRestored) { + scrollTo(scrollOptions) + } + if (scrollToTopElements) { for (const element of scrollToTopElements) { element.scrollTo(scrollOptions) } diff --git a/packages/router-core/tests/scroll-restoration.test.ts b/packages/router-core/tests/scroll-restoration.test.ts index 188d13bb68..c02a8f8783 100644 --- a/packages/router-core/tests/scroll-restoration.test.ts +++ b/packages/router-core/tests/scroll-restoration.test.ts @@ -2,8 +2,15 @@ import { createMemoryHistory } from '@tanstack/history' import { afterEach, describe, expect, test, vi } from 'vitest' import { BaseRootRoute, BaseRoute } from '../src' import { createTestRouter } from './routerTestUtils' +import type { ParsedLocation } from '../src' -function createRouter(options: { scrollRestoration?: boolean } = {}) { +function createRouter( + options: { + scrollRestoration?: boolean + scrollToTopSelectors?: Array Element | null | undefined)> + getScrollRestorationKey?: (location: ParsedLocation) => string + } = {}, +) { const rootRoute = new BaseRootRoute({}) const indexRoute = new BaseRoute({ getParentRoute: () => rootRoute, @@ -18,9 +25,39 @@ function createRouter(options: { scrollRestoration?: boolean } = {}) { } afterEach(() => { + document.body.replaceChildren() + window.sessionStorage.clear() vi.restoreAllMocks() + vi.unstubAllGlobals() }) +function getLocation( + router: ReturnType, + pathname: string, +) { + return { + ...router.latestLocation, + href: pathname, + pathname, + } +} + +function emitNavigation( + router: ReturnType, + type: 'onBeforeLoad' | 'onRendered', + fromLocation: ParsedLocation, + toLocation: ParsedLocation, +) { + router.emit({ + type, + fromLocation, + toLocation, + pathChanged: true, + hrefChanged: true, + hashChanged: false, + }) +} + describe('setupScrollRestoration', () => { test('sets up scroll restoration when scrollRestoration is true', () => { const windowAddEventListener = vi.spyOn(window, 'addEventListener') @@ -46,6 +83,63 @@ describe('setupScrollRestoration', () => { window.history.scrollRestoration = previousScrollRestoration }) + test('snapshots the live position when it changed after the latest scroll event', () => { + const element = document.createElement('div') + element.id = 'unit-live-snapshot-element' + document.body.append(element) + vi.stubGlobal('scrollTo', vi.fn()) + + const router = createRouter({ + scrollRestoration: true, + getScrollRestorationKey: (location) => location.pathname, + }) + const source = getLocation(router, '/unit-live-snapshot-source') + const destination = getLocation(router, '/unit-live-snapshot-destination') + + element.scrollTop = 80 + element.dispatchEvent(new Event('scroll', { bubbles: true })) + element.scrollTop = 120 + + emitNavigation(router, 'onBeforeLoad', source, destination) + element.scrollTop = 0 + emitNavigation(router, 'onRendered', source, destination) + + expect(element.scrollTop).toBe(120) + }) + + test('snapshots the live window position when it changed after the latest scroll event', () => { + const windowScrollTo = vi.fn() + vi.stubGlobal('scrollTo', windowScrollTo) + vi.stubGlobal('scrollX', 0) + vi.stubGlobal('scrollY', 80) + + const router = createRouter({ + scrollRestoration: true, + getScrollRestorationKey: (location) => location.pathname, + }) + const source = getLocation(router, '/unit-live-window-snapshot-source') + const destination = getLocation( + router, + '/unit-live-window-snapshot-destination', + ) + + document.dispatchEvent(new Event('scroll')) + vi.stubGlobal('scrollY', 120) + + emitNavigation(router, 'onBeforeLoad', source, destination) + emitNavigation(router, 'onRendered', source, destination) + windowScrollTo.mockClear() + + emitNavigation(router, 'onBeforeLoad', destination, source) + emitNavigation(router, 'onRendered', destination, source) + + expect(windowScrollTo).toHaveBeenCalledWith({ + top: 120, + left: 0, + behavior: undefined, + }) + }) + test.each([ ['omitted', undefined], ['false', false], @@ -81,4 +175,157 @@ describe('setupScrollRestoration', () => { window.history.scrollRestoration = previousScrollRestoration }, ) + + test('restores a configured element independently from the window', () => { + const element = document.createElement('div') + element.id = 'unit-restored-element' + element.dataset.scrollRestorationId = 'unit-restored-element' + document.body.append(element) + + const elementScrollTo = vi.fn((options: ScrollToOptions) => { + element.scrollTop = options.top ?? element.scrollTop + }) + element.scrollTo = elementScrollTo as typeof element.scrollTo + const windowScrollTo = vi.fn() + vi.stubGlobal('scrollTo', windowScrollTo) + + const router = createRouter({ + scrollRestoration: true, + scrollToTopSelectors: ['#unit-restored-element'], + getScrollRestorationKey: (location) => location.pathname, + }) + const source = getLocation(router, '/unit-element-source') + const destination = getLocation(router, '/unit-element-destination') + + element.scrollTop = 80 + element.dispatchEvent(new Event('scroll', { bubbles: true })) + + emitNavigation(router, 'onBeforeLoad', source, destination) + emitNavigation(router, 'onRendered', source, destination) + expect(element.scrollTop).toBe(0) + + elementScrollTo.mockClear() + windowScrollTo.mockClear() + vi.stubGlobal('scrollX', 0) + vi.stubGlobal('scrollY', 120) + document.dispatchEvent(new Event('scroll')) + emitNavigation(router, 'onBeforeLoad', destination, source) + emitNavigation(router, 'onRendered', destination, source) + + expect(element.scrollTop).toBe(80) + expect(elementScrollTo).not.toHaveBeenCalled() + expect(windowScrollTo).toHaveBeenCalledWith({ + top: 0, + left: 0, + behavior: undefined, + }) + }) + + test('resets an uncached configured element when the window restores', () => { + const element = document.createElement('div') + element.id = 'unit-reset-element' + document.body.append(element) + + const elementScrollTo = vi.fn((options: ScrollToOptions) => { + element.scrollTop = options.top ?? element.scrollTop + }) + element.scrollTo = elementScrollTo as typeof element.scrollTo + + const windowScrollTo = vi.fn() + vi.stubGlobal('scrollX', 0) + vi.stubGlobal('scrollY', 120) + vi.stubGlobal('scrollTo', windowScrollTo) + + const router = createRouter({ + scrollRestoration: true, + scrollToTopSelectors: ['#unit-reset-element'], + getScrollRestorationKey: (location) => location.pathname, + }) + const source = getLocation(router, '/unit-window-source') + const destination = getLocation(router, '/unit-window-destination') + + document.dispatchEvent(new Event('scroll')) + emitNavigation(router, 'onBeforeLoad', source, destination) + emitNavigation(router, 'onRendered', source, destination) + + elementScrollTo.mockClear() + windowScrollTo.mockClear() + element.scrollTop = 80 + + emitNavigation(router, 'onBeforeLoad', destination, source) + emitNavigation(router, 'onRendered', destination, source) + + expect(windowScrollTo).toHaveBeenCalledWith({ + top: 120, + left: 0, + behavior: undefined, + }) + expect(element.scrollTop).toBe(0) + expect(elementScrollTo).toHaveBeenCalledWith({ + top: 0, + left: 0, + behavior: undefined, + }) + }) + + test('resets a configured element when its cached selector becomes stale', () => { + const element = document.createElement('div') + element.id = 'unit-stale-selector-element' + element.dataset.scrollRestorationId = 'unit-stale-selector-source' + document.body.append(element) + + const elementScrollTo = vi.fn((options: ScrollToOptions) => { + element.scrollTop = options.top ?? element.scrollTop + }) + element.scrollTo = elementScrollTo as typeof element.scrollTo + vi.stubGlobal('scrollTo', vi.fn()) + + const router = createRouter({ + scrollRestoration: true, + scrollToTopSelectors: [() => element], + getScrollRestorationKey: (location) => location.pathname, + }) + const source = getLocation(router, '/unit-stale-selector-source') + const destination = getLocation(router, '/unit-stale-selector-destination') + + element.scrollTop = 80 + element.dispatchEvent(new Event('scroll', { bubbles: true })) + emitNavigation(router, 'onBeforeLoad', source, destination) + emitNavigation(router, 'onRendered', source, destination) + expect(element.scrollTop).toBe(0) + + element.dataset.scrollRestorationId = 'unit-stale-selector-current' + element.scrollTop = 40 + elementScrollTo.mockClear() + emitNavigation(router, 'onBeforeLoad', destination, source) + emitNavigation(router, 'onRendered', destination, source) + + expect(element.scrollTop).toBe(0) + expect(elementScrollTo).toHaveBeenCalledWith({ + top: 0, + left: 0, + behavior: undefined, + }) + }) + + test('does not resolve configured selectors for hash navigation without source entries', () => { + const element = document.createElement('div') + document.body.append(element) + const getElement = vi.fn(() => element) + + const router = createRouter({ + scrollRestoration: true, + scrollToTopSelectors: [getElement], + getScrollRestorationKey: (location) => location.pathname, + }) + const source = getLocation(router, '/unit-hash-source') + const destination = { + ...getLocation(router, '/unit-hash-destination'), + hash: 'section', + } + + emitNavigation(router, 'onRendered', source, destination) + + expect(getElement).not.toHaveBeenCalled() + }) })