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,