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..8ec2c6054a3 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
@@ -35,6 +35,7 @@ function Nav({ type }: { type: 'header' | 'footer' }) {
className: 'font-bold',
}}
activeOptions={{ exact: true }}
+ data-testid={`${prefix}-home-link`}
>
{prefix}-/
{' '}
@@ -53,6 +54,7 @@ function Nav({ type }: { type: 'header' | 'footer' }) {
activeProps={{
className: 'font-bold',
}}
+ data-testid={`${prefix}-${options.to}-link`}
>
{prefix}-{options.to}
diff --git a/e2e/react-router/scroll-restoration-sandbox-vite/src/routes/index.tsx b/e2e/react-router/scroll-restoration-sandbox-vite/src/routes/index.tsx
index 9b0474a02f4..98530f87fe3 100644
--- a/e2e/react-router/scroll-restoration-sandbox-vite/src/routes/index.tsx
+++ b/e2e/react-router/scroll-restoration-sandbox-vite/src/routes/index.tsx
@@ -1,12 +1,14 @@
import { createFileRoute } from '@tanstack/react-router'
import * as React from 'react'
-import { Link, linkOptions } from '@tanstack/react-router'
+import { Link, linkOptions, useNavigate } from '@tanstack/react-router'
export const Route = createFileRoute('/')({
component: HomeComponent,
})
function HomeComponent() {
+ const navigate = useNavigate()
+
return (
Welcome Home!
@@ -27,12 +29,59 @@ function HomeComponent() {
{options.to} tests
-
+
{options.to}#at-the-bottom
))}
+
+
scrollRestorationBehavior tests (Link)
+
+
+ /normal-page (smooth scroll)
+
+
+
+
+ /lazy-page (default scroll)
+
+
+
+
+
scrollRestorationBehavior tests (navigate)
+
+
+
+
+
+
+
)
}
diff --git a/e2e/react-router/scroll-restoration-sandbox-vite/tests/app.spec.ts b/e2e/react-router/scroll-restoration-sandbox-vite/tests/app.spec.ts
index 6b598426252..cd2f5d7eeab 100644
--- a/e2e/react-router/scroll-restoration-sandbox-vite/tests/app.spec.ts
+++ b/e2e/react-router/scroll-restoration-sandbox-vite/tests/app.spec.ts
@@ -23,7 +23,7 @@ pages.forEach((options, index) => {
page,
}) => {
await page.goto(toRuntimePath(from))
- const link = page.getByRole('link', { name: `Foot-${options.to}` })
+ const link = page.getByTestId(`Foot-${options.to}-link`)
await link.scrollIntoViewIfNeeded()
await page.waitForTimeout(500)
await link.click()
@@ -37,7 +37,7 @@ pages.forEach((options) => {
page,
}) => {
await page.goto(toRuntimePath('/'))
- await page.getByRole('link', { name: `Head-${options.to}` }).click()
+ await page.getByTestId(`Head-${options.to}-link`).click()
await expect(page.getByTestId('at-the-top')).toBeInViewport()
})
@@ -46,9 +46,7 @@ pages.forEach((options) => {
page,
}) => {
await page.goto(toRuntimePath('/'))
- await page
- .getByRole('link', { name: `${options.to}#at-the-bottom` })
- .click()
+ await page.getByTestId(`index-${options.to}-hash-link`).click()
await expect(page.getByTestId('at-the-bottom')).toBeInViewport()
})
@@ -64,3 +62,223 @@ pages.forEach((options) => {
await expect(page.getByTestId('at-the-bottom')).toBeInViewport()
})
})
+
+// Test for scrollRestorationBehavior option
+test('scrollRestorationBehavior: smooth should pass behavior to window.scrollTo', async ({
+ page,
+}) => {
+ // Intercept window.scrollTo to capture calls with their options
+ await page.addInitScript(() => {
+ ;(window as any).__scrollToCalls = []
+ const originalScrollTo = window.scrollTo.bind(window)
+ window.scrollTo = ((...args: any[]) => {
+ ;(window as any).__scrollToCalls.push(args)
+ return originalScrollTo(...args)
+ }) as typeof window.scrollTo
+ })
+
+ // First go to /normal-page
+ await page.goto(toRuntimePath('/normal-page'))
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Scroll down a bit
+ await page.evaluate(() => window.scrollBy(0, 300))
+ await page.waitForTimeout(200)
+
+ // Go back to home
+ await page.getByTestId('Head-home-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'Welcome Home!' }),
+ ).toBeVisible()
+
+ // Clear any scroll calls from previous navigations
+ await page.evaluate(() => {
+ ;(window as any).__scrollToCalls = []
+ })
+
+ // Click link with scrollRestorationBehavior: 'smooth'
+ await page.getByTestId('smooth-scroll-link').click()
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Get the scroll calls and find the one with behavior option
+ const smoothCalls = await page.evaluate(() => (window as any).__scrollToCalls)
+
+ // There should be at least one scrollTo call with behavior: 'smooth'
+ const hasSmoothBehavior = smoothCalls.some(
+ (call: any) => call[0]?.behavior === 'smooth',
+ )
+ expect(hasSmoothBehavior).toBe(true)
+})
+
+test('scrollRestorationBehavior should reset to default after navigation', async ({
+ page,
+}) => {
+ // Intercept window.scrollTo to capture calls with their options
+ await page.addInitScript(() => {
+ ;(window as any).__scrollToCalls = []
+ const originalScrollTo = window.scrollTo.bind(window)
+ window.scrollTo = ((...args: any[]) => {
+ ;(window as any).__scrollToCalls.push(args)
+ return originalScrollTo(...args)
+ }) as typeof window.scrollTo
+ })
+
+ // First go to /normal-page
+ await page.goto(toRuntimePath('/normal-page'))
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Scroll down a bit
+ await page.evaluate(() => window.scrollBy(0, 300))
+ await page.waitForTimeout(200)
+
+ // Go back to home
+ await page.getByTestId('Head-home-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'Welcome Home!' }),
+ ).toBeVisible()
+
+ // Navigate with smooth scroll
+ await page.getByTestId('smooth-scroll-link').click()
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Scroll down a bit on normal-page
+ await page.evaluate(() => window.scrollBy(0, 300))
+ await page.waitForTimeout(200)
+
+ // Navigate back to home
+ await page.getByTestId('Head-home-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'Welcome Home!' }),
+ ).toBeVisible()
+
+ // Clear scroll calls
+ await page.evaluate(() => {
+ ;(window as any).__scrollToCalls = []
+ })
+
+ // Now navigate with default scroll (no scrollRestorationBehavior)
+ await page.getByTestId('default-scroll-link').click()
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Get the scroll calls - should NOT have behavior: 'smooth'
+ const defaultCalls = await page.evaluate(
+ () => (window as any).__scrollToCalls,
+ )
+
+ // The scrollTo calls should not have behavior: 'smooth'
+ // (they should have undefined behavior or no behavior property)
+ const hasSmoothBehavior = defaultCalls.some(
+ (call: any) => call[0]?.behavior === 'smooth',
+ )
+ expect(hasSmoothBehavior).toBe(false)
+})
+
+// Test for imperative navigate() with scrollRestorationBehavior option
+test('navigate() with scrollRestorationBehavior: smooth should pass behavior to window.scrollTo', async ({
+ page,
+}) => {
+ // Intercept window.scrollTo to capture calls with their options
+ await page.addInitScript(() => {
+ ;(window as any).__scrollToCalls = []
+ const originalScrollTo = window.scrollTo.bind(window)
+ window.scrollTo = ((...args: any[]) => {
+ ;(window as any).__scrollToCalls.push(args)
+ return originalScrollTo(...args)
+ }) as typeof window.scrollTo
+ })
+
+ // First go to /normal-page
+ await page.goto(toRuntimePath('/normal-page'))
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Scroll down a bit
+ await page.evaluate(() => window.scrollBy(0, 300))
+ await page.waitForTimeout(200)
+
+ // Go back to home
+ await page.getByTestId('Head-home-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'Welcome Home!' }),
+ ).toBeVisible()
+
+ // Clear any scroll calls from previous navigations
+ await page.evaluate(() => {
+ ;(window as any).__scrollToCalls = []
+ })
+
+ // Click button that uses navigate() with scrollRestorationBehavior: 'smooth'
+ await page.getByTestId('smooth-scroll-navigate-btn').click()
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Get the scroll calls and find the one with behavior option
+ const smoothCalls = await page.evaluate(() => (window as any).__scrollToCalls)
+
+ // There should be at least one scrollTo call with behavior: 'smooth'
+ const hasSmoothBehavior = smoothCalls.some(
+ (call: any) => call[0]?.behavior === 'smooth',
+ )
+ expect(hasSmoothBehavior).toBe(true)
+})
+
+test('navigate() scrollRestorationBehavior should reset to default after navigation', async ({
+ page,
+}) => {
+ // Intercept window.scrollTo to capture calls with their options
+ await page.addInitScript(() => {
+ ;(window as any).__scrollToCalls = []
+ const originalScrollTo = window.scrollTo.bind(window)
+ window.scrollTo = ((...args: any[]) => {
+ ;(window as any).__scrollToCalls.push(args)
+ return originalScrollTo(...args)
+ }) as typeof window.scrollTo
+ })
+
+ // First go to /normal-page
+ await page.goto(toRuntimePath('/normal-page'))
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Scroll down a bit
+ await page.evaluate(() => window.scrollBy(0, 300))
+ await page.waitForTimeout(200)
+
+ // Go back to home
+ await page.getByTestId('Head-home-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'Welcome Home!' }),
+ ).toBeVisible()
+
+ // Navigate with smooth scroll using navigate()
+ await page.getByTestId('smooth-scroll-navigate-btn').click()
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Scroll down a bit on normal-page
+ await page.evaluate(() => window.scrollBy(0, 300))
+ await page.waitForTimeout(200)
+
+ // Navigate back to home
+ await page.getByTestId('Head-home-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'Welcome Home!' }),
+ ).toBeVisible()
+
+ // Clear scroll calls
+ await page.evaluate(() => {
+ ;(window as any).__scrollToCalls = []
+ })
+
+ // Now navigate with default scroll using navigate() (no scrollRestorationBehavior)
+ await page.getByTestId('default-scroll-navigate-btn').click()
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Get the scroll calls - should NOT have behavior: 'smooth'
+ const defaultCalls = await page.evaluate(
+ () => (window as any).__scrollToCalls,
+ )
+
+ // The scrollTo calls should not have behavior: 'smooth'
+ // (they should have undefined behavior or no behavior property)
+ const hasSmoothBehavior = defaultCalls.some(
+ (call: any) => call[0]?.behavior === 'smooth',
+ )
+ expect(hasSmoothBehavior).toBe(false)
+})
diff --git a/e2e/solid-router/scroll-restoration-sandbox-vite/src/routes/__root.tsx b/e2e/solid-router/scroll-restoration-sandbox-vite/src/routes/__root.tsx
index 9f76b37ef9f..65c67f94cb7 100644
--- a/e2e/solid-router/scroll-restoration-sandbox-vite/src/routes/__root.tsx
+++ b/e2e/solid-router/scroll-restoration-sandbox-vite/src/routes/__root.tsx
@@ -37,6 +37,7 @@ function Nav({ type }: { type: 'header' | 'footer' }) {
class: 'font-bold',
}}
activeOptions={{ exact: true }}
+ data-testid={`${prefix}-home-link`}
>
{prefix}-/
{' '}
@@ -54,6 +55,7 @@ function Nav({ type }: { type: 'header' | 'footer' }) {
activeProps={{
class: 'font-bold',
}}
+ data-testid={`${prefix}-${options.to}-link`}
>
{prefix}-{options.to}
diff --git a/e2e/solid-router/scroll-restoration-sandbox-vite/src/routes/index.tsx b/e2e/solid-router/scroll-restoration-sandbox-vite/src/routes/index.tsx
index ddb3755915b..50dbb3c9738 100644
--- a/e2e/solid-router/scroll-restoration-sandbox-vite/src/routes/index.tsx
+++ b/e2e/solid-router/scroll-restoration-sandbox-vite/src/routes/index.tsx
@@ -1,11 +1,13 @@
import { createFileRoute } from '@tanstack/solid-router'
-import { Link, linkOptions } from '@tanstack/solid-router'
+import { Link, linkOptions, useNavigate } from '@tanstack/solid-router'
export const Route = createFileRoute('/')({
component: HomeComponent,
})
function HomeComponent() {
+ const navigate = useNavigate()
+
return (
Welcome Home!
@@ -26,12 +28,59 @@ function HomeComponent() {
{options.to} tests
-
+
{options.to}#at-the-bottom
))}
+
+
scrollRestorationBehavior tests (Link)
+
+
+ /normal-page (smooth scroll)
+
+
+
+
+ /lazy-page (default scroll)
+
+
+
+
+
scrollRestorationBehavior tests (navigate)
+
+
+
+
+
+
+
)
}
diff --git a/e2e/solid-router/scroll-restoration-sandbox-vite/tests/app.spec.ts b/e2e/solid-router/scroll-restoration-sandbox-vite/tests/app.spec.ts
index 5f226c6db31..e809361e41f 100644
--- a/e2e/solid-router/scroll-restoration-sandbox-vite/tests/app.spec.ts
+++ b/e2e/solid-router/scroll-restoration-sandbox-vite/tests/app.spec.ts
@@ -20,7 +20,7 @@ test('Smoke - Renders home', async ({ page }) => {
page,
}) => {
await page.goto(toRuntimePath('/'))
- await page.getByRole('link', { name: `Head-${options.to}` }).click()
+ await page.getByTestId(`Head-${options.to}-link`).click()
await page.waitForTimeout(0)
await expect(page.getByTestId('at-the-top')).toBeInViewport()
})
@@ -30,9 +30,7 @@ test('Smoke - Renders home', async ({ page }) => {
page,
}) => {
await page.goto(toRuntimePath('/'))
- await page
- .getByRole('link', { name: `${options.to}#at-the-bottom` })
- .click()
+ await page.getByTestId(`index-${options.to}-hash-link`).click()
await page.waitForTimeout(0)
await expect(page.getByTestId('at-the-bottom')).toBeInViewport()
})
@@ -50,3 +48,223 @@ test('Smoke - Renders home', async ({ page }) => {
await expect(page.getByTestId('at-the-bottom')).toBeInViewport()
})
})
+
+// Test for scrollRestorationBehavior option
+test('scrollRestorationBehavior: smooth should pass behavior to window.scrollTo', async ({
+ page,
+}) => {
+ // Intercept window.scrollTo to capture calls with their options
+ await page.addInitScript(() => {
+ ;(window as any).__scrollToCalls = []
+ const originalScrollTo = window.scrollTo.bind(window)
+ window.scrollTo = ((...args: any[]) => {
+ ;(window as any).__scrollToCalls.push(args)
+ return originalScrollTo(...args)
+ }) as typeof window.scrollTo
+ })
+
+ // First go to /normal-page
+ await page.goto(toRuntimePath('/normal-page'))
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Scroll down a bit
+ await page.evaluate(() => window.scrollBy(0, 300))
+ await page.waitForTimeout(200)
+
+ // Go back to home
+ await page.getByTestId('Head-home-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'Welcome Home!' }),
+ ).toBeVisible()
+
+ // Clear any scroll calls from previous navigations
+ await page.evaluate(() => {
+ ;(window as any).__scrollToCalls = []
+ })
+
+ // Click link with scrollRestorationBehavior: 'smooth'
+ await page.getByTestId('smooth-scroll-link').click()
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Get the scroll calls and find the one with behavior option
+ const smoothCalls = await page.evaluate(() => (window as any).__scrollToCalls)
+
+ // There should be at least one scrollTo call with behavior: 'smooth'
+ const hasSmoothBehavior = smoothCalls.some(
+ (call: any) => call[0]?.behavior === 'smooth',
+ )
+ expect(hasSmoothBehavior).toBe(true)
+})
+
+test('scrollRestorationBehavior should reset to default after navigation', async ({
+ page,
+}) => {
+ // Intercept window.scrollTo to capture calls with their options
+ await page.addInitScript(() => {
+ ;(window as any).__scrollToCalls = []
+ const originalScrollTo = window.scrollTo.bind(window)
+ window.scrollTo = ((...args: any[]) => {
+ ;(window as any).__scrollToCalls.push(args)
+ return originalScrollTo(...args)
+ }) as typeof window.scrollTo
+ })
+
+ // First go to /normal-page
+ await page.goto(toRuntimePath('/normal-page'))
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Scroll down a bit
+ await page.evaluate(() => window.scrollBy(0, 300))
+ await page.waitForTimeout(200)
+
+ // Go back to home
+ await page.getByTestId('Head-home-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'Welcome Home!' }),
+ ).toBeVisible()
+
+ // Navigate with smooth scroll
+ await page.getByTestId('smooth-scroll-link').click()
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Scroll down a bit on normal-page
+ await page.evaluate(() => window.scrollBy(0, 300))
+ await page.waitForTimeout(200)
+
+ // Navigate back to home
+ await page.getByTestId('Head-home-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'Welcome Home!' }),
+ ).toBeVisible()
+
+ // Clear scroll calls
+ await page.evaluate(() => {
+ ;(window as any).__scrollToCalls = []
+ })
+
+ // Now navigate with default scroll (no scrollRestorationBehavior)
+ await page.getByTestId('default-scroll-link').click()
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Get the scroll calls - should NOT have behavior: 'smooth'
+ const defaultCalls = await page.evaluate(
+ () => (window as any).__scrollToCalls,
+ )
+
+ // The scrollTo calls should not have behavior: 'smooth'
+ // (they should have undefined behavior or no behavior property)
+ const hasSmoothBehavior = defaultCalls.some(
+ (call: any) => call[0]?.behavior === 'smooth',
+ )
+ expect(hasSmoothBehavior).toBe(false)
+})
+
+// Test for imperative navigate() with scrollRestorationBehavior option
+test('navigate() with scrollRestorationBehavior: smooth should pass behavior to window.scrollTo', async ({
+ page,
+}) => {
+ // Intercept window.scrollTo to capture calls with their options
+ await page.addInitScript(() => {
+ ;(window as any).__scrollToCalls = []
+ const originalScrollTo = window.scrollTo.bind(window)
+ window.scrollTo = ((...args: any[]) => {
+ ;(window as any).__scrollToCalls.push(args)
+ return originalScrollTo(...args)
+ }) as typeof window.scrollTo
+ })
+
+ // First go to /normal-page
+ await page.goto(toRuntimePath('/normal-page'))
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Scroll down a bit
+ await page.evaluate(() => window.scrollBy(0, 300))
+ await page.waitForTimeout(200)
+
+ // Go back to home
+ await page.getByTestId('Head-home-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'Welcome Home!' }),
+ ).toBeVisible()
+
+ // Clear any scroll calls from previous navigations
+ await page.evaluate(() => {
+ ;(window as any).__scrollToCalls = []
+ })
+
+ // Click button that uses navigate() with scrollRestorationBehavior: 'smooth'
+ await page.getByTestId('smooth-scroll-navigate-btn').click()
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Get the scroll calls and find the one with behavior option
+ const smoothCalls = await page.evaluate(() => (window as any).__scrollToCalls)
+
+ // There should be at least one scrollTo call with behavior: 'smooth'
+ const hasSmoothBehavior = smoothCalls.some(
+ (call: any) => call[0]?.behavior === 'smooth',
+ )
+ expect(hasSmoothBehavior).toBe(true)
+})
+
+test('navigate() scrollRestorationBehavior should reset to default after navigation', async ({
+ page,
+}) => {
+ // Intercept window.scrollTo to capture calls with their options
+ await page.addInitScript(() => {
+ ;(window as any).__scrollToCalls = []
+ const originalScrollTo = window.scrollTo.bind(window)
+ window.scrollTo = ((...args: any[]) => {
+ ;(window as any).__scrollToCalls.push(args)
+ return originalScrollTo(...args)
+ }) as typeof window.scrollTo
+ })
+
+ // First go to /normal-page
+ await page.goto(toRuntimePath('/normal-page'))
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Scroll down a bit
+ await page.evaluate(() => window.scrollBy(0, 300))
+ await page.waitForTimeout(200)
+
+ // Go back to home
+ await page.getByTestId('Head-home-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'Welcome Home!' }),
+ ).toBeVisible()
+
+ // Navigate with smooth scroll using navigate()
+ await page.getByTestId('smooth-scroll-navigate-btn').click()
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Scroll down a bit on normal-page
+ await page.evaluate(() => window.scrollBy(0, 300))
+ await page.waitForTimeout(200)
+
+ // Navigate back to home
+ await page.getByTestId('Head-home-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'Welcome Home!' }),
+ ).toBeVisible()
+
+ // Clear scroll calls
+ await page.evaluate(() => {
+ ;(window as any).__scrollToCalls = []
+ })
+
+ // Now navigate with default scroll using navigate() (no scrollRestorationBehavior)
+ await page.getByTestId('default-scroll-navigate-btn').click()
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Get the scroll calls - should NOT have behavior: 'smooth'
+ const defaultCalls = await page.evaluate(
+ () => (window as any).__scrollToCalls,
+ )
+
+ // The scrollTo calls should not have behavior: 'smooth'
+ // (they should have undefined behavior or no behavior property)
+ const hasSmoothBehavior = defaultCalls.some(
+ (call: any) => call[0]?.behavior === 'smooth',
+ )
+ expect(hasSmoothBehavior).toBe(false)
+})
diff --git a/e2e/vue-router/scroll-restoration-sandbox-vite/src/routes/__root.tsx b/e2e/vue-router/scroll-restoration-sandbox-vite/src/routes/__root.tsx
index 113b932e872..c57be5b5c8d 100644
--- a/e2e/vue-router/scroll-restoration-sandbox-vite/src/routes/__root.tsx
+++ b/e2e/vue-router/scroll-restoration-sandbox-vite/src/routes/__root.tsx
@@ -36,6 +36,7 @@ function Nav({ type }: { type: 'header' | 'footer' }) {
class: 'font-bold',
}}
activeOptions={{ exact: true }}
+ data-testid={`${prefix}-home-link`}
>
{prefix}-/
{' '}
@@ -53,6 +54,7 @@ function Nav({ type }: { type: 'header' | 'footer' }) {
activeProps={{
class: 'font-bold',
}}
+ data-testid={`${prefix}-${options.to}-link`}
>
{prefix}-{options.to}
diff --git a/e2e/vue-router/scroll-restoration-sandbox-vite/src/routes/index.tsx b/e2e/vue-router/scroll-restoration-sandbox-vite/src/routes/index.tsx
index fe9e21680a1..28cbf405d10 100644
--- a/e2e/vue-router/scroll-restoration-sandbox-vite/src/routes/index.tsx
+++ b/e2e/vue-router/scroll-restoration-sandbox-vite/src/routes/index.tsx
@@ -1,11 +1,13 @@
import { createFileRoute } from '@tanstack/vue-router'
-import { Link, linkOptions } from '@tanstack/vue-router'
+import { Link, linkOptions, useNavigate } from '@tanstack/vue-router'
export const Route = createFileRoute('/')({
component: HomeComponent,
})
function HomeComponent() {
+ const navigate = useNavigate()
+
return (
Welcome Home!
@@ -26,12 +28,59 @@ function HomeComponent() {
{options.to} tests
-
+
{options.to}#at-the-bottom
))}
+
+
scrollRestorationBehavior tests (Link)
+
+
+ /normal-page (smooth scroll)
+
+
+
+
+ /lazy-page (default scroll)
+
+
+
+
+
scrollRestorationBehavior tests (navigate)
+
+
+
+
+
+
+
)
}
diff --git a/e2e/vue-router/scroll-restoration-sandbox-vite/tests/app.spec.ts b/e2e/vue-router/scroll-restoration-sandbox-vite/tests/app.spec.ts
index 5f226c6db31..e809361e41f 100644
--- a/e2e/vue-router/scroll-restoration-sandbox-vite/tests/app.spec.ts
+++ b/e2e/vue-router/scroll-restoration-sandbox-vite/tests/app.spec.ts
@@ -20,7 +20,7 @@ test('Smoke - Renders home', async ({ page }) => {
page,
}) => {
await page.goto(toRuntimePath('/'))
- await page.getByRole('link', { name: `Head-${options.to}` }).click()
+ await page.getByTestId(`Head-${options.to}-link`).click()
await page.waitForTimeout(0)
await expect(page.getByTestId('at-the-top')).toBeInViewport()
})
@@ -30,9 +30,7 @@ test('Smoke - Renders home', async ({ page }) => {
page,
}) => {
await page.goto(toRuntimePath('/'))
- await page
- .getByRole('link', { name: `${options.to}#at-the-bottom` })
- .click()
+ await page.getByTestId(`index-${options.to}-hash-link`).click()
await page.waitForTimeout(0)
await expect(page.getByTestId('at-the-bottom')).toBeInViewport()
})
@@ -50,3 +48,223 @@ test('Smoke - Renders home', async ({ page }) => {
await expect(page.getByTestId('at-the-bottom')).toBeInViewport()
})
})
+
+// Test for scrollRestorationBehavior option
+test('scrollRestorationBehavior: smooth should pass behavior to window.scrollTo', async ({
+ page,
+}) => {
+ // Intercept window.scrollTo to capture calls with their options
+ await page.addInitScript(() => {
+ ;(window as any).__scrollToCalls = []
+ const originalScrollTo = window.scrollTo.bind(window)
+ window.scrollTo = ((...args: any[]) => {
+ ;(window as any).__scrollToCalls.push(args)
+ return originalScrollTo(...args)
+ }) as typeof window.scrollTo
+ })
+
+ // First go to /normal-page
+ await page.goto(toRuntimePath('/normal-page'))
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Scroll down a bit
+ await page.evaluate(() => window.scrollBy(0, 300))
+ await page.waitForTimeout(200)
+
+ // Go back to home
+ await page.getByTestId('Head-home-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'Welcome Home!' }),
+ ).toBeVisible()
+
+ // Clear any scroll calls from previous navigations
+ await page.evaluate(() => {
+ ;(window as any).__scrollToCalls = []
+ })
+
+ // Click link with scrollRestorationBehavior: 'smooth'
+ await page.getByTestId('smooth-scroll-link').click()
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Get the scroll calls and find the one with behavior option
+ const smoothCalls = await page.evaluate(() => (window as any).__scrollToCalls)
+
+ // There should be at least one scrollTo call with behavior: 'smooth'
+ const hasSmoothBehavior = smoothCalls.some(
+ (call: any) => call[0]?.behavior === 'smooth',
+ )
+ expect(hasSmoothBehavior).toBe(true)
+})
+
+test('scrollRestorationBehavior should reset to default after navigation', async ({
+ page,
+}) => {
+ // Intercept window.scrollTo to capture calls with their options
+ await page.addInitScript(() => {
+ ;(window as any).__scrollToCalls = []
+ const originalScrollTo = window.scrollTo.bind(window)
+ window.scrollTo = ((...args: any[]) => {
+ ;(window as any).__scrollToCalls.push(args)
+ return originalScrollTo(...args)
+ }) as typeof window.scrollTo
+ })
+
+ // First go to /normal-page
+ await page.goto(toRuntimePath('/normal-page'))
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Scroll down a bit
+ await page.evaluate(() => window.scrollBy(0, 300))
+ await page.waitForTimeout(200)
+
+ // Go back to home
+ await page.getByTestId('Head-home-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'Welcome Home!' }),
+ ).toBeVisible()
+
+ // Navigate with smooth scroll
+ await page.getByTestId('smooth-scroll-link').click()
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Scroll down a bit on normal-page
+ await page.evaluate(() => window.scrollBy(0, 300))
+ await page.waitForTimeout(200)
+
+ // Navigate back to home
+ await page.getByTestId('Head-home-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'Welcome Home!' }),
+ ).toBeVisible()
+
+ // Clear scroll calls
+ await page.evaluate(() => {
+ ;(window as any).__scrollToCalls = []
+ })
+
+ // Now navigate with default scroll (no scrollRestorationBehavior)
+ await page.getByTestId('default-scroll-link').click()
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Get the scroll calls - should NOT have behavior: 'smooth'
+ const defaultCalls = await page.evaluate(
+ () => (window as any).__scrollToCalls,
+ )
+
+ // The scrollTo calls should not have behavior: 'smooth'
+ // (they should have undefined behavior or no behavior property)
+ const hasSmoothBehavior = defaultCalls.some(
+ (call: any) => call[0]?.behavior === 'smooth',
+ )
+ expect(hasSmoothBehavior).toBe(false)
+})
+
+// Test for imperative navigate() with scrollRestorationBehavior option
+test('navigate() with scrollRestorationBehavior: smooth should pass behavior to window.scrollTo', async ({
+ page,
+}) => {
+ // Intercept window.scrollTo to capture calls with their options
+ await page.addInitScript(() => {
+ ;(window as any).__scrollToCalls = []
+ const originalScrollTo = window.scrollTo.bind(window)
+ window.scrollTo = ((...args: any[]) => {
+ ;(window as any).__scrollToCalls.push(args)
+ return originalScrollTo(...args)
+ }) as typeof window.scrollTo
+ })
+
+ // First go to /normal-page
+ await page.goto(toRuntimePath('/normal-page'))
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Scroll down a bit
+ await page.evaluate(() => window.scrollBy(0, 300))
+ await page.waitForTimeout(200)
+
+ // Go back to home
+ await page.getByTestId('Head-home-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'Welcome Home!' }),
+ ).toBeVisible()
+
+ // Clear any scroll calls from previous navigations
+ await page.evaluate(() => {
+ ;(window as any).__scrollToCalls = []
+ })
+
+ // Click button that uses navigate() with scrollRestorationBehavior: 'smooth'
+ await page.getByTestId('smooth-scroll-navigate-btn').click()
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Get the scroll calls and find the one with behavior option
+ const smoothCalls = await page.evaluate(() => (window as any).__scrollToCalls)
+
+ // There should be at least one scrollTo call with behavior: 'smooth'
+ const hasSmoothBehavior = smoothCalls.some(
+ (call: any) => call[0]?.behavior === 'smooth',
+ )
+ expect(hasSmoothBehavior).toBe(true)
+})
+
+test('navigate() scrollRestorationBehavior should reset to default after navigation', async ({
+ page,
+}) => {
+ // Intercept window.scrollTo to capture calls with their options
+ await page.addInitScript(() => {
+ ;(window as any).__scrollToCalls = []
+ const originalScrollTo = window.scrollTo.bind(window)
+ window.scrollTo = ((...args: any[]) => {
+ ;(window as any).__scrollToCalls.push(args)
+ return originalScrollTo(...args)
+ }) as typeof window.scrollTo
+ })
+
+ // First go to /normal-page
+ await page.goto(toRuntimePath('/normal-page'))
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Scroll down a bit
+ await page.evaluate(() => window.scrollBy(0, 300))
+ await page.waitForTimeout(200)
+
+ // Go back to home
+ await page.getByTestId('Head-home-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'Welcome Home!' }),
+ ).toBeVisible()
+
+ // Navigate with smooth scroll using navigate()
+ await page.getByTestId('smooth-scroll-navigate-btn').click()
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Scroll down a bit on normal-page
+ await page.evaluate(() => window.scrollBy(0, 300))
+ await page.waitForTimeout(200)
+
+ // Navigate back to home
+ await page.getByTestId('Head-home-link').click()
+ await expect(
+ page.getByRole('heading', { name: 'Welcome Home!' }),
+ ).toBeVisible()
+
+ // Clear scroll calls
+ await page.evaluate(() => {
+ ;(window as any).__scrollToCalls = []
+ })
+
+ // Now navigate with default scroll using navigate() (no scrollRestorationBehavior)
+ await page.getByTestId('default-scroll-navigate-btn').click()
+ await expect(page.getByTestId('at-the-top')).toBeInViewport()
+
+ // Get the scroll calls - should NOT have behavior: 'smooth'
+ const defaultCalls = await page.evaluate(
+ () => (window as any).__scrollToCalls,
+ )
+
+ // The scrollTo calls should not have behavior: 'smooth'
+ // (they should have undefined behavior or no behavior property)
+ const hasSmoothBehavior = defaultCalls.some(
+ (call: any) => call[0]?.behavior === 'smooth',
+ )
+ expect(hasSmoothBehavior).toBe(false)
+})
diff --git a/packages/history/src/index.ts b/packages/history/src/index.ts
index b8151bdf0e0..b732037ac3a 100644
--- a/packages/history/src/index.ts
+++ b/packages/history/src/index.ts
@@ -72,6 +72,8 @@ export type ParsedHistoryState = HistoryState & {
__TSR_index: number
/** Whether to reset scroll position on this navigation (default: true) */
__TSR_resetScroll?: boolean
+ /** Scroll restoration behavior override for this navigation */
+ __TSR_scrollRestorationBehavior?: ScrollBehavior
/** Session id for cached TSR internals */
__TSR_sessionId?: string
/** Match snapshot for fast-path on back/forward navigation */
diff --git a/packages/router-core/src/RouterProvider.ts b/packages/router-core/src/RouterProvider.ts
index 52aeaffabb3..abf937ad0b1 100644
--- a/packages/router-core/src/RouterProvider.ts
+++ b/packages/router-core/src/RouterProvider.ts
@@ -20,6 +20,7 @@ export interface CommitLocationOptions {
**/
startTransition?: boolean
ignoreBlocker?: boolean
+ scrollRestorationBehavior?: ScrollBehavior
}
export type NavigateFn = <
diff --git a/packages/router-core/src/link.ts b/packages/router-core/src/link.ts
index 1931a55a508..af8595069e5 100644
--- a/packages/router-core/src/link.ts
+++ b/packages/router-core/src/link.ts
@@ -348,6 +348,12 @@ export interface NavigateOptionProps {
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/NavigateOptionsType#reloaddocument)
*/
reloadDocument?: boolean
+ /**
+ * The scroll behavior to use for scroll restoration on this navigation.
+ * If provided, this will override `router.options.scrollRestorationBehavior` for this navigation only.
+ * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/NavigateOptionsType#scrollrestorationbehavior)
+ */
+ scrollRestorationBehavior?: ScrollBehavior
/**
* This can be used instead of `to` to navigate to a fully built href, e.g. pointing to an external target.
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/NavigateOptionsType#href)
diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts
index 6815bcdf322..260c20204e9 100644
--- a/packages/router-core/src/router.ts
+++ b/packages/router-core/src/router.ts
@@ -1960,6 +1960,7 @@ export class RouterCore<
commitLocation: CommitLocationFn = async ({
viewTransition,
ignoreBlocker,
+ scrollRestorationBehavior,
...next
}) => {
const isSameState = () => {
@@ -2035,6 +2036,10 @@ export class RouterCore<
nextHistory.state.__hashScrollIntoViewOptions =
hashScrollIntoView ?? this.options.defaultHashScrollIntoView ?? true
+ // Store scrollRestorationBehavior in history state so it survives back/forward navigation
+ nextHistory.state.__TSR_scrollRestorationBehavior =
+ scrollRestorationBehavior
+
// Store resetScroll in history state so it survives back/forward navigation
nextHistory.state.__TSR_resetScroll = next.resetScroll ?? true
@@ -2102,6 +2107,7 @@ export class RouterCore<
hashScrollIntoView,
viewTransition,
ignoreBlocker,
+ scrollRestorationBehavior,
href,
...rest
}: BuildNextOptions & CommitLocationOptions = {}) => {
@@ -2140,6 +2146,7 @@ export class RouterCore<
resetScroll,
hashScrollIntoView,
ignoreBlocker,
+ scrollRestorationBehavior,
})
// Clear pending location after commit starts
diff --git a/packages/router-core/src/scroll-restoration.ts b/packages/router-core/src/scroll-restoration.ts
index 88eec467753..da1bc5e1ad8 100644
--- a/packages/router-core/src/scroll-restoration.ts
+++ b/packages/router-core/src/scroll-restoration.ts
@@ -356,7 +356,9 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) {
restoreScroll({
storageKey,
key: cacheKey,
- behavior: router.options.scrollRestorationBehavior,
+ behavior:
+ event.toLocation.state.__TSR_scrollRestorationBehavior ??
+ router.options.scrollRestorationBehavior,
shouldScrollRestoration: router.isScrollRestoring,
scrollToTopSelectors: router.options.scrollToTopSelectors,
location: router.history.location,