-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
fix(router-core): handle window and element scroll restoration independently #7807
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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 | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Unhandled rejection if
🛠️ Proposed fix invalidatedOnMount.current = true
- void router.invalidate().then(() => {
- setInvalidateCount((count) => count + 1)
- })
+ void router.invalidate()
+ .then(() => {
+ setInvalidateCount((count) => count + 1)
+ })
+ .catch(() => {})📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||||||||||||||
| <div className="p-2"> | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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> | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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 | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| 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> | ||
| ) | ||
| } |
There was a problem hiding this comment.
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:
Repository: TanStack/router
Length of output: 2021
🏁 Script executed:
Repository: TanStack/router
Length of output: 13404
Use the data attribute selector for
hash-scroll-nested.scrollToTopSelectorsare resolved withdocument.querySelector(), so#hash-scroll-nestednever 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
📝 Committable suggestion
🤖 Prompt for AI Agents