Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/fix-independent-scroll-targets.md
Original file line number Diff line number Diff line change
@@ -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.
71 changes: 71 additions & 0 deletions e2e/react-start/scroll-restoration/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '/',
Expand Down Expand Up @@ -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',
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -145,13 +169,16 @@ 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
fullPaths:
| '/'
| '/hash-scroll-about'
| '/hash-scroll-repro'
| '/issue-7687'
| '/nested-scroll-away'
| '/nested-scroll-carry-over-a'
| '/nested-scroll-carry-over-b'
Expand All @@ -163,6 +190,8 @@ export interface FileRouteTypes {
| '/ssr-scroll-key'
| '/with-loader'
| '/with-search'
| '/issue-7687/detail'
| '/issue-7687/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
Expand All @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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'
Expand All @@ -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,
Expand Down
21 changes: 20 additions & 1 deletion e2e/react-start/scroll-restoration/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
Comment on lines +10 to +21

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm no element anywhere defines id="hash-scroll-nested", and inspect getScrollToTopElements/getScrollRestorationSelector matching logic.
rg -n 'id="hash-scroll-nested"' e2e/react-start/scroll-restoration
rg -n 'getScrollToTopElements|getScrollRestorationSelector' packages/router-core/src/scroll-restoration.ts -A5 -B2

Repository: TanStack/router

Length of output: 2021


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== router.tsx ==\n'
sed -n '1,80p' e2e/react-start/scroll-restoration/src/router.tsx

printf '\n== hash-scroll-repro.tsx (around nested target) ==\n'
sed -n '80,130p' e2e/react-start/scroll-restoration/src/routes/'(tests)'/hash-scroll-repro.tsx

printf '\n== scroll-restoration.ts (selector logic) ==\n'
sed -n '1,360p' packages/router-core/src/scroll-restoration.ts

Repository: TanStack/router

Length of output: 13404


Use the data attribute selector for hash-scroll-nested.
scrollToTopSelectors are resolved with document.querySelector(), so #hash-scroll-nested never matches this element. That leaves the nested scroller out of both the reset-to-top path and the cross-key carry-over exclusion.

🐛 Proposed fix
-      '`#hash-scroll-nested`',
+      '[data-scroll-restoration-id="hash-scroll-nested"]',
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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',
],
scrollToTopSelectors: [
'[data-scroll-restoration-id="carry-over-reset"]',
'[data-scroll-restoration-id="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`',
],
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/react-start/scroll-restoration/src/router.tsx` around lines 10 - 21,
Update the scrollToTopSelectors entry for the nested hash scroller in the router
configuration to use that element’s data-attribute selector instead of the
ineffective ID selector, preserving its participation in reset-to-top and
cross-key carry-over handling.

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
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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')({
Expand All @@ -17,6 +17,20 @@ function Component() {
<p data-testid="hash-scroll-about-top">
This route exists so hover preloading can reproduce the hash scroll bug.
</p>
<Link
to="/hash-scroll-repro"
hash="one"
data-testid="hash-scroll-restore-link"
>
Return to #one
</Link>
<Link
to="/hash-scroll-repro"
search={{ scrollKey: 'destination' }}
data-testid="hash-scroll-destination-link"
>
Return to the destination key
</Link>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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])
Comment on lines +26 to +35

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Unhandled rejection if router.invalidate() fails.

void router.invalidate().then(...) has no .catch; a rejected invalidation becomes an unhandled promise rejection, which can produce noisy console errors or flaky Playwright failures if the test harness asserts on console output.

🛠️ Proposed fix
     invalidatedOnMount.current = true
-    void router.invalidate().then(() => {
-      setInvalidateCount((count) => count + 1)
-    })
+    void router.invalidate()
+      .then(() => {
+        setInvalidateCount((count) => count + 1)
+      })
+      .catch(() => {})
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
React.useLayoutEffect(() => {
if (!invalidateOnMount || invalidatedOnMount.current) {
return
}
invalidatedOnMount.current = true
void router.invalidate().then(() => {
setInvalidateCount((count) => count + 1)
})
}, [invalidateOnMount, router])
React.useLayoutEffect(() => {
if (!invalidateOnMount || invalidatedOnMount.current) {
return
}
invalidatedOnMount.current = true
void router.invalidate()
.then(() => {
setInvalidateCount((count) => count + 1)
})
.catch(() => {})
}, [invalidateOnMount, router])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/react-start/scroll-restoration/src/routes/`(tests)/hash-scroll-repro.tsx
around lines 26 - 35, Handle rejection from router.invalidate() within the
React.useLayoutEffect callback by adding a catch path to the existing promise
chain, preventing failed invalidations from becoming unhandled promise
rejections while preserving the success-only setInvalidateCount update.


return (
<div className="p-2">
Expand Down Expand Up @@ -61,6 +79,24 @@ function Component() {
>
#four no reset
</Link>
<Link
to="/hash-scroll-repro"
search={{ scrollKey: 'destination' }}
hash="one"
className="rounded border px-3 py-2"
data-testid="hash-scroll-different-key-link"
>
#one different key
</Link>
<Link
to="/hash-scroll-repro"
search={{ invalidateOnMount: true }}
hash="one"
className="rounded border px-3 py-2"
data-testid="hash-scroll-layout-invalidate-link"
>
#one and invalidate in layout effect
</Link>
</div>
</div>

Expand All @@ -74,6 +110,16 @@ function Component() {
))}
</div>

<div
id="hash-scroll-reset-target"
data-testid="hash-scroll-reset-target"
className="mt-4 h-24 overflow-auto rounded border p-2"
>
{Array.from({ length: 20 }).map((_, i) => (
<div key={i}>Hash reset target row {i}</div>
))}
</div>

<div className="mt-6 grid gap-10">
{sectionIds.map((sectionId) => (
<section
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/(tests)/issue-7687/detail')({
component: Component,
})

function Component() {
return (
<div className="grid gap-4">
<h3>issue-7687-detail</h3>
{Array.from({ length: 40 }).map((_, i) => (
<div key={i}>Detail row {i}</div>
))}
</div>
)
}
Loading
Loading